# DPDPA Consent Management API — Implementation Checklist

**6-week rollout plan: from prerequisites to production hardening.**

Applicable to all Data Fiduciaries under Section 8(5). Steps marked **[SDF]** apply
additionally to Significant Data Fiduciaries. Legal anchors are cited per phase.

---

## How to Use This Checklist

1. **Assign an owner** to each phase before the phase begins
2. **Check off items** as you complete them — never mark done until the gate criterion is met
3. **Never skip the hardening phase** (Week 6) — an unrotated KMS key and an unverified chain are Section 6(10) liabilities
4. **Review trigger:** Repeat Phase 2 onwards for any schema migration, notice version change, or new processor onboarding

---

## Phase 0 — Prerequisites (Week 1)

**Owner: Engineering Lead + Platform Engineering**

This phase establishes the infrastructure dependencies. Nothing in Phases 1–5 is deployable without these.

### 0.1 Database

- [ ] PostgreSQL 15 or later confirmed in target environment
- [ ] `pgcrypto` extension available (`CREATE EXTENSION IF NOT EXISTS pgcrypto;` succeeds)
- [ ] Database user with `CREATE TABLE`, `CREATE FUNCTION`, `CREATE TRIGGER` privileges
- [ ] Partition management strategy confirmed: who creates monthly partitions, and how far in advance
- [ ] Backup and point-in-time recovery (PITR) tested — consent records are legal evidence

**Section 8(5):** PITR is a "reasonable security safeguard." A consent DB that cannot be recovered is a Rule 6(1)(d) availability failure.

### 0.2 Cache Infrastructure

- [ ] Redis 7+ (or equivalent) available in target region(s)
- [ ] Redis connection pooling configured; max connection limits set
- [ ] CDN (CloudFront, Fastly, Cloudflare) with cache key support for `(purpose_id, notice_version_id, language_code)` tuples
- [ ] CDN purge mechanism tested — required when a notice version is superseded
- [ ] Cache warm-up runbook written for new regions

### 0.3 KMS (Key Management Service)

- [ ] KMS provider selected: AWS KMS / GCP Cloud KMS / Azure Key Vault
- [ ] Ed25519 signing key created and ARN/resource path documented
  - This key signs consent receipts — losing it breaks Section 6(10) verifiability
- [ ] HMAC-SHA256 key created for principal pseudonymisation (PseudonymService)
  - Separate from signing key; annual rotation schedule documented
- [ ] KMS IAM policy: only the consent API service account has Sign/Verify access
- [ ] Public key export URL configured at `/.well-known/consent-signing-key.pub`
  - Required for offline receipt verification (auditors, Data Protection Board)
- [ ] Key rotation runbook written (see Phase 5 for rotation procedure)

**Section 6(10):** The published signing public key is the auditor's trust anchor. It must be stable and versioned.

### 0.4 Translator Workflow

- [ ] Approved translation vendor or in-house translators identified for all 23 languages
- [ ] Ed25519 translator signing keys created per translator (not per language)
- [ ] Translator signing key registry documented (translator_id → public key)
- [ ] Translation submission workflow defined:
  1. Translator receives source content + notice_version_id
  2. Translator produces translated text + Ed25519 signature over SHA-256(content)
  3. Engineering ingests into `notice_translation` table
- [ ] Initial translations commissioned for Priority 1 languages: hi, ta, te, bn, mr, gu (at minimum)

**Section 5(3):** All 23 languages must be available on request. Lower-traffic languages (sa, sat, brx, doi, mni) should follow within 30 days of Phase 2 completion.

### 0.5 Environment Validation

```bash
# Confirm PostgreSQL version
psql -c "SELECT version();" | grep "PostgreSQL 1[5-9]"

# Confirm pgcrypto
psql -c "SELECT digest('test', 'sha256')::text;"

# Confirm Redis
redis-cli ping  # PONG

# Confirm KMS access (AWS example)
aws kms describe-key --key-id $SIGNING_KEY_ARN --query 'KeyMetadata.KeySpec'
# Expected: "ECC_NIST_P256" or "ED25519" depending on provider
```

- [ ] All validation commands pass before proceeding

**Deliverable:** All infrastructure dependencies confirmed operational; KMS keys created; translator workflow documented.

---

## Phase 1 — Schema Deployment (Week 2)

**Owner: Engineering Lead + DBA**

### 1.1 Deploy DDL

- [ ] Run `schema/consent-schema.sql` against the target database in a transaction
- [ ] Confirm all 6 tables created: `purpose`, `notice_version`, `notice_translation`, `consent_record`, `consent_audit_event`, `processor_fanout`
- [ ] Confirm 3 partitions created: `consent_audit_event_2026_04`, `_2026_05`, `_2026_06`
- [ ] Confirm all indexes created (10 indexes total — verify with `\di consent*`)
- [ ] Confirm `prevent_consent_mutation()` function and trigger installed

```sql
-- Verify trigger exists
SELECT trigger_name, event_manipulation, action_statement
FROM information_schema.triggers
WHERE event_object_table = 'consent_record';
-- Expected: trg_prevent_consent_mutation | UPDATE | EXECUTE FUNCTION prevent_consent_mutation()
```

- [ ] Confirm `append_audit_event()` function installed

```sql
-- Test append_audit_event
BEGIN;
INSERT INTO purpose (name, description, legal_basis, data_categories)
  VALUES ('test', 'test', 'consent', ARRAY['contact']) RETURNING purpose_id;
-- Use returned purpose_id to create a test consent_record, then:
SELECT append_audit_event('<consent_id>', 'NOTICE_SHOWN', '{"test": true}');
SELECT event_id, event_type, this_event_hash IS NOT NULL AS has_hash
FROM consent_audit_event WHERE consent_id = '<consent_id>';
ROLLBACK;
```

- [ ] Confirm `v_consent_current_status` view created and returns rows for test data

### 1.2 Verify Trigger Immutability

```sql
-- Insert a test consent_record with status = 'granted'
INSERT INTO consent_record (..., status) VALUES (..., 'granted');

-- Attempt forbidden mutation (must RAISE EXCEPTION)
DO $$
BEGIN
  UPDATE consent_record SET status = 'granted' WHERE consent_id = '<id>';
  RAISE NOTICE 'ERROR: mutation was not blocked';
EXCEPTION WHEN OTHERS THEN
  RAISE NOTICE 'PASS: mutation correctly blocked: %', SQLERRM;
END;
$$;

-- Verify permitted transition works
UPDATE consent_record
SET status = 'withdrawn', withdrawn_at = NOW()
WHERE consent_id = '<id>' AND status = 'granted';
-- Expected: 1 row updated (no exception)
```

- [ ] Trigger blocks invalid mutations with `consent_mutation_error` exception
- [ ] Trigger allows `granted → withdrawn` transition
- [ ] Trigger allows `withdrawn → erased` transition

### 1.3 Seed Reference Data

- [ ] Insert at least 1 row per active processing purpose into `purpose` table
- [ ] Insert `notice_version` rows for each purpose
- [ ] Insert `notice_translation` rows for all available language translations
  - Minimum for launch: hi, ta, te, bn, mr, gu, en (7 languages)
  - Remaining 16 must follow within 30 days (Section 5(3) obligation)
- [ ] Verify `canonical_hash` on each `notice_version` matches SHA-256 of its translations bundle

```sql
-- Verify no active notice version is missing English translation
SELECT nv.notice_version_id, nv.purpose_id
FROM notice_version nv
LEFT JOIN notice_translation nt
  ON nt.notice_version_id = nv.notice_version_id AND nt.language_code = 'en'
WHERE nv.superseded_at IS NULL AND nt.translation_id IS NULL;
-- Expected: 0 rows
```

- [ ] Zero missing English translations for active notice versions

**Deliverable:** Schema deployed; triggers verified; reference data seeded; immutability gates passing.

---

## Phase 2 — Notice API and Translation Workflow (Week 3)

**Owner: Backend Engineering**

### 2.1 Implement GET /v1/notices/{purpose_id}

Using `api-spec/consent-management-api.yaml` as the contract:

- [ ] Endpoint returns correct `NoticeBundle` schema for `(purpose_id, lang)` tuples
- [ ] Returns `404 notice_not_found` when no active version exists for purpose_id
- [ ] Returns `400 unsupported_language` for unrecognised BCP-47 codes
- [ ] `content_hash` in response matches `sha256(rendered_content.encode('utf-8'))`
- [ ] `Cache-Control: public, max-age=31536000, immutable` header present on 200 responses
- [ ] `ETag` header set to `notice_version_id`

### 2.2 Three-Tier Cache

- [ ] Redis cache: key = `notice:{notice_version_id}:{language_code}`, TTL = 86400s (24h)
- [ ] CDN cache: key = `(purpose_id, notice_version_id, language_code)`, TTL = 31536000s
- [ ] Cache-miss path: PostgreSQL lookup → canonicalise → compute hash → populate both caches
- [ ] Cache invalidation tested: create new `notice_version` → old CDN key naturally expires; new key serves immediately

```bash
# Smoke test: Tamil notice served in <50ms p99 after warm
wrk -t4 -c100 -d30s "https://consent.staging.example.com/v1/notices/<purpose_id>?lang=ta"
# Latency p99 target: <50ms
```

- [ ] p99 latency <50ms for cached notice requests

### 2.3 Load All 23 Languages

- [ ] Trigger translation delivery for remaining languages from vendor/in-house translators
- [ ] Each translation ingested into `notice_translation` with valid `translator_signature`
- [ ] Integration test: request all 23 language codes and confirm 200 for each

```python
# Test all 23 languages return 200
from consent_receipt import EIGHTH_SCHEDULE_LANGUAGES
import requests

for lang in EIGHTH_SCHEDULE_LANGUAGES:
    resp = requests.get(f"/v1/notices/{PURPOSE_ID}?lang={lang}", headers=auth)
    assert resp.status_code == 200, f"Missing translation for {lang}"
    body = resp.json()
    # Verify content_hash integrity
    import hashlib
    computed = hashlib.sha256(body["content"].encode("utf-8")).hexdigest()
    assert computed == body["content_hash"], f"Hash mismatch for {lang}"
```

- [ ] All 23 languages return 200 with valid content_hash
- [ ] Translations verified by re-hashing rendered content locally

**Deliverable:** Notice API live in staging; all 23 languages available; cache tiers operational; p99 <50ms confirmed.

---

## Phase 3 — Consent Grant Endpoint (Week 4)

**Owner: Backend Engineering + Security Engineering**

### 3.1 Implement POST /v1/consents

- [ ] Endpoint validates `notice_version_id` is active (superseded_at IS NULL)
- [ ] Returns `409 notice_version_superseded` if version is no longer active
- [ ] Endpoint validates `content_hash_shown` matches `notice_translation.content_hash` for `(notice_version_id, language_shown)`
- [ ] Returns `409 content_hash_mismatch` if hashes differ
- [ ] Returns `422 unsupported_language` if translation does not exist
- [ ] Returns `409 duplicate_active_consent` if active grant already exists for `(principal_id, purpose_id)`

### 3.2 Consent Receipt Signing

- [ ] `ConsentReceiptBuilder` from `src/consent_receipt.py` integrated with KMS signing key
- [ ] `principal_id` is pseudonymised via `PseudonymService` before inclusion in receipt
- [ ] Receipt fields match `ConsentReceipt` schema in OpenAPI spec
- [ ] `granted_at` uses `datetime.now(timezone.utc)` — never `datetime.utcnow()`
- [ ] Receipt verification test: `ConsentReceiptBuilder.verify(receipt, public_key_pem)` returns `True` for all issued receipts

### 3.3 Atomic Transaction

The following must commit atomically or roll back entirely — a partial insert violates Section 6(10):

- [ ] `INSERT INTO consent_record` (status = 'granted')
- [ ] `SELECT append_audit_event(consent_id, 'NOTICE_SHOWN', {...})`
- [ ] `SELECT append_audit_event(consent_id, 'GRANT', {receipt_hash, notice_version_id, language_shown, content_hash})`

```python
# Verify atomicity: simulate a transaction abort mid-way
# and confirm no partial consent_record exists afterward
```

- [ ] Confirmed: no partial inserts survive a transaction rollback

### 3.4 Audit Event Payloads

Verify `GRANT` event payload contains:

- [ ] `receipt_hash` (hex) — links to the signed receipt
- [ ] `notice_version_id` — the version that was shown
- [ ] `language_shown` — the BCP-47 language code (Section 5(3))
- [ ] `content_hash` — the exact bytes that were shown (Section 6(10))
- [ ] No `principal_id` or direct identifier in any audit payload

**Section 6(10):** The GRANT audit event must carry enough to reconstruct the evidence without the receipt itself.

**Deliverable:** POST /consents live; receipts verifiable offline; atomic transaction confirmed; audit payloads validated.

---

## Phase 4 — Withdrawal and Erasure Fanout (Week 5)

**Owner: Backend Engineering + Platform Engineering**

### 4.1 Implement DELETE /v1/consents/{consent_id}

- [ ] Endpoint transitions `status = 'withdrawn'`, sets `withdrawn_at = NOW()`
- [ ] `prevent_consent_mutation()` trigger allows this transition and blocks others
- [ ] `WITHDRAW` audit event appended via `append_audit_event()` — payload includes `initiated_by`
- [ ] Returns `202 Accepted` immediately (erasure fanout is async)
- [ ] Returns `409 consent_already_withdrawn` if status is not 'granted'

### 4.2 Processor Erasure Fanout

- [ ] `processor_fanout` rows created for each registered processor at time of withdrawal
- [ ] Erasure requests dispatched to each processor's erasure endpoint
- [ ] `ERASURE_FANOUT` audit event appended per processor (one event per fanout row)
- [ ] Background worker polls `processor_fanout WHERE acknowledgement_status IN ('pending','sent')`
- [ ] Worker retries failed dispatches with exponential backoff (max 3 retries before alerting)

### 4.3 Processor Acknowledgement

- [ ] Processor acknowledgement endpoint (`POST /internal/erasure/ack`) updates `processor_fanout.acknowledgement_status = 'acked'`
- [ ] `ERASURE_CONFIRMED` audit event appended on each ack
- [ ] When all processor_fanout rows for a consent reach 'acked': `consent_record.erasure_confirmed_at = NOW()` and `status = 'erased'`
- [ ] `prevent_consent_mutation()` trigger allows `withdrawn → erased` with `erasure_confirmed_at` set

**Section 8(7):** "cause its Data Processor to erase any personal data that was made available by the Data Fiduciary for processing to such Data Processor." Every processor must be in the fanout registry before the consent system goes live.

### 4.4 Stuck Processor Monitoring

- [ ] Alert fires if any `processor_fanout` row remains in `pending` or `sent` status for >24 hours after withdrawal
- [ ] Runbook written for manual escalation to processor for delayed acks
- [ ] Alert fires if `retry_count >= 3` (processor may be offline or non-compliant)

**Deliverable:** Withdrawal endpoint live; fanout dispatches confirmed; ack pipeline tested end-to-end; monitoring alerts active.

---

## Phase 5 — Hardening (Week 6)

**Owner: Security Engineering + Platform Engineering + DPO**

### 5.1 Audit Chain Verifier

Deploy the `HashChainAuditLog.verify_chain()` verification as a scheduled job:

- [ ] Nightly batch verifier walks all `consent_audit_event` rows for the previous 24 hours
- [ ] Any `broken_links` result triggers a P1 alert (potential tampering under Section 6(10))
- [ ] Monthly full-chain verification run across all partitions in the retention window
- [ ] Verifier output logged to immutable append-only store (separate from the consent DB)

```python
from consent_receipt import HashChainAuditLog
import psycopg

log = HashChainAuditLog()

# Fetch events for all consents modified in the last 24h
events = fetch_recent_audit_events(db_conn, hours=24)

# Group by consent_id and verify each chain
for consent_id, chain_events in group_by_consent_id(events):
    is_valid, broken = log.verify_chain(chain_events)
    if not broken:
        continue
    for link in broken:
        alert(f"CHAIN INTEGRITY FAILURE consent_id={consent_id}: {link}")
```

**Acceptance gate — run before deploying the nightly verifier:**

```bash
# Requires Docker running
pip install pytest testcontainers[postgresql] psycopg2-binary cryptography
pytest tests/test_audit_chain.py -v
```

All 5 tests must pass. If `test_nested_grant_payload` or `test_non_ascii_payload_value`
fail, `verify_chain()` will raise P1 alerts on every production consent event.

- [ ] Round-trip integration test suite passes (`pytest tests/test_audit_chain.py -v`)
- [ ] Nightly verifier deployed and tested
- [ ] P1 alert integration confirmed (PagerDuty / OpsGenie / equivalent)

### 5.2 KMS Key Rotation Procedure

**Signing Key (consent receipt verification):**

Rotating the signing key changes the public key auditors use to verify receipts.
Old receipts remain verifiable only with the old public key. The procedure:

- [ ] Generate new Ed25519 signing key in KMS
- [ ] Publish new public key at `/.well-known/consent-signing-key.pub` (versioned, e.g. `?v=2`)
- [ ] Update API to sign new receipts with new key
- [ ] Retain old public key at `/.well-known/consent-signing-key-v1.pub` indefinitely
  - Old receipts reference receipt_hash + signature; verifiers must use the key that was active at `granted_at`
- [ ] Document key version range (key v1 was active 2026-04-01 to 2027-03-31, etc.)

**HMAC Pseudonymisation Key:**

- [ ] Generate new HMAC key in KMS
- [ ] New pseudonyms are computed under the new key from the rotation date forward
- [ ] Old consents retain old pseudonyms — rights-request lookups (Sections 11–12) must try both keys within the consent period
- [ ] Document rotation dates in the pseudonym key registry

**Schedule:** Align both key rotations to the annual KMS key rotation cycle. Section 6(10) requires that receipts remain verifiable after rotation — do not delete old signing public keys.

### 5.3 Rule 6(1)(e) Retention Window

**Rule 6(1)(e):** "retain such logs and personal data for a period of one year, unless compliance with any law for the time being in force requires otherwise". This is a floor. **Rule 8(3)** adds a second one-year minimum for personal data, associated traffic data and other logs of the processing, after which they are to be erased unless further retention is required by another law. Both apply from 13 May 2027. Section 6(10) evidentiary retention may argue for longer; record the legal basis for any window beyond one year.

- [ ] Partition drop schedule configured: drop `consent_audit_event_YYYY_MM` no earlier than 12 months after partition end date
- [ ] After the one-year minimum: erase full audit payloads, keeping a minimal pseudonymised receipt per consent (event hashes, timestamps, notice version) so the chain can still be proven under S.6(10)
- [ ] Retention policy reviewed with DPO and documented in the data retention register, including the legal basis for any retention beyond the one-year minimum (Rule 8(3))
- [ ] `consent_record`: full row for the consent lifetime and at least one year after withdrawal (Rule 8(3)), then reduce to the minimal receipt; evidence-retention period for the receipt set by counsel

**[SDF]**
- [ ] SDF Data Retention Policy reviewed by DPO and Legal; any sector-specific retention obligations documented
- [ ] DPIA updated to reflect the retention schedule

### 5.4 Monitoring and Alerting

| Alert | Trigger | Severity | Owner |
|-------|---------|----------|-------|
| Chain integrity failure | Any broken link in nightly verifier | P1 | Security |
| Processor fanout stuck | pending/sent > 24h post-withdrawal | P2 | Backend |
| Notice version superseded but consents still referencing it | 409 rate > 1% | P2 | Backend |
| Unsupported language requests | 400 rate for `unsupported_language` > 0.1% | P3 | Engineering |
| KMS signing key inaccessible | Receipt signing failures | P1 | Platform |
| Audit event insertion failures | `append_audit_event()` errors | P1 | Backend |

- [ ] All P1 alerts wired to on-call rotation
- [ ] P2 and P3 alerts wired to Slack/Teams channel

### 5.5 Section 6(10) Evidence Pack Procedure

Document and test the on-demand evidence pack generation for a Data Protection Board inquiry:

- [ ] Runbook: given a `consent_id`, produce: `consent_record` row + signed receipt + full `consent_audit_event` chain + chain verification result + processor fanout acks
- [ ] Runbook: given a principal's rights request (Sections 11–12), enumerate all consents by `pseudonymise(principal_id)` and produce evidence packs for each
- [ ] Evidence pack generation tested end-to-end on staging data
- [ ] Time to produce evidence pack documented (target: <30 minutes from request)

**[SDF]**
- [ ] Evidence pack generation integrated with DPO case management workflow

**Deliverable:** Nightly chain verifier operational; KMS rotation runbooks approved by CISO; retention schedule approved by DPO; evidence pack procedure tested; all P1/P2 alerts active.

---

## Summary Table

| Phase | Week | Owner | Gate |
|-------|------|-------|------|
| 0 — Prerequisites | 1 | Engineering Lead + Platform | All infra dependencies confirmed; KMS keys created; translator workflow documented |
| 1 — Schema Deployment | 2 | Engineering Lead + DBA | DDL deployed; triggers verified; reference data seeded; no SQL validation errors |
| 2 — Notice API + Translation | 3 | Backend Engineering | All 23 languages return 200; p99 <50ms; cache tiers operational |
| 3 — Consent Grant Endpoint | 4 | Backend + Security | POST /consents live; receipt verifiable offline; atomic transaction confirmed |
| 4 — Withdrawal + Erasure Fanout | 5 | Backend + Platform | DELETE /consents live; fanout dispatches confirmed; stuck-processor alert active |
| 5 — Hardening | 6 | Security + Platform + DPO | Nightly chain verifier running; KMS rotation runbooks approved; retention policy signed off |

**Legal anchor:** DPDPA 2023 Section 6(10) — the burden of proof is on the Data Fiduciary.
Every phase in this checklist produces an artefact that contributes to that proof.

---

**StateCheck Security | DPDPA Compliance Resources**
[https://statechecksecurity.com/contact](https://statechecksecurity.com/contact)
