Consent Ledger Service — Security Model
Version: 1.0 Status: Draft Owner: Security + Trust & Safety Last Updated: 2026-04-21 Companion: API_CONTRACTS · DATA_MODEL · EVENT_SCHEMAS · 13 Security, Compliance, Tenancy · ADR-0004 §3
1. Authentication
1.1 gRPC plane — ConsentLedgerService
- mTLS required. The gRPC server accepts only client certificates signed by the platform CA. A SAN allowlist pins the exact authorised callers:
compliance-engine,routing-engine,sms-firewall-service,tenant-sdk-gateway,regulator-portal-service. - Client certificates are mounted from Vault PKI via the Vault Agent Sidecar Injector and rotated every 30 days; the server hot-reloads on file change.
- The tenant-SDK gateway terminates tenant API-keys at its boundary and re-issues a short-lived mTLS cert representing the tenant —
consent-ledger-servicesees only the gateway's mTLS identity plus anX-Tenant-Idheader signed by the gateway's RS256 key, which the service verifies. - Local-dev TLS bypass via
GRPC_TLS_ENABLED=falseis prohibited in any non-local environment. A startup guard refuses to boot with TLS disabled whenNODE_ENV != 'development'.
1.2 REST plane — admin, tenant, citizen
- Kong validates the platform JWT (issued by
auth-service, RS256, JWKS-backed). Requests without a valid token are rejected at the edge. - Kong forwards
X-Tenant-Id,X-Account-Id,X-User-Id,X-Rolesheaders on the authenticated upstream request.consent-ledger-servicenever parses platform JWTs directly — it trusts Kong's injection. - For tenant-portal endpoints,
consent-ledger-servicesetsSET LOCAL app.current_tenant_id = <X-Tenant-Id>per request; Row-Level Security onconsent.recordsenforces tenant isolation at the DB level (belt and braces; see DATA_MODEL §3.2). - For citizen self-service (
/v1/consent/citizen/*), authentication is MSISDN-OTP delivered bychannel-router-service:POST /v1/consent/citizen/otp/request— captcha-gated; Kong rate-limits to 5 per MSISDN per hour.POST /v1/consent/citizen/otp/verify— OTP redeemed;auth-serviceissues a short-lived (15 min)citizen-jwtscopedcitizen:consent:read/citizen:consent:writeand bound tomsisdn.- Subsequent calls present this JWT; the service binds the session to
current_setting('app.citizen_msisdn_hash')for RLS.
1.3 IdP-agnostic
Per ADR-0002, the identity provider that authenticated the tenant user (Keycloak default / tenant OIDC / tenant SAML / Firebase legacy) is irrelevant to consent-ledger-service. The idp claim is captured in consent.audit.payload.actor.idp for forensics only.
2. Authorization (RBAC)
Role definitions live in auth-service; consent-ledger-service enforces scope/role checks at the handler boundary using NestJS Guards.
| Role | Capabilities |
|---|---|
platform.consent.admin | Full CRUD on STOP-keyword catalog (cannot delete platform defaults), DND admin, policy changes (with second-approver), erasure-request management, audit-log query, tenant tier insight |
platform.regulator | Read-only on consent.audit, DND registry, erasure requests, hash-chain verification reports; can submit S3-cold-archive restore jobs; cannot mutate any state |
platform.support | Read-only on consent.records for any tenant (heavily redacted MSISDN); can view recent STOP MO events (no body); cannot mutate |
Tenant consent:read | Read own tenant's consent.records; read own double-opt-in status; cannot read other tenants |
Tenant consent:write | All consent:read + RecordConsent, RevokeConsent, bulk-import, double-opt-in initiate, false-positive feedback |
Citizen JWT (citizen:consent:read) | Read all consent.records whose msisdn_hash matches the bound msisdn; read own audit (CITIZEN_INSPECTION_VIEW); regulator query view (per Art. 15) |
Citizen JWT (citizen:consent:write) | All :read + revoke own consent (per-tenant or STOP-ALL), initiate erasure request |
Enforcement points (in order):
- Kong — JWT validation,
consumer.acl_groupsplus declarative scope check; rejects with401/403at the edge. - NestJS RoleGuard — runs first inside the service, rejects with
403 INSUFFICIENT_SCOPEbefore handler entry. @RequireRoles(...)decorator — declarative and contract-tested.- Postgres RLS — final defence on
consent.recordsandconsent.audit; even if a handler bug leaks tenant context, RLS prevents cross-tenant reads. - Body-redaction interceptor — masks MSISDN in REST responses outside the citizen JWT path; only
platform.consent.adminand the bound citizen sees the raw MSISDN.
3. Data protection
3.1 PII inventory & classification
| Field | Classification | Storage | Transit |
|---|---|---|---|
consent.records.msisdn | CONFIDENTIAL (subscriber identifier; PII) | Plaintext for SQL joinability; protected by RLS + DB encryption-at-rest (per platform 13-sct §3) | TLS 1.2+ only |
consent.records.msisdn_encrypted | RESTRICTED | AES-256-GCM (per-tenant DEK wrapped by per-tenant KEK in Vault Transit transit/ghasi-consent-<tenantId>) | TLS 1.2+ |
consent.records.msisdn_hash | INTERNAL (one-way derived) | sha256 with platform pepper (Vault KV secret/ghasi/consent/msisdn_pepper) — pepper rotated quarterly with envelope re-keying | TLS 1.2+ |
consent.audit.payload.msisdn | CONFIDENTIAL | Same as records; redacted on erasure with chain-preserving marker | TLS 1.2+ |
consent.false_positive_feedback.mo_encrypted | RESTRICTED (potential message body content) | AES-256-GCM per-tenant KEK | TLS 1.2+ |
consent.dnd_registry.msisdn | CONFIDENTIAL | Plaintext for query; protected by RLS (admin/regulator only) + DB encryption-at-rest | TLS 1.2+ |
consent.audit.signing_key_id | INTERNAL | Plain | — |
| Hash-chain hashes | INTERNAL | Plain (sha256 outputs) | — |
3.2 Encryption keys
| Key | Store | Rotation |
|---|---|---|
Per-tenant KEK for msisdn_encrypted | Vault Transit (transit/ghasi-consent-<tenantId>) | Annual; or on tenant request / incident |
| DEK per row | Wrapped inline; unwrapped per read via Vault Transit; cached in-process for ≤ 60 s | Implicit |
| MSISDN pepper | Vault KV (secret/ghasi/consent/msisdn_pepper) | Quarterly; rotation re-hashes via background job over consent.records and consent.audit (writes new _hash column variant; old/new tracked by pepper_version) |
Audit chain signing key (signing_key_id) | Vault Transit (transit/ghasi-consent-audit-signing) | Annual; key version embedded per row to support verification across rotations |
| mTLS server + client certs | Vault PKI (pki/ghasi-consent) | 30 days |
| Postgres credentials | Vault DB dynamic secret | 24 h TTL |
| Redis credentials | Vault KV | Quarterly |
| NATS credentials | Vault KV | Quarterly |
| ATRA SFTP key | Vault KV (secret/ghasi/consent/atra-sftp) | Annual or on regulator request |
| Citizen-OTP HMAC key | Vault KV | Quarterly |
3.3 Redaction rules
- In events. MSISDN appears only as
msisdnHashand (where policy permits)msisdnMasked(+CC NNN ***). Raw MSISDN never published. - In logs. Pino redactor masks
msisdn,mo.body,confirmationToken, and forbidspayload.msisdnplaintext serialisation. An ESLint rule (no-pii-in-log) blockslogger.*({msisdn: …})patterns at PR time. - In REST responses. MSISDN appears in plaintext only:
- To
platform.consent.adminandplatform.regulator(auditing necessity). - To the citizen JWT bound to that exact MSISDN.
- In double-opt-in confirmation flows (the URL path itself is unauthenticated — but it never embeds MSISDN; only the HMAC token).
- To
- In audit
payload. Stored encrypted at rest; rendered plain only to admin/regulator handlers.
3.4 Canonical serialisation for audit hash
- RFC 8785 JSON Canonicalization Scheme (sorted keys, UTF-8 NFC, no insignificant whitespace).
- Field ordering deterministic.
- A
redactedFields[]marker added during erasure indicates which fields were null-ed; verifiers reconstruct payload bytes by recognising the marker (the redacted fields are skipped during recompute, which is valid because the originalpayload_hashbound the bytes at write time and nothing recomputes it).
3.5 Data residency (CONS critical invariant)
- All
consentschema rows, audit chain, Redis cache, and Vault namespaces reside in Afghan regions only (Kabul primary; Herat sync standby; Mazar async tertiary), per ADR-0004 §3. - Cross-region replication is permitted only between Afghan regions. The Patroni cluster definition pins
replica_locations: af-kabul-1, af-herat-1, af-mazar-1; any attempt to add a non-af-*replica is rejected by the cluster admission webhook. - S3 cold-archive uses an Afghan-region bucket (
s3://ghasi-consent-audit-cold-af-kabul/) with Object Lock; cross-region replication targets only Herat. - A platform compliance test (
tests/residency/consent_residency.spec.ts) asserts at deploy time that all configured endpoints resolve to Afghan IP space.
4. Audit
All consent state changes are recorded in consent.audit per DOMAIN_MODEL §2 ConsentAuditEntry, with append-only enforcement at the database level and hash-chain integrity verified daily (CONS-US-012). compliance.audit.v1 from compliance-engine references consent.audit.audit_id for any verdict involving a CONSENT rule, providing cross-service traceability.
Mirror copies of audit events are forwarded to the regulator's SIEM via regulator-portal-service (per regulator-portal-service SERVICE_OVERVIEW). A second copy lands in the immutable S3 archive bucket within 13 months.
5. Fail-closed posture
consent-ledger-service is designed to never falsely report allowed: true for CheckConsent:
- gRPC handler errors and timeouts →
UNAVAILABLE;compliance-engineandrouting-engineinterpret asBLOCKfor non-emergency lanes (defence in depth, see SYNC_CONTRACT §7). - Redis + Postgres both unavailable → return
{ allowed: false, reason: CONSENT_UNKNOWN }directly. - ATRA DND fetch failure → service continues against last-known DND;
ConsentDndStaleafter 24 h. - Audit insert failure aborts the parent transaction → no consent state change without an audit row.
- A circuit breaker on Vault Transit (KEK unwrap) caches decrypted DEKs for ≤ 60 s; on Vault outage > 60 s, REST endpoints requiring MSISDN decryption return
503 DEPENDENCY_UNAVAILABLE.CheckConsentis unaffected (it never decrypts).
Security-wise, fail-closed means availability attacks cannot bypass consent. At worst they delay traffic, which is acceptable.
6. Tenant isolation
- Postgres RLS on
consent.recordskeyed ontenant_id. - Redis cache keys include
{tenantId}so per-tenant invalidation never affects another. - Per-tenant KEK for MSISDN encryption ensures a key compromise is scoped.
platform.consent.*roles are platform-wide by definition; tenants self-serve via the tenant API only.- Cross-tenant leakage tests are part of the integration suite (see TESTING_STRATEGY §3.3).
7. Secrets
| Secret | Store | Injected as |
|---|---|---|
| gRPC server cert + key | Vault PKI → K8s Secret (Vault Agent) | File mount |
| gRPC client cert (per caller) | Vault PKI | File mount in caller pods |
| PostgreSQL credentials | Vault DB dynamic secret | Env var (24 h TTL) |
| Redis credentials | Vault KV | Env var |
| NATS credentials | Vault KV | Env var |
| Per-tenant KEK | Vault Transit (referenced, not exported) | — |
| MSISDN pepper | Vault KV | Env var (refreshed via Vault Agent on rotation) |
| ATRA SFTP key | Vault KV | File mount |
| Citizen OTP HMAC key | Vault KV | Env var |
No secret is ever written to logs, events, or config files. Pre-commit gitleaks scan blocks accidental commits.
8. Threat model
| Threat | Mitigation |
|---|---|
| Malicious admin disables a platform-default STOP keyword | Default keywords sealed by Postgres trigger; deletion rejected at DB level |
Compromised tenant credentials flood RecordConsent with fake opt-ins | Per-tenant rate limits; verificationMethod audit trail; tenant suspension flow; compliance-engine cross-checks via CheckConsent (audit-mismatch detection) |
Attacker tampers with consent.audit row | Append-only DB rules; hash-chain breaks detected by daily verifier; alert is CRITICAL |
| Attacker uses citizen-portal to revoke another subscriber's consent | MSISDN-OTP authentication; citizen JWT bound to verified MSISDN; constant-time comparison of jwt.msisdn to query MSISDN |
| Citizen-portal MSISDN-OTP brute force | Kong rate limit (5 per MSISDN per hour); captcha; OTP entropy ≥ 6 digits; OTP lifetime 5 min |
| Replay of citizen-jwt after revocation | JWT lifetime 15 min; revocation is immediate (no JWT denylist; short lifetime is the mitigation) |
| Malicious tenant submits a CSV bulk-import claiming opt-in for MSISDNs they don't own | Bulk-import audit-tagged with CSV hash; high-volume imports trigger tenant_suspicious_consent_volume alert; regulator complaint flow can dispute via audit |
| Audit hash-chain key compromise | signing_key_id per row supports rotation; old keys retired but never destroyed; verifier handles multi-version chains |
| ATRA SFTP feed tampering | PGP signature verification on each fetch; source_signature_valid = false aborts the run and pages on-call |
Side-channel: reverse-lookup MSISDN from msisdn_hash | Pepper rotated quarterly; msisdnHash exposed only inside platform — never to tenants or citizens |
| External LLM exfiltration of consent data | Egress NetworkPolicy denies all non-Afghan endpoints; on-cluster LLM only; no PII in prompts (see AI_INTEGRATION §1) |
9. GDPR & regulatory
- Right to access (Art. 15): Citizen self-service
/v1/consent/citizen/recordsreturns a complete inventory; the inspection event is itself audit-logged so the regulator can verify the citizen exercised the right. - Right to erasure (Art. 17): implemented per CONS-US-015 and APPLICATION_LOGIC UC-Erasure. Audit rows are not deleted but redacted with chain preservation. National-DND rows are NOT erased — regulator override.
- Right to portability (Art. 20): Citizen can download their consent records as JSON via the same self-service portal; the portal returns a signed URL for the export.
- Audit evidence window: ≥ 7 years (regulator requirement; CONS-US-013), implemented via 13-month hot Postgres + 7-year S3 immutable cold archive.
- Sub-processors: ATRA (DND source) is the only third party in scope. There is no offshore sub-processor for consent data.
- Lawful basis: Tenants attest lawful basis when calling
RecordConsent; the attestation is captured insourceand audit. This is the platform's defensible position in regulator complaints.
10. Security testing
- Contract tests per API_CONTRACTS §8.
- Property-based tests on
Msisdnparsing (E.164 conformance, malformed inputs, Unicode confusables). - Property-based tests on STOP-keyword matcher (NFKC normalisation, ZWJ obfuscation, mixed-script edge cases).
- Hash-chain integrity tests: mutate a payload byte; verifier MUST detect.
- Erasure preservation tests: erase a target MSISDN; verifier MUST still pass over the redacted partition.
- ZAP baseline + API scan run on each main-branch build.
- Quarterly penetration test scoped to citizen-portal MSISDN-OTP flow and admin REST surface.
- Role-matrix integration test — every endpoint × every role — verifies 200/403 behaviour.
- Secret scanning in CI (
gitleaks); dependency scanning (osv-scanner); container scanning (trivy). - Residency check: deploy-time test resolves all configured external endpoints and confirms Afghan IP space (no offshore leak).