Exercise 1 — The legal bases
- The classification: email (contract: the sale), optional phone (consent: only if the org wants SMS notices), purchase history (contract + fiscal obligation: the retention), login IP (legitimate interest: security, 23), the analytics cookie (consent BEFORE loading it: the banner), the org's photo (consent + the content's lawfulness). The table with the basis per row: the datum without a basis does not get collected (minimization).
- The opt-in's evidence:
class MarketingConsent(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
version_texto = models.CharField(max_length=20) # "v2027-03"
concedido_en = models.DateTimeField()
revocado_en = models.DateTimeField(null=True)
# marketing's filter: ACTIVE consent (granted and not revoked) — the derived flag, not the loose boolean- The extended erasure flow: the profile and contacts get DELETED; the ticket/ledger's email gets ANONYMIZED (
u-841@deleted.invalid— the invoicing history's integrity preserved) and the audit records "erasure executed per request X, 3 tables, 2 anonymized". The test: the later export without PII + the ledger balancing (the month's commission identical): the money's legal exception works without breaking the invoicing.
Exercise 2 — The Art. 30 map
- The map's findings: 45's log: technical identifiers without PII (basis: legitimate interest, 30-day retention); the profile cache (38): PII in Redis → contract basis + per-user purge (23) + 60 s TTL (the datum expiring by itself: retention in the cache); the event's guests table (the free-text field of the companions' names): unclassified → classified (contract, retention event end + fiscal).
- The PII guard: the test scanning active cache keys and sample log lines against the email/phone regexes: 0 matches. The typical finding: 26's detail with the email the user mistyped (the error log carried the payload: fixed with the identifier).
- The map's doc with the quarterly review and the owner: the Art. 30 document with the link to each schema (48: doc-linked-to-code).
Exercise 3 — The SOC 2 evidences
- The 3 evidences (repo scripts):
manage.py reporte_accesos --desde 2027-07 # the role escalations of the audit (23/21)
manage.py reporte_secretos # 27's rotations with date and actor
manage.py reporte_cambios --sin-review # the deploys without a PR: expected 0 (41)- The change chain proven: direct push to main → blocked (branch protection: 41's PR + CI); the
kubectl applyoutside the pipeline → 44's drift diff catches it at the next plan; the emergency hotfix → emergency PR with the documented later review (47). The change controls with evidence: the CI + the IaC. - The honest gap: the quarterly access review (who has access they no longer need?) did not exist → the issue with owner/date (exercise 1's script as its base). The declared gap: the control on its way.
Exercise 4 — The audit log and the breach
- The auditor's queries with their test:
def test_consulta_de_datos_de_usuario_solo_admin(self):
self.client.force_authenticate(self.soporte) # NOT an auditor
r = self.client.get(f"/admin/audit/dato/{self.user.id}")
self.assertEqual(r.status_code, 403) # 21: audit access is privileged
self.client.force_authenticate(self.auditor)
self.assertEqual(self.client.get(...).status_code, 200) # and the query got audited (the meta-audit!)- Retention as a job: with FakeClock 13 months: the audit row > 12 months archives to cold (42's bucket) and the purge's record lands in audit. The revoked consent: the marketing datum purged in the next cycle with its row ("purged by revocation").
- The breach runbook (excerpt): T0 detect (46's alert) → T+1h scope (47: protocol) → T+2h contain (41's flag/rollback) → T+24h assess risk (high? → notify the affected) → T+72h notify the regulator (the doc's template) → postmortem with the regulatory action. The written thresholds: "sensitive data or >100 affected: the affected always; the rest: the DPO's assessment" — the decision documented before the clock.
Exercise 5 — The internal DPO
- The schema's linter:
def test_toda_tabla_con_pii_clasificada(self):
for model in MODELOS_CON_DATOS_PERSONALES:
assert hasattr(model, "data_classification"), \
f"{model.__name__} without legal basis/retention: the Art. 30 map lies"The data_classification = {"base": "contrato", "retencion": "5y"} in the Meta: the schema annotates its compliance (§2's map generated from code: 48's living reference).
- The internal DPO's diagram:
[Art.30 map generated from the schema] ← the classification linter (1)
[export/erasure API] → audit ← SLA minutes (23)
[retention jobs 31] → audit ← automatic purges (4)
[SOC 2 evidences] → cron reports (3)
[breach runbook] → doc with expiry (48)- The final checklist (10 items): legal bases, generated map, consent with evidence, erasure with the money's exception, portability, PII in log/cache guarded, auditor queries, automated retention, breach runbook, SOC 2 evidences (3 of 5: the access review in issue). The real status: 9 of 10 — compliance as the course's engineering consequence, with the remaining gap documented.
Professor's summary
- GDPR gets implemented in the data's cycle: legal basis per datum, minimization, retention by type, and the erasure respecting the money's exception (anonymize, don't delete the invoicing).
- SOC 2 audits controls operating: the course already produces the evidences (PRs, rotations, drills, postmortems) — 80% of the certificate is organizing good engineering.
- The append-only audit log + the auditor's queries + the retention jobs + the classification linter = the internal DPO: compliance as the system's observable consequence.