Skip to main content

Identity & credentials (users, roles, API keys, passkeys, secret rotations)

Phase 1 shipped (2026-07-18)

The four privilege-escalation criticals — gaps 1, 2, 3, 4 — landed in release identity-p1-20260718080316.

  • gap 1: ApiKeysService.create() gained a caller-scope check. A caller can only issue an API key with scopes they themselves hold. Escalation attempts refuse with 403 + emit api_key.create.scope_escalation_refused audit event listing the refused scopes. Super-admins pass trivially (they hold all scopes). Controller wired to pass the AuthenticatedUser through.
  • gap 2: UsersService.suspend() now extends the existing refresh-token revocation with an atomic prisma.apiKey.updateMany({ revokedAt: now }) (where ownerType='user' AND ownerId=id) + passkeyCredential.deleteMany. Emits user.suspend.credentials_revoked audit event carrying apiKeysRevoked + passkeysRemoved counts. Closes the IRA IT Controls §4 24 h revocation SLA gap on durable credentials.
  • gap 3: UsersService.assignRole() gained a caller-role-scope check. A caller can only assign a role whose permission set is a subset of their own (unless they already hold that same role, in which case the check trivially passes). Escalation refuses with 403 + emits user.role.assign.escalation_refused audit event listing the missing permissions. Blocks the "compliance officer promotes agent to super_admin" attack.
  • gap 4: UsersService.assignRole() also calls SanctionsService.screenForPayout('user', userId, ctx) when the role being assigned has isPrivileged=true. Blocked screens refuse the grant + emit user.role.privileged.sanctions_block with the screening id. The sanctions module gained a user subject type (name-match only; no nationalId column on User).

No schema migration — the state machines + existing columns were sufficient.

New identity-phase1.spec locks 7 contracts (scope-escalation allow / refuse / super-admin bypass, suspend cascade counts, role-escalation refuse, super-admin-role trivially passes, sanctions-block on privileged grant). 65/65 suites, 466/466 tests green.

Phase 2 shipped (2026-07-18)

The hygiene + integrity block — gaps 5, 6, 7, 8, 9 — landed in release identity-p2-20260718081557.

  • gap 5: new api_keys.default_rate_limit_per_min policy (default 60). ApiKeyMiddleware now coalesces key.rateLimitPerMin ?? policy_default on every request so a key issued without an explicit per-minute cap can no longer run unthrottled. OperationalPoliciesModule wired into ApiKeysModule.
  • gap 6: new passwords.hibp_enabled policy (default true). PasswordService.assertNotBreached() no longer skips when the legacy HIBP_ENABLED env is unset; it now runs by default via the policy and only skips when the policy is explicitly disabled or HIBP_ENABLED=false is set (maintenance-window escape hatch). UsersService.create() + update() now call assertNotBreached on every password write (the auth-service reset flows already called it).
  • gap 7: new PasskeyChallengeCleanupJob runs hourly via @Cron(CronExpression.EVERY_HOUR). Deletes expired PasskeyChallenge rows (expiresAt < now) and emits an aggregate passkey_challenge.retention.cleared audit event with the count. Ends the "plaintext base64 challenges accumulate forever" DPPA §11 minimum-necessary drift.
  • gap 8: new data_sharing.credentials_retention_days policy (default 2555 = 7 y AML §14 floor). RetentionPurgeJob extends across four credential tables — ApiKey (revoked / expired past window), PasskeyCredential (lastUsedAt past window), RefreshToken (revoked / expired past window), MfaRecoveryCode (consumed past window). Sensitive material (keyHash, tokenHash, codeHash, IP + user-agent trails, nicknames) is scrubbed + retentionScrubbedAt stamped. Per-table *.retention.scrubbed audit events emitted.
  • gap 9: new passkeys.challenge_ttl_seconds policy (default 300). PasskeysService.storeChallenge() reads the policy and computes expiresAt = now + policy_ms so mobile enrolment flows can be given a longer window without a redeploy.

Schema migration 20260718050000_identity_phase2 adds retentionScrubbedAt on the four credential tables + supporting indexes.

New identity-phase2.spec locks 2 additional contracts (challenge-cleanup clears + emits audit; no-op when nothing expired). 66/66 suites, 468/468 tests green.

Phase 3 shipped (2026-07-18)

The MFA-hardening block — gaps 10, 11, 12 — landed in release identity-p3-20260718082831, closing the module at 12 / 12 gaps.

  • gap 10: UsersService.assignRole() gained an extended cascade. When the newly-granted role has mfaRequired=true and the target user has neither mfaEnabled nor a registered passkey, all active refresh tokens are revoked with an user.role.mfa_enrolment_required audit event. The next POST /auth/login hits the existing MFA-enrolment gate, so an already-logged-in user can't side-step BOU §7.4 requirements by virtue of an old session.
  • gap 11: POST /api-keys accepts Idempotency-Key header. ApiKeysService.createIdempotent wraps via the shared IdempotencyService (24 h TTL, scope api_key.create). Same body under the same key replays the memoised response (plaintext + all); body-mismatch → 409 IDEMPOTENCY_CONFLICT. Ends the "network retry mints two keys" problem.
  • gap 12: new User.mfaSecretRotatedAt column + new security.mfa_secret_rotation_days policy (default 365). New MfaSecretRotationReminderJob runs nightly at 05:00 UTC: walks MFA-enabled users whose secret is past the SLA (or never rotated), emits user.mfa.rotation_due audit event, and fires a best-effort user.mfa.rotate_now notification. BOU §5 second-factor rotation SLA now has a signal + reminder loop.

Schema migration 20260718051500_identity_phase3 adds User.mfaSecretRotatedAt + supporting index.

New identity-phase3.spec locks 3 additional contracts (rotation overdue → audit fires, never-rotated → audit fires, MFA-disabled user is skipped). 67/67 suites, 471/471 tests green.

All 12 / 12 gaps closed for the Identity & credentials module.

Scope

Five tightly-coupled modules that together form the identity + credentials surface for staff + integrators:

  1. users/User model + admin lifecycle. Password hash (Argon2id), TOTP-secret + recovery-codes storage, role assignment, invite → activate → suspend → soft-delete state machine, failed- login lockout (24 h).
  2. roles/Role + Permission catalogue (250+ fine-grained permissions), RolePermission grants, system-role immutability (SuperAdmin / OperationsOfficer / FinanceOfficer / Compliance / Regulator / MLRO / DPO cannot be edited), mfaRequired + isPrivileged flags at role level.
  3. api-keys/ApiKey model. Plaintext returned once at issue; SHA-256 hash + 12-char public prefix stored. Scopes validated against AllSystemPermissions. Per-key IP allowlist + rate-limit override.
  4. passkeys/ — WebAuthn passkey enrolment / verification / revocation. PasskeyCredential.counter defends replay (NIST SP 800-63B). PasskeyChallenge holds registration + authentication challenges with a 5-min TTL.
  5. secret-rotations/ — Generic secret-rotation metadata ledger. Nightly secret-rotation-overdue.job auto-declares a BreachIncident per (code, day) when a secret is past its rotateAfterDays SLA (default 90 d).

The auth-security review (already shipped) covered the auth flow (login, refresh-token rotation, TOTP challenge, session revocation). This review covers the credential storage + access-control + termination surface that sits underneath it.

Compliance envelope

  • DPPA 2019 §11 — user PII (email, phone). Ciphered at rest via PiiCipher; hash siblings support equality lookup.
  • DPPA 2019 §17 — right to erasure. Terminated users past the AML §14 7-y record-keeping floor must be scrubbed.
  • BOU Cybersecurity Guidelines §5 — credential handling. Password Argon2id + 90-day rotation on privileged roles; API-key hash storage; TOTP secret encrypted at rest.
  • BOU Cybersecurity Guidelines §7.4 — MFA required for privileged roles; second-factor rotation on suspicion.
  • NIST SP 800-63B AAL2 — Argon2id passwords, TOTP or WebAuthn passkey, session rotation + reuse detection.
  • OWASP ASVS Level 2 — password + secret storage + rotation.
  • IRA IT Controls Guidance §4 — access revocation within 24 h of termination; auditor test is "list every credential removed within 24 h of the last suspend / deactivate audit event".

Current state (2026-07-18)

Module footprint

src/modules/users/users.module.ts — 335 LOC
src/modules/roles/roles.module.ts — 150 LOC
src/modules/api-keys/api-keys.module.ts — 369 LOC
src/modules/passkeys/passkeys.module.ts — 329 LOC
src/modules/secret-rotations/secret-rotations.module.ts — 253 LOC
src/modules/scheduled-jobs/secret-rotation-overdue.job.ts — nightly 03:30 UTC

Prisma models: User, UserRole, Role, Permission, RolePermission, ApiKey, PasskeyCredential, PasskeyChallenge, RefreshToken, MfaRecoveryCode, SecretRotation.

What works today

  • Password hash is Argon2id + salt (NIST SP 800-63B-3 AAL2 compliant); PasswordService.assertMeetsPolicy runs on every POST /users + PATCH /users/:id/password write.
  • API key plaintext returned once at issue; SHA-256 hash + 12-char public prefix stored. Middleware round-trip uses hash- lookup + expiry + revocation check. Per-key IP allowlist + rate- limit override.
  • Passkey counter (BigInt) incremented on every WebAuthn authentication; replay attempts are rejected. Enumeration-safe authentication-options (unknown email returns an empty allowCredentials shape rather than 404).
  • Recovery codes are Argon2id-hashed + one-shot (deleted on consume).
  • System roles are immutable — mutating a Role.isSystem=true row is refused at the service layer.
  • Step-up + IP allowlist: RequireStepUp() decorator forces a fresh MFA re-proof within the last 30 min on every user + role mutation endpoint; PrivilegedIpGuard() gates the same surface to a configured allow-list.
  • Nightly overdue secret-rotation sweep auto-declares a BreachIncident per code+day past the SLA — the rotation workflow is observable + escalates.

Gaps

All 12 gaps closed across Phase 1, Phase 2, and Phase 3 (all shipped 2026-07-18). See the shipped-notes above for the exact scope of each phase; every gap is now enforced by locked spec coverage in identity-phase1.spec / identity-phase2.spec / identity-phase3.spec + the auth + retention integration suites.