Identity & credentials (users, roles, API keys, passkeys, secret rotations)
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 + emitapi_key.create.scope_escalation_refusedaudit event listing the refused scopes. Super-admins pass trivially (they hold all scopes). Controller wired to pass theAuthenticatedUserthrough. - gap 2:
UsersService.suspend()now extends the existing refresh-token revocation with an atomicprisma.apiKey.updateMany({ revokedAt: now })(whereownerType='user' AND ownerId=id) +passkeyCredential.deleteMany. Emitsuser.suspend.credentials_revokedaudit event carryingapiKeysRevoked+passkeysRemovedcounts. 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 + emitsuser.role.assign.escalation_refusedaudit event listing the missing permissions. Blocks the "compliance officer promotes agent to super_admin" attack. - gap 4:
UsersService.assignRole()also callsSanctionsService.screenForPayout('user', userId, ctx)when the role being assigned hasisPrivileged=true. Blocked screens refuse the grant + emituser.role.privileged.sanctions_blockwith the screening id. The sanctions module gained ausersubject type (name-match only; nonationalIdcolumn onUser).
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.
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_minpolicy (default 60).ApiKeyMiddlewarenow coalesceskey.rateLimitPerMin ?? policy_defaulton every request so a key issued without an explicit per-minute cap can no longer run unthrottled.OperationalPoliciesModulewired intoApiKeysModule. - gap 6: new
passwords.hibp_enabledpolicy (defaulttrue).PasswordService.assertNotBreached()no longer skips when the legacyHIBP_ENABLEDenv is unset; it now runs by default via the policy and only skips when the policy is explicitly disabled orHIBP_ENABLED=falseis set (maintenance-window escape hatch).UsersService.create()+update()now callassertNotBreachedon every password write (the auth-service reset flows already called it). - gap 7: new
PasskeyChallengeCleanupJobruns hourly via@Cron(CronExpression.EVERY_HOUR). Deletes expiredPasskeyChallengerows (expiresAt < now) and emits an aggregatepasskey_challenge.retention.clearedaudit event with the count. Ends the "plaintext base64 challenges accumulate forever" DPPA §11 minimum-necessary drift. - gap 8: new
data_sharing.credentials_retention_dayspolicy (default 2555 = 7 y AML §14 floor).RetentionPurgeJobextends across four credential tables —ApiKey(revoked / expired past window),PasskeyCredential(lastUsedAtpast window),RefreshToken(revoked / expired past window),MfaRecoveryCode(consumed past window). Sensitive material (keyHash,tokenHash,codeHash, IP + user-agent trails, nicknames) is scrubbed +retentionScrubbedAtstamped. Per-table*.retention.scrubbedaudit events emitted. - gap 9: new
passkeys.challenge_ttl_secondspolicy (default 300).PasskeysService.storeChallenge()reads the policy and computesexpiresAt = now + policy_msso 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.
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 hasmfaRequired=trueand the target user has neithermfaEnablednor a registered passkey, all active refresh tokens are revoked with anuser.role.mfa_enrolment_requiredaudit event. The nextPOST /auth/loginhits 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-keysacceptsIdempotency-Keyheader.ApiKeysService.createIdempotentwraps via the sharedIdempotencyService(24 h TTL, scopeapi_key.create). Same body under the same key replays the memoised response (plaintext + all); body-mismatch → 409IDEMPOTENCY_CONFLICT. Ends the "network retry mints two keys" problem. - gap 12: new
User.mfaSecretRotatedAtcolumn + newsecurity.mfa_secret_rotation_dayspolicy (default 365). NewMfaSecretRotationReminderJobruns nightly at 05:00 UTC: walks MFA-enabled users whose secret is past the SLA (or never rotated), emitsuser.mfa.rotation_dueaudit event, and fires a best-effortuser.mfa.rotate_nownotification. 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:
users/—Usermodel + admin lifecycle. Password hash (Argon2id), TOTP-secret + recovery-codes storage, role assignment, invite → activate → suspend → soft-delete state machine, failed- login lockout (24 h).roles/—Role+Permissioncatalogue (250+ fine-grained permissions),RolePermissiongrants, system-role immutability (SuperAdmin / OperationsOfficer / FinanceOfficer / Compliance / Regulator / MLRO / DPO cannot be edited),mfaRequired+isPrivilegedflags at role level.api-keys/—ApiKeymodel. Plaintext returned once at issue; SHA-256 hash + 12-char public prefix stored. Scopes validated againstAllSystemPermissions. Per-key IP allowlist + rate-limit override.passkeys/— WebAuthn passkey enrolment / verification / revocation.PasskeyCredential.counterdefends replay (NIST SP 800-63B).PasskeyChallengeholds registration + authentication challenges with a 5-min TTL.secret-rotations/— Generic secret-rotation metadata ledger. Nightlysecret-rotation-overdue.jobauto-declares aBreachIncidentper (code, day) when a secret is past itsrotateAfterDaysSLA (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.assertMeetsPolicyruns on everyPOST /users+PATCH /users/:id/passwordwrite. - 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
allowCredentialsshape rather than 404). - Recovery codes are Argon2id-hashed + one-shot (deleted on consume).
- System roles are immutable — mutating a
Role.isSystem=truerow 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
BreachIncidentper 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.