Skip to main content

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-service sees only the gateway's mTLS identity plus an X-Tenant-Id header signed by the gateway's RS256 key, which the service verifies.
  • Local-dev TLS bypass via GRPC_TLS_ENABLED=false is prohibited in any non-local environment. A startup guard refuses to boot with TLS disabled when NODE_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-Roles headers on the authenticated upstream request. consent-ledger-service never parses platform JWTs directly — it trusts Kong's injection.
  • For tenant-portal endpoints, consent-ledger-service sets SET LOCAL app.current_tenant_id = <X-Tenant-Id> per request; Row-Level Security on consent.records enforces 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 by channel-router-service:
    1. POST /v1/consent/citizen/otp/request — captcha-gated; Kong rate-limits to 5 per MSISDN per hour.
    2. POST /v1/consent/citizen/otp/verify — OTP redeemed; auth-service issues a short-lived (15 min) citizen-jwt scoped citizen:consent:read/citizen:consent:write and bound to msisdn.
    3. 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.

RoleCapabilities
platform.consent.adminFull 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.regulatorRead-only on consent.audit, DND registry, erasure requests, hash-chain verification reports; can submit S3-cold-archive restore jobs; cannot mutate any state
platform.supportRead-only on consent.records for any tenant (heavily redacted MSISDN); can view recent STOP MO events (no body); cannot mutate
Tenant consent:readRead own tenant's consent.records; read own double-opt-in status; cannot read other tenants
Tenant consent:writeAll 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):

  1. Kong — JWT validation, consumer.acl_groups plus declarative scope check; rejects with 401/403 at the edge.
  2. NestJS RoleGuard — runs first inside the service, rejects with 403 INSUFFICIENT_SCOPE before handler entry.
  3. @RequireRoles(...) decorator — declarative and contract-tested.
  4. Postgres RLS — final defence on consent.records and consent.audit; even if a handler bug leaks tenant context, RLS prevents cross-tenant reads.
  5. Body-redaction interceptor — masks MSISDN in REST responses outside the citizen JWT path; only platform.consent.admin and the bound citizen sees the raw MSISDN.

3. Data protection

3.1 PII inventory & classification

FieldClassificationStorageTransit
consent.records.msisdnCONFIDENTIAL (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_encryptedRESTRICTEDAES-256-GCM (per-tenant DEK wrapped by per-tenant KEK in Vault Transit transit/ghasi-consent-<tenantId>)TLS 1.2+
consent.records.msisdn_hashINTERNAL (one-way derived)sha256 with platform pepper (Vault KV secret/ghasi/consent/msisdn_pepper) — pepper rotated quarterly with envelope re-keyingTLS 1.2+
consent.audit.payload.msisdnCONFIDENTIALSame as records; redacted on erasure with chain-preserving markerTLS 1.2+
consent.false_positive_feedback.mo_encryptedRESTRICTED (potential message body content)AES-256-GCM per-tenant KEKTLS 1.2+
consent.dnd_registry.msisdnCONFIDENTIALPlaintext for query; protected by RLS (admin/regulator only) + DB encryption-at-restTLS 1.2+
consent.audit.signing_key_idINTERNALPlain
Hash-chain hashesINTERNALPlain (sha256 outputs)

3.2 Encryption keys

KeyStoreRotation
Per-tenant KEK for msisdn_encryptedVault Transit (transit/ghasi-consent-<tenantId>)Annual; or on tenant request / incident
DEK per rowWrapped inline; unwrapped per read via Vault Transit; cached in-process for ≤ 60 sImplicit
MSISDN pepperVault 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 certsVault PKI (pki/ghasi-consent)30 days
Postgres credentialsVault DB dynamic secret24 h TTL
Redis credentialsVault KVQuarterly
NATS credentialsVault KVQuarterly
ATRA SFTP keyVault KV (secret/ghasi/consent/atra-sftp)Annual or on regulator request
Citizen-OTP HMAC keyVault KVQuarterly

3.3 Redaction rules

  • In events. MSISDN appears only as msisdnHash and (where policy permits) msisdnMasked (+CC NNN ***). Raw MSISDN never published.
  • In logs. Pino redactor masks msisdn, mo.body, confirmationToken, and forbids payload.msisdn plaintext serialisation. An ESLint rule (no-pii-in-log) blocks logger.*({msisdn: …}) patterns at PR time.
  • In REST responses. MSISDN appears in plaintext only:
    • To platform.consent.admin and platform.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).
  • 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 original payload_hash bound the bytes at write time and nothing recomputes it).

3.5 Data residency (CONS critical invariant)

  • All consent schema 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-engine and routing-engine interpret as BLOCK for 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; ConsentDndStale after 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. CheckConsent is 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.records keyed on tenant_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

SecretStoreInjected as
gRPC server cert + keyVault PKI → K8s Secret (Vault Agent)File mount
gRPC client cert (per caller)Vault PKIFile mount in caller pods
PostgreSQL credentialsVault DB dynamic secretEnv var (24 h TTL)
Redis credentialsVault KVEnv var
NATS credentialsVault KVEnv var
Per-tenant KEKVault Transit (referenced, not exported)
MSISDN pepperVault KVEnv var (refreshed via Vault Agent on rotation)
ATRA SFTP keyVault KVFile mount
Citizen OTP HMAC keyVault KVEnv var

No secret is ever written to logs, events, or config files. Pre-commit gitleaks scan blocks accidental commits.

8. Threat model

ThreatMitigation
Malicious admin disables a platform-default STOP keywordDefault keywords sealed by Postgres trigger; deletion rejected at DB level
Compromised tenant credentials flood RecordConsent with fake opt-insPer-tenant rate limits; verificationMethod audit trail; tenant suspension flow; compliance-engine cross-checks via CheckConsent (audit-mismatch detection)
Attacker tampers with consent.audit rowAppend-only DB rules; hash-chain breaks detected by daily verifier; alert is CRITICAL
Attacker uses citizen-portal to revoke another subscriber's consentMSISDN-OTP authentication; citizen JWT bound to verified MSISDN; constant-time comparison of jwt.msisdn to query MSISDN
Citizen-portal MSISDN-OTP brute forceKong rate limit (5 per MSISDN per hour); captcha; OTP entropy ≥ 6 digits; OTP lifetime 5 min
Replay of citizen-jwt after revocationJWT 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 ownBulk-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 compromisesigning_key_id per row supports rotation; old keys retired but never destroyed; verifier handles multi-version chains
ATRA SFTP feed tamperingPGP signature verification on each fetch; source_signature_valid = false aborts the run and pages on-call
Side-channel: reverse-lookup MSISDN from msisdn_hashPepper rotated quarterly; msisdnHash exposed only inside platform — never to tenants or citizens
External LLM exfiltration of consent dataEgress 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/records returns 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 in source and audit. This is the platform's defensible position in regulator complaints.

10. Security testing

  • Contract tests per API_CONTRACTS §8.
  • Property-based tests on Msisdn parsing (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).