Task — engineering-spec@1

"HR CCCD photo KMS — separate keyspace for VN citizen ID photos with sev-1 access audit + ROOT-CHRO-only key access"

draftTASK-HR-003
module hr · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001 · blocks none

§1 — Description (BCP-14 normative)

The HR service MUST ship CCCD photo encryption at services/hr/src/cccd/ with separate KMS keyspace per tenant, ROOT-CHRO-only decrypt, sev-1 audit per access, consent gate, 5 memory audit kinds.

  1. MUST validate cccd_access_kind against closed enum per DEC-1821.
  1. MUST encrypt at kms_wrapper.rs::encrypt(photo_bytes, tenant_id):
  1. MUST require consent per DEC-1823 — consent_token parameter required at upload; validated against member's prior consent record.
  1. MUST gate decrypt at access_gate.rs::check(user, tenant) per DEC-1820:
  1. MUST emit sev-1 audit on every decrypt per DEC-1822:
  1. MUST define tables at migration 0003: ```sql CREATE TABLE hr_cccd_storage ( storage_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL UNIQUE, encrypted_photo BYTEA NOT NULL, encryption_kms_key_arn TEXT NOT NULL, consent_token UUID NOT NULL, consent_at TIMESTAMPTZ NOT NULL, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), trace_id CHAR(32) ); ALTER TABLE hr_cccd_storage ENABLE ROW LEVEL SECURITY; CREATE POLICY cccd_storage_rls ON hr_cccd_storage USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_cccd_storage FROM cyberos_app; GRANT DELETE ON hr_cccd_storage TO cyberos_app; -- CHRO can delete

CREATE TABLE hr_cccd_access_log ( log_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, accessor_id UUID NOT NULL, access_kind TEXT NOT NULL CHECK (access_kind IN ('upload','decrypt_view','rotate_key','delete')), accessor_role TEXT NOT NULL, ip_address TEXT, -- hashed succeeded BOOLEAN NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX cccd_access_log_member_time_idx ON hr_cccd_access_log(tenant_id, member_id, created_at DESC); ALTER TABLE hr_cccd_access_log ENABLE ROW LEVEL SECURITY; CREATE POLICY cccd_log_rls ON hr_cccd_access_log USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_cccd_access_log FROM cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/hr/members/{id}/cccd-photo body: {photo_bytes, consent_token} (member-self or CHRO) GET /v1/hr/members/{id}/cccd-photo (ROOT-CHRO only; decrypts + audits sev-1) POST /v1/hr/members/{id}/cccd-photo/rotate (CHRO only — re-encrypt with new key) DELETE /v1/hr/members/{id}/cccd-photo (member-self via DSAR or CHRO) ``
  1. MUST emit 5 memory audit kinds per DEC-1824. PII per TASK-MEMORY-111: encrypted_photo not in chain; access_kind + role ok.
  1. MUST thread trace_id through all access paths.
  1. MUST NOT decrypt for non-ROOT-CHRO per DEC-1820.
  1. MUST NOT upload without consent_token per DEC-1823.

§2 — Why this design

Why separate KMS keyspace (DEC-1820)? Blast radius — compromise of system-wide key would leak all CCCDs; per-tenant key limits exposure to one tenant.

Why ROOT-CHRO-only decrypt (DEC-1820)? CCCD = government ID = sensitive; principle of least privilege. Audit team can't decrypt without explicit grant.

Why sev-1 on every decrypt (DEC-1822)? Internal abuse most common attack; every access surfaces immediately for CHRO review.

Why consent token (DEC-1823)? PDPL Law 91/2025 Art. 23 explicitly requires explicit consent for sensitive PII collection; token is consent proof.


§3 — API contract (see §1.7)

Sample consent recording (precondition to upload):

POST /v1/hr/members/{id}/cccd-consent
{ "consent_purpose": "ID verification for employment", "consent_text_hash": "sha256..." }

Returns consent_token UUID for use in upload.


§4 — Acceptance criteria

  1. CCCD encrypted with separate KMS keyspace. 2. ROOT-CHRO-only decrypt. 3. Non-CHRO decrypt → 403 + access_denied sev-1 audit. 4. Sev-1 audit on every successful decrypt. 5. CHRO email notification on decrypt. 6. Consent token required for upload. 7. access_kind enum cardinality 4. 8. Per-tenant key alias. 9. Key rotation re-encrypts. 10. 5 memory audit kinds emitted. 11. PII: encrypted_photo never in chain. 12. RLS denies cross-tenant. 13. IP address SHA256 hashed. 14. Trace_id preserved. 15. Access log immutable (no UPDATE/DELETE grant). 16. One CCCD per member (UNIQUE). 17. Delete via DSAR or CHRO action. 18. Member-self can upload + delete own. 19. Failed access logged with succeeded=false. 20. Key creation idempotent at tenant provisioning.

§5 — Verification

#[tokio::test]
async fn non_chro_decrypt_denied() {
    let ctx = TestContext::with_uploaded_cccd().await;
    let r = ctx.try_decrypt_as(ctx.am_user, ctx.member_id).await;
    assert_eq!(r.status_code, 403);
    let logs = ctx.fetch_access_log(ctx.member_id).await;
    assert!(logs.iter().any(|l| l.access_kind == "decrypt_view" && !l.succeeded));
}

#[tokio::test]
async fn chro_decrypt_emits_sev1() {
    let ctx = TestContext::with_uploaded_cccd_as_chro().await;
    let r = ctx.decrypt_as(ctx.chro_user, ctx.member_id).await;
    assert!(r.is_ok());
    let audits = ctx.fetch_memory_audits("hr.cccd_decrypted").await;
    assert!(audits.iter().any(|a| a.severity == 1));
}

#[tokio::test]
async fn upload_without_consent_rejected() {
    let ctx = TestContext::with_member_no_consent().await;
    let r = ctx.upload_cccd_no_token(ctx.member_id).await;
    assert_eq!(r.status_code, 412);  // Precondition Failed
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Cross-module: TASK-AUTH-105 (ROOT-CHRO role), TASK-MEMORY-111 (audit chain), TASK-AUTH-101 (RBAC).

§10 — Failure modes

FailureDetectionOutcomeRecovery
KMS decrypt failKMS errsev-1; failed auditretry; check IAM
Non-CHRO bypass attemptrole check403; sev-1investigate
Consent token expiredvalidate412re-consent
Photo size > 5MBvalidate400resize
Key rotation mid-decryptre-fetchinherentinherent
Multiple consent tokens same memberuse latest validinherentinherent
KMS key disabledsev-1 alertinherentCISO action
Cross-tenant decrypt attemptRLS0 rows + sev-1inherent
DSAR delete after uploadcascadeinherentper TASK-PORTAL-008
Member-self decrypts ownOK (still sev-1)inherentinherent

§11 — Implementation notes


End of TASK-HR-003 spec.