From 597dc5d67bf96a1444143923b4c9700b7b84e250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:00:21 +0700 Subject: [PATCH 01/43] fix(iam): persist recovery challenges before delivery --- .../iam/application/recovery.service.ts | 52 +++++++++++----- .../features/iam/recovery.service.test.ts | 60 +++++++++++++++++-- 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index 4a339b06..2463aace 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -9,6 +9,7 @@ import { parseStableIdentifierV1, type StableIdentifierV1, } from '@databreeze/domain/tenant-scope/v1'; +import type { RecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; import type { PasswordCredentialService } from './password-credential.service.js'; import type { @@ -117,11 +118,11 @@ export class RecoveryService { const expiresAt = new Date( Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, ).toISOString(); + let issued: RecoveryChallengeV1 | undefined; try { - return await this.ports.repository.withTransaction(async (transaction) => { + const persisted = await this.ports.repository.withTransaction(async (transaction) => { const userId = await transaction.findUserIdByEmail(normalized.value); - if (!userId) - return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + if (!userId) return undefined; const active = await transaction.findActiveChallengeForUser(userId); const challenge = createRecoveryChallengeV1({ id: challengeId, @@ -131,28 +132,49 @@ export class RecoveryService { issuedAt, expiresAt, }); - if (!challenge.accepted) return unavailable(); - try { - await this.ports.delivery.deliver({ - challengeId: challenge.value.id, - recipientEmail: normalized.value, - rawToken: raw, - expiresAt: challenge.value.expiresAt, - }); - } catch { - return unavailable(); - } + if (!challenge.accepted) throw new Error('IAM_RECOVERY_CHALLENGE_INVALID'); + // Replace the prior bearer atomically so there is never more than one active token. if (active) { const revoked = revokeRecoveryChallengeV1(active, issuedAt); - if (!revoked.accepted) return unavailable(); + if (!revoked.accepted) throw new Error('IAM_RECOVERY_REVOKE_INVALID'); await transaction.saveChallenge(revoked.value); } await transaction.saveChallenge(challenge.value); + return challenge.value; + }); + if (!persisted) return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + issued = persisted; + } catch { + return unavailable(); + } + + try { + await this.ports.delivery.deliver({ + challengeId: issued.id, + recipientEmail: normalized.value, + rawToken: raw, + expiresAt: issued.expiresAt, }); } catch { + try { + await this.ports.repository.withTransaction(async (transaction) => { + const current = await transaction.findChallengeByTokenDigest(issued.tokenDigest); + if (!current || current.status !== 'ACTIVE') return; + const revoked = revokeRecoveryChallengeV1( + current, + timestamp(this.ports.clock) ?? issuedAt, + ); + if (!revoked.accepted) throw new Error('IAM_RECOVERY_REVOKE_INVALID'); + await transaction.saveChallenge(revoked.value); + }); + } catch { + // The challenge remains unusable only if the compensating revocation also fails. + } return unavailable(); } + + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); } public async complete( diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 5c41a053..ad8ffa6a 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -55,13 +55,14 @@ function service( admission?: InMemoryRecoveryAdmissionAdapter, passwordCredentials: PasswordCredentialService = credentials(), completionAdmission?: InMemoryRecoveryAdmissionAdapter, + digestToken: () => string = () => 'a'.repeat(64), ) { let id = 2; return new RecoveryService({ repository, passwordCredentials, digest: { - digestToken: () => 'a'.repeat(64), + digestToken, digestEmail: () => 'b'.repeat(64), }, delivery, @@ -135,6 +136,57 @@ void test('[IAM-015] recovery request is generic for unknown email and stores a assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); }); +void test('[IAM-015] recovery persists the challenge before delivery', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + let persistedDuringDelivery = false; + const recovery = service(repository, { + deliver: async () => { + persistedDuringDelivery = repository.challenge('a'.repeat(64))?.status === 'ACTIVE'; + }, + }); + + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.equal(persistedDuringDelivery, true); +}); + +void test('[IAM-015] recovery revokes a previous challenge before delivering its replacement', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const digests: readonly [string, string] = ['a'.repeat(64), 'c'.repeat(64)]; + let digestIndex = 0; + let replacementState: { + readonly previous: string | undefined; + readonly replacement: string | undefined; + } = { previous: undefined, replacement: undefined }; + const recovery = service( + repository, + { + deliver: async () => { + replacementState = { + previous: repository.challenge(digests[0])?.status, + replacement: repository.challenge(digests[1])?.status, + }; + }, + }, + undefined, + credentials(), + undefined, + () => { + const digest = digests[digestIndex] ?? digests[1]; + digestIndex += 1; + return digest; + }, + ); + + assert.equal((await recovery.request('user@example.com')).accepted, true); + assert.equal((await recovery.request('user@example.com')).accepted, true); + assert.deepEqual(replacementState, { previous: 'REVOKED', replacement: 'ACTIVE' }); +}); + void test('[IAM-015] recovery admission throttles known and unknown requests through one generic outcome', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); @@ -186,7 +238,7 @@ void test('[IAM-015] completion atomically consumes the challenge, rotates the c }); }); -void test('[IAM-015] recovery delivery failures do not persist a usable challenge', async () => { +void test('[IAM-015] recovery delivery failures revoke the new challenge', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); const recovery = service(repository, { @@ -199,10 +251,10 @@ void test('[IAM-015] recovery delivery failures do not persist a usable challeng accepted: false, code: 'RECOVERY_UNAVAILABLE', }); - assert.equal(repository.challenge('a'.repeat(64)), undefined); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'REVOKED'); }); -void test('[IAM-015] recovery delivery failure preserves an existing active challenge', async () => { +void test('[IAM-015] recovery persistence conflicts preserve an existing active challenge', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); let failDelivery = false; From eb2d3c92bfc71c5017d532042fbce52bd94d1501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:00:29 +0700 Subject: [PATCH 02/43] fix(iam): compose service accounts from derived repository --- services/api/src/features/iam/iam.module.ts | 4 ++-- .../iam/service-account-composition.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index c6dfd3bf..57977a3f 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -451,11 +451,11 @@ export class IamModule { : new PrismaServiceAccountRepositoryAdapter(options.serviceAccountDatabase)); const serviceAccountService = options.serviceAccountService ?? - (options.iamRepository === undefined + (iamRepository === undefined ? new UnavailableServiceAccountService() : new ServiceAccountService( serviceAccountRepository, - options.iamRepository, + iamRepository, options.serviceAccountSecretIssuer ?? new RandomServiceAccountSecretIssuer(), options.serviceAccountClock, options.serviceAccountIdGenerator, diff --git a/services/api/test/features/iam/service-account-composition.test.ts b/services/api/test/features/iam/service-account-composition.test.ts index 5f14c5e2..5ac715ec 100644 --- a/services/api/test/features/iam/service-account-composition.test.ts +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -30,3 +30,17 @@ void test('[IAM-013] IAM composition registers a replaceable service-account rep ), ); }); + +void test('[IAM-013] IAM database composition enables service-account lifecycle management', () => { + const registered = IamModule.register({ iamDatabase: {} as never }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SERVICE_ACCOUNT_SERVICE, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof ServiceAccountService); +}); From bfdcd5d17b6257a7d4043476884f0ba6942d7570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:04:01 +0700 Subject: [PATCH 03/43] fix(iam): enforce strong digest keys --- .../adapter/iam-invitation-crypto.adapter.ts | 3 ++- .../iam/adapter/iam-recovery-crypto.adapter.ts | 3 ++- .../iam/iam-invitation-crypto.adapter.test.ts | 12 ++++++++++-- .../features/iam/recovery-composition.test.ts | 6 +++--- .../test/features/iam/recovery-crypto.test.ts | 17 ++++++++++++++--- .../api/test/features/iam/recovery-http.test.ts | 4 +++- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts b/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts index 1b05f471..212ba227 100644 --- a/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts +++ b/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts @@ -6,7 +6,8 @@ export type IamInvitationDigestKeyV1 = string | Uint8Array; function validKey(key: IamInvitationDigestKeyV1): boolean { return ( - (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + (typeof key === 'string' && Buffer.byteLength(key, 'utf8') >= 32) || + (key instanceof Uint8Array && key.byteLength >= 32) ); } diff --git a/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts b/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts index 6d9a3227..655c8411 100644 --- a/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts +++ b/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts @@ -10,7 +10,8 @@ export type IamRecoveryDigestKeyV1 = string | Uint8Array; function validKey(key: IamRecoveryDigestKeyV1): boolean { return ( - (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + (typeof key === 'string' && Buffer.byteLength(key, 'utf8') >= 32) || + (key instanceof Uint8Array && key.byteLength >= 32) ); } diff --git a/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts b/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts index c2cff028..bb4ee79e 100644 --- a/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts +++ b/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts @@ -10,7 +10,7 @@ import { } from '../../../src/features/iam/adapter/iam-invitation-crypto.adapter.js'; void test('[IAM-010] invitation digests are deterministic, keyed, domain-separated, and hex bounded', () => { - const digest = new HmacSha256IamInvitationDigestAdapter('test-key-v1'); + const digest = new HmacSha256IamInvitationDigestAdapter('test-key-v1-012345678901234567890'); const token = digest.digestToken('raw-token-abcdefghijklmnopqrstuvwxyz123456'); const email = digest.digestEmail('invitee@example.com'); assert.match(token, /^[a-f0-9]{64}$/u); @@ -19,7 +19,7 @@ void test('[IAM-010] invitation digests are deterministic, keyed, domain-separat assert.notEqual(token, email); assert.notEqual( token, - new HmacSha256IamInvitationDigestAdapter('other-key-v1').digestToken( + new HmacSha256IamInvitationDigestAdapter('other-key-v1-012345678901234567890').digestToken( 'raw-token-abcdefghijklmnopqrstuvwxyz123456', ), ); @@ -31,6 +31,14 @@ void test('[IAM-010] invitation crypto adapters reject unusable key material', ( () => new HmacSha256IamInvitationDigestAdapter(new Uint8Array()), /IAM_INVITATION_KEY_INVALID/, ); + assert.throws( + () => new HmacSha256IamInvitationDigestAdapter('short-invitation-key'), + /IAM_INVITATION_KEY_INVALID/u, + ); + assert.throws( + () => new HmacSha256IamInvitationDigestAdapter(new Uint8Array(31)), + /IAM_INVITATION_KEY_INVALID/u, + ); }); void test('[IAM-010] generated invitation identifiers and tokens are fresh and non-guessable', () => { diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts index 96b1fc1d..d01541e1 100644 --- a/services/api/test/features/iam/recovery-composition.test.ts +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -56,7 +56,7 @@ void test('[IAM-015] durable recovery composition requires password, digest, del const configured = IamModule.register({ recoveryDatabase: {} as never, passwordCredentials, - recoveryDigestKey: 'test-recovery-key', + recoveryDigestKey: 'test-recovery-key-v1-012345678901234567', recoveryDelivery: { deliver: async () => { await Promise.resolve(); @@ -76,7 +76,7 @@ void test('[IAM-015] recovery composition selects the shared admission adapter w const configured = IamModule.register({ recoveryDatabase: {} as never, passwordCredentials, - recoveryDigestKey: 'test-recovery-key', + recoveryDigestKey: 'test-recovery-key-v1-012345678901234567', recoveryDelivery: { deliver: async () => { await Promise.resolve(); @@ -100,7 +100,7 @@ void test('[IAM-015] recovery composition gives completion counters a separate R const configured = IamModule.register({ recoveryDatabase: {} as never, passwordCredentials, - recoveryDigestKey: 'test-recovery-key', + recoveryDigestKey: 'test-recovery-key-v1-012345678901234567', recoveryDelivery: { deliver: async () => { await Promise.resolve(); diff --git a/services/api/test/features/iam/recovery-crypto.test.ts b/services/api/test/features/iam/recovery-crypto.test.ts index 35a85d09..5fa8c4a5 100644 --- a/services/api/test/features/iam/recovery-crypto.test.ts +++ b/services/api/test/features/iam/recovery-crypto.test.ts @@ -4,9 +4,9 @@ import test from 'node:test'; import { HmacSha256IamRecoveryDigestAdapter } from '../../../src/features/iam/adapter/iam-recovery-crypto.adapter.js'; void test('[IAM-015] recovery HMAC digests are deterministic, keyed, and domain-separated', () => { - const first = new HmacSha256IamRecoveryDigestAdapter('recovery-key'); - const second = new HmacSha256IamRecoveryDigestAdapter('recovery-key'); - const other = new HmacSha256IamRecoveryDigestAdapter('other-key'); + const first = new HmacSha256IamRecoveryDigestAdapter('recovery-key-v1-012345678901234567'); + const second = new HmacSha256IamRecoveryDigestAdapter('recovery-key-v1-012345678901234567'); + const other = new HmacSha256IamRecoveryDigestAdapter('other-key-v1-012345678901234567890'); const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; assert.equal(first.digestToken(token), second.digestToken(token)); assert.equal(first.digestEmail('user@example.com').length, 64); @@ -14,3 +14,14 @@ void test('[IAM-015] recovery HMAC digests are deterministic, keyed, and domain- assert.notEqual(first.digestToken(token), other.digestToken(token)); assert.throws(() => first.digestToken(''), /IAM_RECOVERY_INPUT_INVALID/u); }); + +void test('[IAM-015] recovery HMAC rejects keys shorter than 32 bytes', () => { + assert.throws( + () => new HmacSha256IamRecoveryDigestAdapter('short-recovery-key'), + /IAM_RECOVERY_KEY_INVALID/u, + ); + assert.throws( + () => new HmacSha256IamRecoveryDigestAdapter(new Uint8Array(31)), + /IAM_RECOVERY_KEY_INVALID/u, + ); +}); diff --git a/services/api/test/features/iam/recovery-http.test.ts b/services/api/test/features/iam/recovery-http.test.ts index 7ded3b84..3d8374a8 100644 --- a/services/api/test/features/iam/recovery-http.test.ts +++ b/services/api/test/features/iam/recovery-http.test.ts @@ -34,7 +34,9 @@ void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and const { app } = await createApiApplication({ recoveryRepository: repository, passwordCredentials: credentials(), - recoveryDigest: new HmacSha256IamRecoveryDigestAdapter('test-recovery-key'), + recoveryDigest: new HmacSha256IamRecoveryDigestAdapter( + 'test-recovery-key-v1-012345678901234567', + ), recoveryDelivery: { deliver: async ({ rawToken: deliveredToken }) => { await Promise.resolve(); From 88601358e97d0b7909e455f209dd3bfcc7d4e35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:06:57 +0700 Subject: [PATCH 04/43] fix(iam): reject recovery for inactive users --- .../prisma-recovery-repository.adapter.ts | 2 +- .../iam/prisma-recovery-repository.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts index 905c0ff6..a4151d2f 100644 --- a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts @@ -238,7 +238,7 @@ class PrismaRecoveryTransactionAdapter implements RecoveryTransactionPortV1 { const user = await this.client.userIdentity.findUnique({ where: { id: input.challenge.userId }, }); - if (!user || user.status === 'DEACTIVATED' || user.id !== input.challenge.userId) + if (!user || user.status !== 'ACTIVE' || user.id !== input.challenge.userId) throw new Error('IAM_RECOVERY_USER_NOT_FOUND'); const updatedUser = await this.client.userIdentity.updateMany({ where: { id: user.id, securityEpoch: user.securityEpoch }, diff --git a/services/api/test/features/iam/prisma-recovery-repository.test.ts b/services/api/test/features/iam/prisma-recovery-repository.test.ts index 9d809e45..04cdca57 100644 --- a/services/api/test/features/iam/prisma-recovery-repository.test.ts +++ b/services/api/test/features/iam/prisma-recovery-repository.test.ts @@ -263,6 +263,32 @@ void test('[IAM-015] Prisma recovery completion rotates credential, epoch, MFA s assert.equal(state.challenges.get(challengeId)?.['status'], 'CONSUMED'); }); +void test('[IAM-015] Prisma recovery completion rejects non-active accounts', async () => { + const state = database(); + state.users.set(userId, { + ...state.users.get(userId), + status: 'SUSPENDED', + }); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + + await assert.rejects( + adapter.withTransaction((transaction) => + transaction.completeRecovery({ + challenge: challenge('CONSUMED'), + credentialId: stable('00000000-0000-4000-8000-000000000004'), + credential: { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + }, + }), + ), + /IAM_RECOVERY_USER_NOT_FOUND/u, + ); + assert.equal(state.users.get(userId)?.['securityEpoch'], 1); +}); + void test('[IAM-015] Prisma recovery challenge compare-and-set rejects a stale terminal transition', async () => { const state = database(); const adapter = new PrismaRecoveryRepositoryAdapter(state.client); From ea70d9f49eeab230bee750c6c230bd0155d34d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:11:45 +0700 Subject: [PATCH 05/43] fix(iam): guard service-account replacement scope --- ...mory-service-account-repository.adapter.ts | 1 + .../iam/service-account-repository.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts index 55bd76eb..4aaf01a9 100644 --- a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -96,6 +96,7 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); const current = this.accounts.get(account.id); if (!current || !visibleInScope(context, current)) throw new Error('SERVICE_ACCOUNT_NOT_FOUND'); + if (!writableInScope(context, current)) throw new Error('SCOPE_DENIED'); if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); if (account.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); const duplicateDigest = [...this.accounts.values()].find( diff --git a/services/api/test/features/iam/service-account-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts index 2d4dbe8b..ea5633d0 100644 --- a/services/api/test/features/iam/service-account-repository.test.ts +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -128,3 +128,33 @@ void test('[IAM-013] replacement is revision guarded and transactions roll back 'Import worker', ); }); + +void test('[IAM-013] replacement cannot move an account across workspace scope', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const workspaceContext = context( + { scopeType: 'workspace', organizationId, workspaceId }, + 'scope', + ); + await repository.saveServiceAccount( + context({ scopeType: 'organization', organizationId }, 'parent'), + account({ workspaceId: undefined }), + ); + const moved = Object.freeze({ + ...account({ workspaceId }), + revision: 2, + }); + + await assert.rejects( + repository.replaceServiceAccount(workspaceContext, moved, 1), + /SCOPE_DENIED/u, + ); + assert.equal( + ( + await repository.findServiceAccount( + context({ scopeType: 'organization', organizationId }, 'read'), + stableAccountId, + ) + )?.workspaceId, + undefined, + ); +}); From 2946e92fb0fe30e232b80bebc6a2f360ea9b53cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:15:32 +0700 Subject: [PATCH 06/43] fix(iam): persist invitations before delivery --- .../iam/application/invitation.service.ts | 53 ++++++++++++------- .../features/iam/invitation-service.test.ts | 25 +++++++++ 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts index d2cfd819..2ce99e4b 100644 --- a/services/api/src/features/iam/application/invitation.service.ts +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -165,8 +165,13 @@ export class IamInvitationService { return rejected('UNAVAILABLE'); } if (!stable(invitationId) || !rawToken(raw)) return rejected('UNAVAILABLE'); + let pendingDelivery: { + readonly token: InvitationTokenV1; + readonly rawToken: string; + readonly recipientEmail: string; + }; try { - return await this.repository.withTransaction(context, async (transaction) => { + const persisted = await this.repository.withTransaction(context, async (transaction) => { const membership = await transaction.findMembershipById(context, membershipId); if (!membership) return rejected('NOT_FOUND'); if (membership.status !== 'INVITED') return rejected('INVALID_STATE'); @@ -192,28 +197,40 @@ export class IamInvitationService { expiresAt, }); if (!token.accepted) return rejected('UNAVAILABLE'); - try { - await this.delivery.deliver({ - invitationId: token.value.id, - membershipId: token.value.membershipId, - recipientEmail: normalizedEmail.value, - rawToken: raw, - expiresAt: token.value.expiresAt, - }); - } catch { - return rejected('DELIVERY_UNAVAILABLE'); - } await transaction.saveInvitation(context, token.value); - return accepted({ - invitationId: token.value.id, - membershipId: token.value.membershipId, - expiresAt: token.value.expiresAt, - deliveryStatus: 'DELIVERED' as const, - }); + return { + pending: { + token: token.value, + rawToken: raw, + recipientEmail: normalizedEmail.value, + }, + } as const; }); + if (!('pending' in persisted)) return persisted; + pendingDelivery = persisted.pending; } catch (error) { return rejected(applicationError(error)); } + + try { + await this.delivery.deliver({ + invitationId: pendingDelivery.token.id, + membershipId: pendingDelivery.token.membershipId, + recipientEmail: pendingDelivery.recipientEmail, + rawToken: pendingDelivery.rawToken, + expiresAt: pendingDelivery.token.expiresAt, + }); + } catch { + // Keep the committed token active: a provider may have accepted the message + // before reporting an error, and a later resend can use the same bearer. + return rejected('DELIVERY_UNAVAILABLE'); + } + return accepted({ + invitationId: pendingDelivery.token.id, + membershipId: pendingDelivery.token.membershipId, + expiresAt: pendingDelivery.token.expiresAt, + deliveryStatus: 'DELIVERED' as const, + }); } public async accept( diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts index 9a8bfbac..73e49c32 100644 --- a/services/api/test/features/iam/invitation-service.test.ts +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -169,11 +169,14 @@ class Digest implements IamInvitationDigestPortV1 { class Delivery implements IamInvitationDeliveryPortV1 { readonly sent: Array<{ readonly token: string; readonly email: string }> = []; + constructor(private readonly onDeliver?: () => void) {} + async deliver(input: { readonly rawToken: string; readonly recipientEmail: string; }): Promise { await Promise.resolve(); + this.onDeliver?.(); this.sent.push({ token: input.rawToken, email: input.recipientEmail }); } } @@ -215,6 +218,28 @@ void test('[IAM-010] issuing an invitation delivers a raw token but returns only assert.deepEqual(composed.delivery.sent, [{ token: RAW_TOKEN, email: 'invitee@example.com' }]); }); +void test('[IAM-010] invitation persistence commits before raw-token delivery', async () => { + const repository = new Repository(); + let persistedDuringDelivery = false; + const composed = service( + repository, + new Delivery(() => { + persistedDuringDelivery = repository.invitations[0]?.status === 'ACTIVE'; + }), + ); + + assert.equal( + ( + await composed.service.issue(context(ids.owner, 'invitation-issue-persisted-first'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }) + ).accepted, + true, + ); + assert.equal(persistedDuringDelivery, true); +}); + void test('[IAM-010] email mismatch and non-owner issuance are denied without persistence', async () => { const repository = new Repository(); const composed = service(repository); From 75af99237ce8940cbafa30ac024e31b5f3a6d5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:18:28 +0700 Subject: [PATCH 07/43] fix(iam): map invitation invariant conflicts --- .../iam/application/invitation.service.ts | 8 +++- .../features/iam/invitation-service.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts index 2ce99e4b..c5ff4318 100644 --- a/services/api/src/features/iam/application/invitation.service.ts +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -101,7 +101,13 @@ function applicationError(error: unknown): IamInvitationApplicationCodeV1 { const message = error instanceof Error ? error.message : ''; if (message === 'IAM_SCOPE_DENIED' || message === 'IAM_SCOPE_NARROWING_REQUIRED') return 'SCOPE_DENIED'; - if (message === 'IAM_INVITATION_CONFLICT' || message === 'IAM_REVISION_CONFLICT') + if ( + message === 'IAM_INVITATION_CONFLICT' || + message === 'IAM_REVISION_CONFLICT' || + message === 'IAM_INVITATION_REVISION_CONFLICT' || + message === 'IAM_INVITATION_SCOPE_IMMUTABLE' || + message === 'IAM_MEMBERSHIP_SCOPE_IMMUTABLE' + ) return 'CONFLICT'; if (message === 'IAM_INVITATION_INVALID') return 'INVALID_TOKEN'; return 'UNAVAILABLE'; diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts index 73e49c32..d7837868 100644 --- a/services/api/test/features/iam/invitation-service.test.ts +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -82,6 +82,8 @@ class Repository implements IamInvitationRepositoryPortV1 { }, ]; invitations: InvitationTokenV1[] = []; + saveInvitationError?: string; + saveMembershipError?: string; private tail: Promise = Promise.resolve(); async withTransaction( @@ -120,6 +122,7 @@ class Repository implements IamInvitationRepositoryPortV1 { }, saveInvitation: async (_context, invitation) => { await Promise.resolve(); + if (this.saveInvitationError) throw new Error(this.saveInvitationError); const index = this.invitations.findIndex((item) => item.id === invitation.id); if (index >= 0) { if (this.invitations[index]?.revision !== invitation.revision - 1) @@ -133,6 +136,7 @@ class Repository implements IamInvitationRepositoryPortV1 { }, saveMembership: async (_context, membership) => { await Promise.resolve(); + if (this.saveMembershipError) throw new Error(this.saveMembershipError); const index = this.memberships.findIndex((item) => item.id === membership.id); if (index < 0 || this.memberships[index]?.revision !== membership.revision - 1) throw new Error('IAM_REVISION_CONFLICT'); @@ -260,6 +264,39 @@ void test('[IAM-010] email mismatch and non-owner issuance are denied without pe assert.equal(repository.invitations.length, 0); }); +void test('[IAM-010] invitation invariant conflicts map to a stable conflict outcome', async () => { + const issueRepository = new Repository(); + issueRepository.saveInvitationError = 'IAM_INVITATION_SCOPE_IMMUTABLE'; + const issueComposed = service(issueRepository); + assert.deepEqual( + await issueComposed.service.issue(context(ids.owner, 'invitation-conflict-issue'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }), + { accepted: false, code: 'CONFLICT' }, + ); + + const acceptRepository = new Repository(); + const acceptComposed = service(acceptRepository); + assert.equal( + ( + await acceptComposed.service.issue(context(ids.owner, 'invitation-conflict-accept-issue'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }) + ).accepted, + true, + ); + acceptRepository.saveMembershipError = 'IAM_MEMBERSHIP_SCOPE_IMMUTABLE'; + assert.deepEqual( + await acceptComposed.service.accept( + context(ids.invitee, 'invitation-conflict-accept'), + RAW_TOKEN, + ), + { accepted: false, code: 'CONFLICT' }, + ); +}); + void test('[IAM-010] acceptance binds token, principal, email, role, and scope then consumes once', async () => { const repository = new Repository(); const composed = service(repository); From 7910db80b98c870b5a8f8e18ec27e257cf2487d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:20:56 +0700 Subject: [PATCH 08/43] fix(iam): enforce one active invitation per membership --- .../migration.sql | 4 ++++ services/api/test/prisma-foundation.test.mjs | 13 +++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql diff --git a/services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql b/services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql new file mode 100644 index 00000000..6a460e27 --- /dev/null +++ b/services/api/prisma/migrations/20260804000000_iam_invitation_active_membership_unique/migration.sql @@ -0,0 +1,4 @@ +-- IAM-010: enforce one active invitation per membership at the database boundary. +CREATE UNIQUE INDEX "invitation_tokens_active_membership_key" +ON "iam"."invitation_tokens"("membership_id") +WHERE "status" = 'ACTIVE'; diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 816b3536..3b70d781 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -135,6 +135,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803060000_iam_service_accounts', '20260803070000_bua_entitlement_leases', '20260803080000_aud_seal_attestations', + '20260804000000_iam_invitation_active_membership_unique', 'migration_lock.toml', ]); const migration = await readFile( @@ -560,4 +561,16 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\\]\\]/g, '\\$&')), ); } + const activeInvitationMigration = await readFile( + path.join( + migrationsDirectory, + '20260804000000_iam_invitation_active_membership_unique', + 'migration.sql', + ), + 'utf8', + ); + assert.match( + activeInvitationMigration, + /CREATE UNIQUE INDEX "invitation_tokens_active_membership_key"/u, + ); }); From a723292e27a1ff24de4d623a4959fc6400c82a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:24:22 +0700 Subject: [PATCH 09/43] fix(iam): validate service-account permissions before issuance --- .../application/service-account.service.ts | 16 ++++++---- .../iam/service-account.service.test.ts | 29 +++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts index 26009f35..d643e990 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -6,12 +6,14 @@ import { markServiceAccountUsedV1, revokeServiceAccountV1, rotateServiceAccountSecretV1, + SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1, type ServiceAccountV1, type ServiceAccountErrorCodeV1, } from '@databreeze/domain/service-account/v1'; import { roleHasPermissionV1, PERMISSIONS_V1, + isPermissionV1, type PermissionV1, } from '@databreeze/domain/permissions/v1'; import { @@ -168,11 +170,15 @@ function accountScope(account: ServiceAccountV1): TenantScopeV1 { function serviceAccountPermissions(input: unknown): input is readonly PermissionV1[] { return ( Array.isArray(input) && - !input.some( - (permission) => - permission === PERMISSIONS_V1.SERVICE_ACCOUNT_READ || - permission === PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE || - permission === PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, + input.length > 0 && + input.length <= SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1 && + new Set(input).size === input.length && + input.every( + (permission): permission is PermissionV1 => + isPermissionV1(permission) && + permission !== PERMISSIONS_V1.SERVICE_ACCOUNT_READ && + permission !== PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE && + permission !== PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, ) ); } diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts index 0184d118..7af15cb2 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -60,7 +60,9 @@ function membership( }; } -function service() { +function service(secretIssuerInput?: { + issue: () => { readonly secret: string; readonly digest: string }; +}) { const iam = new InMemoryIamRepositoryAdapter(); iam.seed([membership()]); const digest = (secret: string) => createHash('sha256').update(secret, 'utf8').digest('hex'); @@ -68,10 +70,15 @@ function service() { { secret: 'dbsa_first', digest: digest('dbsa_first') }, { secret: 'dbsa_second', digest: digest('dbsa_second') }, ]; + const secretIssuer = + secretIssuerInput ?? + ({ + issue: () => secrets.shift() ?? { secret: 'dbsa_fallback', digest: 'c'.repeat(64) }, + } as const); const service = new ServiceAccountService( new InMemoryServiceAccountRepositoryAdapter(), iam, - { issue: () => secrets.shift() ?? { secret: 'dbsa_fallback', digest: 'c'.repeat(64) } }, + secretIssuer, () => new Date('2026-01-01T00:00:00.000Z'), () => accountId, ); @@ -94,6 +101,24 @@ void test('[IAM-013] authorized creation returns a one-time secret but never the assert.equal(result.value.account.status, 'ACTIVE'); }); +void test('[IAM-013] invalid service-account permissions fail before secret issuance', async () => { + let issued = 0; + const accountService = service({ + issue: () => { + issued += 1; + return { secret: 'dbsa_invalid', digest: 'e'.repeat(64) }; + }, + }); + assert.deepEqual( + await accountService.create(context({ scopeType: 'organization', organizationId }), { + name: 'Invalid worker', + permissions: [], + }), + { accepted: false, code: 'INVALID_INPUT' }, + ); + assert.equal(issued, 0); +}); + void test('[IAM-013] service account management requires the delegated IAM permission and target scope', async () => { const iam = new InMemoryIamRepositoryAdapter(); iam.seed([membership('viewer')]); From b54f0b4bd495309b8192a77423004f8b9614cd44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:30:26 +0700 Subject: [PATCH 10/43] fix(bua): use server time for lease verification --- services/api/openapi/v1.json | 7 ------- .../src/features/bua/api/entitlement-lease.dto.ts | 12 ++---------- .../src/features/bua/api/entitlement.controller.ts | 1 - .../bua/application/entitlement-lease.service.ts | 6 ++++-- .../features/bua/entitlement-lease.service.test.ts | 10 ++++++---- .../test/features/bua/entitlement.controller.test.ts | 12 ++++++++++++ 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 67792751..f0fcbff8 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -9807,13 +9807,6 @@ "in": "query", "schema": { "minimum": 1, "type": "number" } }, - { - "name": "now", - "required": false, - "in": "query", - "description": "Verification time; server clock is used when omitted", - "schema": { "format": "date-time", "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", diff --git a/services/api/src/features/bua/api/entitlement-lease.dto.ts b/services/api/src/features/bua/api/entitlement-lease.dto.ts index 917036c8..6d06c207 100644 --- a/services/api/src/features/bua/api/entitlement-lease.dto.ts +++ b/services/api/src/features/bua/api/entitlement-lease.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsInt, Max, Min } from 'class-validator'; export class IssueEntitlementLeaseDto { @ApiProperty({ format: 'date-time', description: 'UTC expiry no more than 24 hours after issue' }) @@ -19,12 +19,4 @@ export class VerifyEntitlementLeaseDto { @Min(1) @Max(Number.MAX_SAFE_INTEGER) securityEpoch!: number; - - @ApiPropertyOptional({ - format: 'date-time', - description: 'Verification time; server clock is used when omitted', - }) - @IsOptional() - @IsISO8601() - now?: string; } diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 217f69fe..e1f3b8c7 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -147,7 +147,6 @@ export class EntitlementController { await this.executeLease(() => this.leases.verify(context, { leaseId, - now: input.now, snapshotRevision: input.snapshotRevision, securityEpoch: input.securityEpoch, }), diff --git a/services/api/src/features/bua/application/entitlement-lease.service.ts b/services/api/src/features/bua/application/entitlement-lease.service.ts index a0691137..9e2ba56c 100644 --- a/services/api/src/features/bua/application/entitlement-lease.service.ts +++ b/services/api/src/features/bua/application/entitlement-lease.service.ts @@ -42,7 +42,6 @@ export interface IssueEntitlementLeaseInputV1 { export interface VerifyEntitlementLeaseInputV1 { readonly leaseId: unknown; - readonly now?: unknown; readonly snapshotRevision: unknown; readonly securityEpoch: unknown; } @@ -117,7 +116,10 @@ export class EntitlementLeaseService { ): Promise> { const leaseId = stableId(input.leaseId); if (!leaseId) return rejected('INVALID_IDENTIFIER'); - const now = input.now === undefined ? clockTimestamp(this.clock) : timestamp(input.now); + // Verification time is authoritative server state. Accepting a caller-supplied + // timestamp would let an otherwise expired lease be replayed by choosing an + // earlier value, so the application clock is always used here. + const now = clockTimestamp(this.clock); if (!now) return rejected('INVALID_TIMESTAMP'); const lease = await this.leaseRepository.findLease(context, leaseId); if (!lease) return rejected('ENTITLEMENT_NOT_FOUND'); diff --git a/services/api/test/features/bua/entitlement-lease.service.test.ts b/services/api/test/features/bua/entitlement-lease.service.test.ts index ae057ac4..33733a24 100644 --- a/services/api/test/features/bua/entitlement-lease.service.test.ts +++ b/services/api/test/features/bua/entitlement-lease.service.test.ts @@ -128,7 +128,6 @@ void test('[BUA-018] verifies signature, scope, revision, epoch, and time throug assert.deepEqual( await service.verify(context(), { leaseId, - now: '2026-01-01T00:10:00.000Z', snapshotRevision: 4, securityEpoch: 2, }), @@ -137,7 +136,6 @@ void test('[BUA-018] verifies signature, scope, revision, epoch, and time throug assert.deepEqual( await service.verify(context(), { leaseId, - now: '2026-01-01T00:10:00.000Z', snapshotRevision: 3, securityEpoch: 2, }), @@ -160,9 +158,13 @@ void test('[BUA-018] rejects invalid generated IDs and malformed verification ti { accepted: false, code: 'INVALID_IDENTIFIER' }, ); assert.deepEqual( - await service.verify(context(), { + await new EntitlementLeaseService( + new InMemoryEntitlementLeaseRepositoryAdapter(), + entitlementRepository, + signer(), + () => new Date('invalid'), + ).verify(context(), { leaseId, - now: 'invalid', snapshotRevision: 4, securityEpoch: 2, }), diff --git a/services/api/test/features/bua/entitlement.controller.test.ts b/services/api/test/features/bua/entitlement.controller.test.ts index e7893780..441a23fb 100644 --- a/services/api/test/features/bua/entitlement.controller.test.ts +++ b/services/api/test/features/bua/entitlement.controller.test.ts @@ -71,3 +71,15 @@ void test('[BUA-018] controller maps stale and unavailable lease results', async error instanceof EntitlementProblemError && error.code === 'ENTITLEMENT_UNAVAILABLE', ); }); + +void test('[BUA-018] controller never forwards a caller-controlled verification time', async () => { + let received: unknown; + const instance = controller({ + verify: (_context: unknown, input: unknown) => { + received = input; + return Promise.resolve({ accepted: true as const, value: true as const }); + }, + }); + await instance.verifyLease({}, leaseId, { snapshotRevision: 4, securityEpoch: 2 }); + assert.deepEqual(received, { leaseId, snapshotRevision: 4, securityEpoch: 2 }); +}); From 716229a040ff30a13506985704f3caf26af2b6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:32:28 +0700 Subject: [PATCH 11/43] fix(iam): make MFA reenrollment state explicit --- services/api/openapi/v1.json | 11 +++++++++-- services/api/src/features/iam/api/auth-session.dto.ts | 5 ++--- .../src/features/iam/api/authentication.controller.ts | 8 ++------ .../api/src/features/iam/api/current-session.dto.ts | 5 ++--- services/api/test/http-contract.test.ts | 2 ++ 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index f0fcbff8..381b5ca9 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -10157,7 +10157,13 @@ "mfaRequired": { "type": "boolean" }, "mfaReenrollmentRequired": { "type": "boolean" } }, - "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] + "required": [ + "userId", + "organizationId", + "authorizationEpoch", + "mfaRequired", + "mfaReenrollmentRequired" + ] }, "SignInDto": { "type": "object", @@ -10190,7 +10196,8 @@ "accessToken", "accessExpiresAt", "securityEpoch", - "mfaRequired" + "mfaRequired", + "mfaReenrollmentRequired" ] }, "SessionRefreshDto": { diff --git a/services/api/src/features/iam/api/auth-session.dto.ts b/services/api/src/features/iam/api/auth-session.dto.ts index ce1d9cb0..07b38a5b 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -51,8 +51,7 @@ export class AuthSessionDto { @IsBoolean() mfaRequired!: boolean; - @ApiProperty({ required: false }) - @IsOptional() + @ApiProperty() @IsBoolean() - mfaReenrollmentRequired?: boolean; + mfaReenrollmentRequired!: boolean; } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index d31817a1..5c80276c 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -68,9 +68,7 @@ export class AuthenticationController { : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, mfaRequired: context.mfaRequired ?? false, - ...(context.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: context.mfaReenrollmentRequired }), + mfaReenrollmentRequired: context.mfaReenrollmentRequired ?? false, }; } @@ -112,9 +110,7 @@ export class AuthenticationController { accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, - ...(result.value.principal.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: result.value.principal.mfaReenrollmentRequired }), + mfaReenrollmentRequired: result.value.principal.mfaReenrollmentRequired ?? false, }; } diff --git a/services/api/src/features/iam/api/current-session.dto.ts b/services/api/src/features/iam/api/current-session.dto.ts index df1005b0..0e62a1c2 100644 --- a/services/api/src/features/iam/api/current-session.dto.ts +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -23,8 +23,7 @@ export class CurrentSessionDto { @IsBoolean() mfaRequired!: boolean; - @ApiProperty({ required: false }) - @IsOptional() + @ApiProperty() @IsBoolean() - mfaReenrollmentRequired?: boolean; + mfaReenrollmentRequired!: boolean; } diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index a797afa6..6bca20aa 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -333,6 +333,7 @@ void test('sign-in returns a session DTO and maps authentication failures withou accessExpiresAt: '2026-01-01T00:15:00.000Z', securityEpoch: 2, mfaRequired: true, + mfaReenrollmentRequired: false, }); const setCookies = response.headers['set-cookie']; assert.ok(Array.isArray(setCookies)); @@ -674,6 +675,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac workspaceId: principal.workspaceId, authorizationEpoch: principal.securityEpoch, mfaRequired: principal.mfaRequired, + mfaReenrollmentRequired: false, }); const auditEvents = await app.inject({ From 03a2101834d645c8599aabb39dff0e931f63907d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:36:27 +0700 Subject: [PATCH 12/43] fix(security): fail closed on signer errors and MFA clear --- packages/domain/src/entitlements/v1.ts | 7 ++++++- .../entitlement-lease-issuance-v1.test.mjs | 19 +++++++++++++++++++ .../adapter/prisma-mfa-repository.adapter.ts | 6 ++++++ .../iam/application/mfa-repository.port.ts | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/domain/src/entitlements/v1.ts b/packages/domain/src/entitlements/v1.ts index cdf7cb8f..deba28bb 100644 --- a/packages/domain/src/entitlements/v1.ts +++ b/packages/domain/src/entitlements/v1.ts @@ -317,7 +317,12 @@ export function createEntitlementLeaseV1( expiresAt, }; const payload = canonicalLease(unsigned); - const signature = text(signer.sign(payload), 2048); + let signature: string | undefined; + try { + signature = text(signer.sign(payload), 2048); + } catch { + return rejected('LEASE_INVALID'); + } if (!signature) return rejected('LEASE_INVALID'); return Object.freeze({ accepted: true, diff --git a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs index 49121ee9..e03b1c22 100644 --- a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -104,6 +104,25 @@ void test('[BUA-017, BUA-018] suspended snapshots and overlong leases fail close ); }); +void test('[BUA-018] a signing-provider exception fails closed as an invalid lease', () => { + assert.deepEqual( + createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000759', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + { + sign: () => { + throw new Error('provider unavailable'); + }, + }, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); +}); + void test('[BUA-018] acceptance rejects payloads that do not canonically bind lease fields', () => { const lease = createEntitlementLeaseV1( snapshot(), diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts index bd3ac175..0f086b8b 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -316,6 +316,12 @@ export class PrismaMfaRepositoryAdapter implements MfaRepositoryPortV1 { ); } + public clearRecoveryReenrollment(userId: string): Promise { + return this.client.$transaction((transaction) => + new PrismaMfaTransactionAdapter(transaction).clearRecoveryReenrollment(userId), + ); + } + public withTransaction( work: (transaction: MfaTransactionPortV1) => Promise, ): Promise { diff --git a/services/api/src/features/iam/application/mfa-repository.port.ts b/services/api/src/features/iam/application/mfa-repository.port.ts index 4ac8b66a..9cf17969 100644 --- a/services/api/src/features/iam/application/mfa-repository.port.ts +++ b/services/api/src/features/iam/application/mfa-repository.port.ts @@ -7,7 +7,7 @@ export interface MfaTransactionPortV1 { findState(userId: StableIdentifierV1): Promise; saveState(userId: StableIdentifierV1, state: MfaStateV1): Promise; /** Clears the post-recovery gate after a newly verified factor; returns whether a flag changed. */ - clearRecoveryReenrollment?(userId: StableIdentifierV1): Promise; + clearRecoveryReenrollment(userId: StableIdentifierV1): Promise; } export interface MfaRepositoryPortV1 extends MfaTransactionPortV1 { From c6c5cee04c86e713955d10e2f129965593aa2a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:36:32 +0700 Subject: [PATCH 13/43] fix(api): preserve integer and UTC timestamp contracts --- services/api/openapi/v1.json | 110 ++++++++++++++---- .../features/aud/api/audit-attestation.dto.ts | 4 +- .../features/bua/api/entitlement-lease.dto.ts | 3 + .../iae/api/artifact-admission.dto.ts | 2 +- .../iae/api/artifact-retention.dto.ts | 15 +-- .../src/features/iae/api/inbox-item.dto.ts | 9 +- .../src/features/iam/api/registration.dto.ts | 2 +- .../features/sa/api/spreadsheet-audit.dto.ts | 11 +- 8 files changed, 117 insertions(+), 39 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 381b5ca9..3cc4ebd3 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -10411,7 +10411,12 @@ "RegistrationDto": { "type": "object", "properties": { - "email": { "type": "string", "example": "ngu***@example.com", "maxLength": 254 }, + "email": { + "type": "string", + "format": "email", + "example": "nguyen@example.com", + "maxLength": 254 + }, "displayName": { "type": "string", "minLength": 1, "maxLength": 200 }, "password": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true }, "locale": { "type": "string", "enum": ["vi-VN", "en"], "default": "vi-VN" } @@ -10604,8 +10609,17 @@ "assigneeId": { "oneOf": [{ "type": "string", "format": "uuid" }, { "type": "null" }] }, "labels": { "maxItems": 32, "type": "array", "items": { "type": "string" } }, "priority": { "type": "string", "enum": ["LOW", "NORMAL", "HIGH", "URGENT"] }, - "dueAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, - "expectedRevision": { "type": "number", "minimum": 1 } + "dueAt": { + "oneOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + { "type": "null" } + ] + }, + "expectedRevision": { "type": "integer", "minimum": 1 } } }, "CreateEvidenceGrantDto": { @@ -10639,11 +10653,31 @@ "CreateArtifactDeletionRequestDto": { "type": "object", "properties": { - "evaluatedAt": { "type": "string", "format": "date-time" }, - "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, - "resourceRetentionUntil": { "type": "string", "format": "date-time" }, - "auditRetentionUntil": { "type": "string", "format": "date-time" }, - "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "evaluatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "workspaceRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "resourceRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "auditRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "recoveryWindowUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, "activeApproval": { "type": "boolean" }, "legalHold": { "type": "boolean" }, "requestId": { "type": "string", "format": "uuid" }, @@ -10653,7 +10687,11 @@ "deprecated": true, "description": "Ignored. Attribution always uses the authenticated actor." }, - "requestedAt": { "type": "string", "format": "date-time" } + "requestedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } }, "required": [ "evaluatedAt", @@ -10670,14 +10708,38 @@ "AuthorizeArtifactDeletionRequestDto": { "type": "object", "properties": { - "evaluatedAt": { "type": "string", "format": "date-time" }, - "workspaceRetentionUntil": { "type": "string", "format": "date-time" }, - "resourceRetentionUntil": { "type": "string", "format": "date-time" }, - "auditRetentionUntil": { "type": "string", "format": "date-time" }, - "recoveryWindowUntil": { "type": "string", "format": "date-time" }, + "evaluatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "workspaceRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "resourceRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "auditRetentionUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "recoveryWindowUntil": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, "activeApproval": { "type": "boolean" }, "legalHold": { "type": "boolean" }, - "approvedAt": { "type": "string", "format": "date-time" }, + "approvedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, "mfaSatisfied": { "type": "boolean" }, "expectedRevision": { "type": "number", "minimum": 1 } }, @@ -10779,7 +10841,7 @@ "actualByteSize": { "type": "integer", "minimum": 0 }, "detectedMediaType": { "type": "string" }, "scanState": { "type": "string", "enum": ["PENDING", "CLEAN", "MALICIOUS", "FAILED"] }, - "maxByteSize": { "type": "number", "minimum": 0 }, + "maxByteSize": { "type": "integer", "minimum": 0 }, "scannedAt": { "type": "string", "format": "date-time" } }, "required": [ @@ -11506,8 +11568,8 @@ "description": "Server-generated when omitted" }, "signerKeyId": { "type": "string", "minLength": 1, "maxLength": 200 }, - "firstSequence": { "type": "number", "minimum": 1 }, - "lastSequence": { "type": "number", "minimum": 1 }, + "firstSequence": { "type": "integer", "minimum": 1 }, + "lastSequence": { "type": "integer", "minimum": 1 }, "rootDigest": { "type": "string", "minLength": 1, "maxLength": 512 } }, "required": ["signerKeyId", "firstSequence", "lastSequence", "rootDigest"] @@ -11528,9 +11590,9 @@ "properties": { "sheetId": { "type": "string", "format": "uuid" }, "name": { "type": "string", "maxLength": 128 }, - "maxRow": { "type": "number", "minimum": 0, "maximum": 1048576 }, - "maxColumn": { "type": "number", "minimum": 0, "maximum": 16384 }, - "formulaCount": { "type": "number", "minimum": 0, "maximum": 1000000 } + "maxRow": { "type": "integer", "minimum": 0, "maximum": 1048576 }, + "maxColumn": { "type": "integer", "minimum": 0, "maximum": 16384 }, + "formulaCount": { "type": "integer", "minimum": 0, "maximum": 1000000 } }, "required": ["sheetId", "name", "maxRow", "maxColumn", "formulaCount"] }, @@ -11568,7 +11630,11 @@ "items": { "type": "string", "enum": ["MACRO", "EXTERNAL_LINK", "UNSUPPORTED_XML"] } }, "processorVersion": { "type": "string", "maxLength": 128 }, - "createdAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } }, "required": [ "auditId", diff --git a/services/api/src/features/aud/api/audit-attestation.dto.ts b/services/api/src/features/aud/api/audit-attestation.dto.ts index 9ad2111d..a637b910 100644 --- a/services/api/src/features/aud/api/audit-attestation.dto.ts +++ b/services/api/src/features/aud/api/audit-attestation.dto.ts @@ -22,13 +22,13 @@ export class CreateAuditAttestationDto { @MaxLength(200) signerKeyId!: string; - @ApiProperty({ minimum: 1 }) + @ApiProperty({ type: 'integer', minimum: 1 }) @IsInt() @Min(1) @Max(Number.MAX_SAFE_INTEGER) firstSequence!: number; - @ApiProperty({ minimum: 1 }) + @ApiProperty({ type: 'integer', minimum: 1 }) @IsInt() @Min(1) @Max(Number.MAX_SAFE_INTEGER) diff --git a/services/api/src/features/bua/api/entitlement-lease.dto.ts b/services/api/src/features/bua/api/entitlement-lease.dto.ts index 6d06c207..553f99b1 100644 --- a/services/api/src/features/bua/api/entitlement-lease.dto.ts +++ b/services/api/src/features/bua/api/entitlement-lease.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsISO8601, IsInt, Max, Min } from 'class-validator'; export class IssueEntitlementLeaseDto { @@ -9,12 +10,14 @@ export class IssueEntitlementLeaseDto { export class VerifyEntitlementLeaseDto { @ApiProperty({ minimum: 1 }) + @Type(() => Number) @IsInt() @Min(1) @Max(Number.MAX_SAFE_INTEGER) snapshotRevision!: number; @ApiProperty({ minimum: 1 }) + @Type(() => Number) @IsInt() @Min(1) @Max(Number.MAX_SAFE_INTEGER) diff --git a/services/api/src/features/iae/api/artifact-admission.dto.ts b/services/api/src/features/iae/api/artifact-admission.dto.ts index 840a03fd..ea13b87a 100644 --- a/services/api/src/features/iae/api/artifact-admission.dto.ts +++ b/services/api/src/features/iae/api/artifact-admission.dto.ts @@ -19,7 +19,7 @@ export class AdmitArtifactDto { @IsIn(['PENDING', 'CLEAN', 'MALICIOUS', 'FAILED']) scanState!: 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; - @ApiProperty({ minimum: 0 }) + @ApiProperty({ type: 'integer', minimum: 0 }) @IsInt() @Min(0) maxByteSize!: number; diff --git a/services/api/src/features/iae/api/artifact-retention.dto.ts b/services/api/src/features/iae/api/artifact-retention.dto.ts index 688a9142..94db35e2 100644 --- a/services/api/src/features/iae/api/artifact-retention.dto.ts +++ b/services/api/src/features/iae/api/artifact-retention.dto.ts @@ -2,29 +2,30 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsISO8601, IsInt, IsOptional, IsUUID, Matches, Min } from 'class-validator'; const strictUtcTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const strictUtcTimestampPattern = strictUtcTimestamp.source; export class RetentionEvaluationDto { - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) evaluatedAt!: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) workspaceRetentionUntil!: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) resourceRetentionUntil!: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) auditRetentionUntil!: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) recoveryWindowUntil!: string; @@ -52,14 +53,14 @@ export class CreateArtifactDeletionRequestDto extends RetentionEvaluationDto { @IsUUID() requestedBy?: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) requestedAt!: string; } export class AuthorizeArtifactDeletionRequestDto extends RetentionEvaluationDto { - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(strictUtcTimestamp) approvedAt!: string; diff --git a/services/api/src/features/iae/api/inbox-item.dto.ts b/services/api/src/features/iae/api/inbox-item.dto.ts index 44d7713a..ba3a4d81 100644 --- a/services/api/src/features/iae/api/inbox-item.dto.ts +++ b/services/api/src/features/iae/api/inbox-item.dto.ts @@ -17,6 +17,8 @@ import { import type { InboxPriorityV1 } from '@databreeze/domain/artifact-intake/v1'; +const strictUtcTimestampPattern = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'; + /** IAE-001: content-free, idempotent intake registration request. */ export class CreateInboxItemDto { @ApiProperty({ format: 'uuid' }) @@ -62,7 +64,10 @@ export class UpdateInboxMetadataDto { priority?: InboxPriorityV1; @ApiProperty({ - oneOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }], + oneOf: [ + { type: 'string', format: 'date-time', pattern: strictUtcTimestampPattern }, + { type: 'null' }, + ], required: false, }) @IsOptional() @@ -70,7 +75,7 @@ export class UpdateInboxMetadataDto { @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) dueAt?: string | null; - @ApiProperty({ minimum: 1, required: false }) + @ApiProperty({ type: 'integer', minimum: 1, required: false }) @IsOptional() @IsInt() @Min(1) diff --git a/services/api/src/features/iam/api/registration.dto.ts b/services/api/src/features/iam/api/registration.dto.ts index 1063c7ba..ba08833d 100644 --- a/services/api/src/features/iam/api/registration.dto.ts +++ b/services/api/src/features/iam/api/registration.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; export class RegistrationDto { - @ApiProperty({ example: 'ngu***@example.com', maxLength: 254 }) + @ApiProperty({ format: 'email', example: 'nguyen@example.com', maxLength: 254 }) @IsEmail() @MaxLength(254) email!: string; diff --git a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts index bae916fe..f16fc2e8 100644 --- a/services/api/src/features/sa/api/spreadsheet-audit.dto.ts +++ b/services/api/src/features/sa/api/spreadsheet-audit.dto.ts @@ -29,19 +29,19 @@ export class SpreadsheetAuditSheetDto { @MaxLength(128) name!: string; - @ApiProperty({ minimum: 0, maximum: 1_048_576 }) + @ApiProperty({ type: 'integer', minimum: 0, maximum: 1_048_576 }) @IsInt() @Min(0) @Max(1_048_576) maxRow!: number; - @ApiProperty({ minimum: 0, maximum: 16_384 }) + @ApiProperty({ type: 'integer', minimum: 0, maximum: 16_384 }) @IsInt() @Min(0) @Max(16_384) maxColumn!: number; - @ApiProperty({ minimum: 0, maximum: 1_000_000 }) + @ApiProperty({ type: 'integer', minimum: 0, maximum: 1_000_000 }) @IsInt() @Min(0) @Max(1_000_000) @@ -117,7 +117,10 @@ export class CreateSpreadsheetAuditResultDto { @MaxLength(128) processorVersion!: string; - @ApiProperty({ format: 'date-time' }) + @ApiProperty({ + format: 'date-time', + pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$', + }) @IsISO8601({ strict: true, strictSeparator: true }) @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) createdAt!: string; From c4a08a2a9d20a1dfb32c14f07c0418ab95fa7810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:39:30 +0700 Subject: [PATCH 14/43] fix(bua): compare immutable leases by fields --- ...ry-entitlement-lease-repository.adapter.ts | 3 +- ...ma-entitlement-lease-repository.adapter.ts | 3 +- .../bua/application/entitlement-equality.ts | 19 ++++++++++ .../features/bua/entitlement-equality.test.ts | 36 +++++++++++++++++-- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts index 252553d1..ae777f9f 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts @@ -5,6 +5,7 @@ import type { EntitlementLeaseRepositoryPortV1, EntitlementLeaseTransactionPortV1, } from '../application/entitlement-lease-repository.port.js'; +import { sameEntitlementLeaseV1 } from '../application/entitlement-equality.js'; function leaseScope(lease: EntitlementLeaseV1) { return lease.tenantScope; @@ -24,7 +25,7 @@ export class InMemoryEntitlementLeaseRepositoryAdapter implements EntitlementLea if (!tenantScopeContainsV1(context.tenantScope, leaseScope(lease))) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); const existing = this.leases.get(lease.leaseId); - if (existing && JSON.stringify(existing) !== JSON.stringify(lease)) + if (existing && !sameEntitlementLeaseV1(existing, lease)) throw new Error('BUA_IMMUTABLE_LEASE'); this.leases.set(lease.leaseId, clone(lease)); } diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts index 21825d74..eded4e6a 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts @@ -15,6 +15,7 @@ import type { EntitlementLeaseRepositoryPortV1, EntitlementLeaseTransactionPortV1, } from '../application/entitlement-lease-repository.port.js'; +import { sameEntitlementLeaseV1 } from '../application/entitlement-equality.js'; export interface EntitlementLeaseDatabaseRowV1 { readonly id: string; @@ -142,7 +143,7 @@ class PrismaEntitlementLeaseTransactionAdapter implements EntitlementLeaseTransa where: { id: lease.leaseId }, }); if (existing) { - if (JSON.stringify(persistedLease(existing)) !== JSON.stringify(lease)) + if (!sameEntitlementLeaseV1(persistedLease(existing), lease)) throw new Error('BUA_IMMUTABLE_LEASE'); return; } diff --git a/services/api/src/features/bua/application/entitlement-equality.ts b/services/api/src/features/bua/application/entitlement-equality.ts index 775b8d9a..c2efa9b9 100644 --- a/services/api/src/features/bua/application/entitlement-equality.ts +++ b/services/api/src/features/bua/application/entitlement-equality.ts @@ -1,4 +1,5 @@ import type { + EntitlementLeaseV1, EntitlementPlanV1, EntitlementQuotaV1, EntitlementSnapshotV1, @@ -72,6 +73,24 @@ export function sameEntitlementSnapshotV1( ); } +/** Compares the immutable lease value without relying on object key insertion order. */ +export function sameEntitlementLeaseV1( + left: EntitlementLeaseV1, + right: EntitlementLeaseV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.leaseId === right.leaseId && + sameScope(left.tenantScope, right.tenantScope) && + left.snapshotRevision === right.snapshotRevision && + left.securityEpoch === right.securityEpoch && + left.issuedAt === right.issuedAt && + left.expiresAt === right.expiresAt && + left.payload === right.payload && + left.signature === right.signature + ); +} + export function sameUsageEntryV1(left: UsageLedgerEntryV1, right: UsageLedgerEntryV1): boolean { return ( left.schemaVersion === right.schemaVersion && diff --git a/services/api/test/features/bua/entitlement-equality.test.ts b/services/api/test/features/bua/entitlement-equality.test.ts index e35acf38..a1debf07 100644 --- a/services/api/test/features/bua/entitlement-equality.test.ts +++ b/services/api/test/features/bua/entitlement-equality.test.ts @@ -1,13 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { UsageReservationV1 } from '@databreeze/domain/entitlements/v1'; +import type { EntitlementLeaseV1, UsageReservationV1 } from '@databreeze/domain/entitlements/v1'; import { parseStableIdentifierV1, parseStrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; -import { validUsageReservationTransitionV1 } from '../../../src/features/bua/application/entitlement-equality.js'; +import { + sameEntitlementLeaseV1, + validUsageReservationTransitionV1, +} from '../../../src/features/bua/application/entitlement-equality.js'; function stable(value: string) { const parsed = parseStableIdentifierV1(value); @@ -57,3 +60,32 @@ void test('[BUA-012] reservation transitions share one terminal-state policy', ( false, ); }); + +void test('[BUA-017, BUA-018] lease equality is field-wise and independent of scope key order', () => { + const workspaceScope = Object.freeze({ + scopeType: 'workspace' as const, + organizationId: stable('00000000-0000-4000-8000-000000000004'), + workspaceId: stable('00000000-0000-4000-8000-000000000005'), + }); + const base: EntitlementLeaseV1 = Object.freeze({ + schemaVersion: 1, + leaseId: stable('00000000-0000-4000-8000-000000000003'), + tenantScope: workspaceScope, + snapshotRevision: 1, + securityEpoch: 1, + issuedAt: timestamp('2026-01-01T00:00:00.000Z'), + expiresAt: timestamp('2026-01-01T01:00:00.000Z'), + payload: 'payload', + signature: 'signature', + }); + const reordered: EntitlementLeaseV1 = Object.freeze({ + ...base, + tenantScope: Object.freeze({ + workspaceId: workspaceScope.workspaceId, + organizationId: workspaceScope.organizationId, + scopeType: 'workspace' as const, + }), + }); + assert.equal(sameEntitlementLeaseV1(base, reordered), true); + assert.equal(sameEntitlementLeaseV1(base, { ...base, signature: 'changed' }), false); +}); From 418b56af2561828d3b092aca308bcb883cc2be64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:42:53 +0700 Subject: [PATCH 15/43] fix(aud): use targeted scoped seal lookups --- .../in-memory-audit-repository.adapter.ts | 17 +++++++ ...ma-audit-attestation-repository.adapter.ts | 1 + .../prisma-audit-repository.adapter.ts | 29 ++++++++++++ .../application/audit-attestation.service.ts | 29 +++++------- .../aud/application/audit-repository.port.ts | 11 +++++ ...risma-audit-attestation-repository.test.ts | 44 ++++++++++++++++--- .../aud/prisma-audit-repository.test.ts | 11 +++++ 7 files changed, 119 insertions(+), 23 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 3449764e..2af2e80d 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -10,6 +10,7 @@ import type { AuditPageInputV1, AuditPageV1, AuditRepositoryPortV1, + AuditSealSelectorV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; import { createAuditPageCursorV1, auditPageOffsetV1 } from '../application/audit-page-cursor.js'; @@ -150,6 +151,22 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { .map(cloneSeal); } + async findSeal( + context: IamTenantContextV1, + selector: AuditSealSelectorV1, + ): Promise { + await Promise.resolve(); + if (!visibleInScope(context.tenantScope, selector.tenantScope)) return undefined; + const seal = [...this.seals.values()].find( + (candidate) => + tenantScopeKeyV1(candidate.tenantScope) === tenantScopeKeyV1(selector.tenantScope) && + candidate.firstSequence === selector.firstSequence && + candidate.lastSequence === selector.lastSequence && + candidate.rootDigest === selector.rootDigest, + ); + return seal ? cloneSeal(seal) : undefined; + } + async listSealPage( context: IamTenantContextV1, input: AuditPageInputV1, diff --git a/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts index c193f725..2f379dac 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts @@ -132,6 +132,7 @@ function scopeWhere(context: IamTenantContextV1): Readonly visible(context.tenantScope, persistedScope(row))) .map(persistedSeal); } + + public async findSeal( + context: IamTenantContextV1, + selector: AuditSealSelectorV1, + ): Promise { + if (!visible(context.tenantScope, selector.tenantScope)) return undefined; + const row = await this.client.auditSealRecord.findFirst({ + where: { + scopeKey: tenantScopeKeyV1(selector.tenantScope), + firstSequence: selector.firstSequence, + lastSequence: selector.lastSequence, + rootDigest: selector.rootDigest, + }, + }); + if (!row) return undefined; + const seal = persistedSeal(row); + return visible(context.tenantScope, seal.tenantScope) ? seal : undefined; + } } export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { @@ -494,4 +513,14 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { public listSeals(context: IamTenantContextV1): Promise { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listSeals(context); } + + public findSeal( + context: IamTenantContextV1, + selector: AuditSealSelectorV1, + ): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).findSeal( + context, + selector, + ); + } } diff --git a/services/api/src/features/aud/application/audit-attestation.service.ts b/services/api/src/features/aud/application/audit-attestation.service.ts index e149307a..59d70313 100644 --- a/services/api/src/features/aud/application/audit-attestation.service.ts +++ b/services/api/src/features/aud/application/audit-attestation.service.ts @@ -10,7 +10,6 @@ import { } from '@databreeze/domain/audit/v1'; import { parseStableIdentifierV1, - tenantScopeKeyV1, type StableIdentifierV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -90,14 +89,12 @@ export class AuditAttestationService { if (!firstSequence || !lastSequence || lastSequence < firstSequence) return rejected('INVALID_SEQUENCE'); if (!rootDigest) return rejected('INVALID_TEXT'); - const seals = await this.auditRepository.listSeals(context); - const seal = seals.find( - (candidate) => - tenantScopeKeyV1(candidate.tenantScope) === tenantScopeKeyV1(context.tenantScope) && - candidate.firstSequence === firstSequence && - candidate.lastSequence === lastSequence && - candidate.rootDigest === rootDigest, - ); + const seal = await this.auditRepository.findSeal(context, { + tenantScope: context.tenantScope, + firstSequence, + lastSequence, + rootDigest, + }); if (!seal) return rejected('NOT_FOUND'); const created = createAuditSealAttestationV1( seal, @@ -119,14 +116,12 @@ export class AuditAttestationService { if (!attestationId) return rejected('INVALID_IDENTIFIER'); const attestation = await this.attestationRepository.findAttestation(context, attestationId); if (!attestation) return rejected('NOT_FOUND'); - const seals = await this.auditRepository.listSeals(context); - const seal = seals.find( - (candidate) => - tenantScopeKeyV1(candidate.tenantScope) === tenantScopeKeyV1(attestation.tenantScope) && - candidate.firstSequence === attestation.firstSequence && - candidate.lastSequence === attestation.lastSequence && - candidate.rootDigest === attestation.rootDigest, - ); + const seal = await this.auditRepository.findSeal(context, { + tenantScope: attestation.tenantScope, + firstSequence: attestation.firstSequence, + lastSequence: attestation.lastSequence, + rootDigest: attestation.rootDigest, + }); if (!seal) return rejected('NOT_FOUND'); return applicationResult(verifyAuditSealAttestationV1(attestation, seal, this.signer)); } diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index 9c7e7890..a10221e6 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -15,6 +15,13 @@ export interface AuditPageV1 { readonly nextCursor?: string; } +export interface AuditSealSelectorV1 { + readonly tenantScope: TenantScopeV1; + readonly firstSequence: number; + readonly lastSequence: number; + readonly rootDigest: string; +} + /** * Paginated events are ordered by tenant scope key, chain sequence, and event id; * seals use tenant scope key, last sequence, and seal id. The tie-breakers keep @@ -39,6 +46,10 @@ export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise>; + findSeal( + context: IamTenantContextV1, + selector: AuditSealSelectorV1, + ): Promise; listSeals(context: IamTenantContextV1): Promise; withTransaction( context: IamTenantContextV1, diff --git a/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts index a88f04ef..2d018ee7 100644 --- a/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts @@ -9,6 +9,7 @@ import { import { parseStableIdentifierV1, parseStrictUtcTimestampV1, + type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; import { @@ -19,6 +20,8 @@ import { createIamTenantContextV1 } from '../../../src/features/iam/application/ const organizationId = '00000000-0000-4000-8000-000000000821'; const workspaceId = '00000000-0000-4000-8000-000000000822'; +const projectId = '00000000-0000-4000-8000-000000000826'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000827'; const actorId = '00000000-0000-4000-8000-000000000823'; const correlationId = '00000000-0000-4000-8000-000000000824'; @@ -36,11 +39,19 @@ function timestamp(value: string) { return parsed.value; } -function context() { +function workspaceScope(id = workspaceId): TenantScopeV1 { + return { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(id), + }; +} + +function context(scope: TenantScopeV1 = workspaceScope()) { const result = createIamTenantContextV1({ actorId, correlationId, - tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + tenantScope: scope, idempotencyKey: 'prisma-attestation', authorizationEpoch: 1, }); @@ -49,13 +60,11 @@ function context() { return result.value; } -function attestation(): AuditSealAttestationV1 { +function attestation(scope: TenantScopeV1 = workspaceScope()): AuditSealAttestationV1 { const seal: AuditSealV1 = { schemaVersion: 1, tenantScope: { - scopeType: 'workspace', - organizationId: stable(organizationId), - workspaceId: stable(workspaceId), + ...scope, }, firstSequence: 1, lastSequence: 2, @@ -129,3 +138,26 @@ void test('[AUD-015, AUD-016] Prisma attestation adapter persists immutable rows /AUD_IMMUTABLE_ATTESTATION/, ); }); + +void test('[AUD-015, IAM-009] workspace reads include project-scoped attestations in that workspace only', async () => { + const repository = new PrismaAuditAttestationRepositoryAdapter(client()); + const projectScope: TenantScopeV1 = { + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }; + const projectAttestation = attestation(projectScope); + await repository.saveAttestation(context(), projectAttestation); + assert.deepEqual( + await repository.findAttestation(context(), stable(projectAttestation.attestationId)), + projectAttestation, + ); + assert.equal( + await repository.findAttestation( + context(workspaceScope(siblingWorkspaceId)), + stable(projectAttestation.attestationId), + ), + undefined, + ); +}); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index f690a808..20fc7f46 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -192,6 +192,17 @@ void test('[AUD-015, AUD-018] Prisma audit adapter persists and reads immutable ); assert.equal((await repository.listSeals(context(workspaceId, 'read'))).length, 1); assert.equal((await repository.listSeals(context(siblingWorkspaceId, 'sibling'))).length, 0); + assert.equal( + ( + await repository.findSeal(context(workspaceId, 'targeted'), { + tenantScope: context(workspaceId, 'selector').tenantScope, + firstSequence: sealed.accepted ? sealed.value.firstSequence : 1, + lastSequence: sealed.accepted ? sealed.value.lastSequence : 1, + rootDigest: sealed.accepted ? sealed.value.rootDigest : 'missing', + }) + )?.rootDigest, + sealed.accepted ? sealed.value.rootDigest : undefined, + ); }); void test('[AUD-002] Prisma audit transactions do not retain an event when the unit of work fails', async () => { From 3f1c4e95431ee842489962670fbe5785315c28af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:46:22 +0700 Subject: [PATCH 16/43] fix(aud): return typed attestation replays --- ...ry-audit-attestation-repository.adapter.ts | 16 +++++--- ...ma-audit-attestation-repository.adapter.ts | 37 +++++++++++++++---- .../audit-attestation-repository.port.ts | 13 ++++++- .../application/audit-attestation.service.ts | 12 ++++-- .../aud/audit-attestation-repository.test.ts | 15 +++++--- .../aud/audit-attestation.service.test.ts | 18 +++++++++ ...risma-audit-attestation-repository.test.ts | 12 +++--- 7 files changed, 94 insertions(+), 29 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts index 15b4c9f2..a6fde6ea 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts @@ -9,6 +9,7 @@ import { import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { AuditAttestationRepositoryPortV1, + AuditAttestationSaveResultV1, AuditAttestationTransactionPortV1, } from '../application/audit-attestation-repository.port.js'; import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; @@ -32,14 +33,19 @@ export class InMemoryAuditAttestationRepositoryAdapter implements AuditAttestati public async saveAttestation( context: IamTenantContextV1, attestation: AuditSealAttestationV1, - ): Promise { + ): Promise { await Promise.resolve(); + const existing = this.attestations.get(attestation.attestationId); + if (existing) { + return sameAuditSealAttestationV1(existing, attestation) + ? { accepted: true, value: clone(existing), replayed: true } + : { accepted: false, code: 'CONFLICT' }; + } if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); - const existing = this.attestations.get(attestation.attestationId); - if (existing && !sameAuditSealAttestationV1(existing, attestation)) - throw new Error('AUD_IMMUTABLE_ATTESTATION'); - this.attestations.set(attestation.attestationId, clone(attestation)); + const stored = clone(attestation); + this.attestations.set(attestation.attestationId, stored); + return { accepted: true, value: clone(stored), replayed: false }; } public async findAttestation( diff --git a/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts index 2f379dac..63a65ec6 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts @@ -12,6 +12,7 @@ import { import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { AuditAttestationRepositoryPortV1, + AuditAttestationSaveResultV1, AuditAttestationTransactionPortV1, } from '../application/audit-attestation-repository.port.js'; import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; @@ -52,6 +53,10 @@ interface AuditAttestationDelegateV1 { }): Promise; } +function uniqueConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + export interface AuditAttestationDatabaseClientV1 { readonly auditSealAttestationRecord: AuditAttestationDelegateV1; $transaction( @@ -176,18 +181,34 @@ class PrismaAuditAttestationTransactionAdapter implements AuditAttestationTransa public async saveAttestation( context: IamTenantContextV1, attestation: AuditSealAttestationV1, - ): Promise { - if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) - throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + ): Promise { const existing = await this.client.auditSealAttestationRecord.findFirst({ where: { id: attestation.attestationId }, }); if (existing !== null) { - if (!sameAuditSealAttestationV1(persistedAttestation(existing), attestation)) - throw new Error('AUD_IMMUTABLE_ATTESTATION'); - return; + const persisted = persistedAttestation(existing); + return sameAuditSealAttestationV1(persisted, attestation) + ? { accepted: true, value: persisted, replayed: true } + : { accepted: false, code: 'CONFLICT' }; + } + if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + try { + const created = await this.client.auditSealAttestationRecord.create({ + data: attestationData(attestation), + }); + return { accepted: true, value: persistedAttestation(created), replayed: false }; + } catch (error) { + if (!uniqueConflict(error)) throw error; + const raced = await this.client.auditSealAttestationRecord.findFirst({ + where: { id: attestation.attestationId }, + }); + if (!raced) throw error; + const persisted = persistedAttestation(raced); + return sameAuditSealAttestationV1(persisted, attestation) + ? { accepted: true, value: persisted, replayed: true } + : { accepted: false, code: 'CONFLICT' }; } - await this.client.auditSealAttestationRecord.create({ data: attestationData(attestation) }); } public async findAttestation( @@ -218,7 +239,7 @@ export class PrismaAuditAttestationRepositoryAdapter implements AuditAttestation public saveAttestation( context: IamTenantContextV1, attestation: AuditSealAttestationV1, - ): Promise { + ): Promise { return new PrismaAuditAttestationTransactionAdapter(this.client).saveAttestation( context, attestation, diff --git a/services/api/src/features/aud/application/audit-attestation-repository.port.ts b/services/api/src/features/aud/application/audit-attestation-repository.port.ts index 2ca4fa8a..056dfb96 100644 --- a/services/api/src/features/aud/application/audit-attestation-repository.port.ts +++ b/services/api/src/features/aud/application/audit-attestation-repository.port.ts @@ -5,8 +5,19 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js export const AUDIT_ATTESTATION_REPOSITORY_PORT = Symbol('AUDIT_ATTESTATION_REPOSITORY_PORT'); +export type AuditAttestationSaveResultV1 = + | { + readonly accepted: true; + readonly value: AuditSealAttestationV1; + readonly replayed: boolean; + } + | { readonly accepted: false; readonly code: 'CONFLICT' }; + export interface AuditAttestationTransactionPortV1 { - saveAttestation(context: IamTenantContextV1, attestation: AuditSealAttestationV1): Promise; + saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise; findAttestation( context: IamTenantContextV1, attestationId: StableIdentifierV1, diff --git a/services/api/src/features/aud/application/audit-attestation.service.ts b/services/api/src/features/aud/application/audit-attestation.service.ts index 59d70313..397efa9b 100644 --- a/services/api/src/features/aud/application/audit-attestation.service.ts +++ b/services/api/src/features/aud/application/audit-attestation.service.ts @@ -22,7 +22,11 @@ export const AUDIT_ATTESTATION_SERVICE = Symbol('AUDIT_ATTESTATION_SERVICE'); export type AuditAttestationClockV1 = () => Date; export type AuditAttestationIdGeneratorV1 = () => string; -export type AuditAttestationApplicationCodeV1 = AuditErrorCodeV1 | 'NOT_FOUND' | 'UNAVAILABLE'; +export type AuditAttestationApplicationCodeV1 = + | AuditErrorCodeV1 + | 'NOT_FOUND' + | 'UNAVAILABLE' + | 'CONFLICT'; export type AuditAttestationApplicationResultV1 = | { readonly accepted: true; readonly value: TValue } @@ -102,10 +106,10 @@ export class AuditAttestationService { this.signer, ); if (!created.accepted) return applicationResult(created); - await this.attestationRepository.withTransaction(context, async (transaction) => { - await transaction.saveAttestation(context, created.value); + const saved = await this.attestationRepository.withTransaction(context, async (transaction) => { + return transaction.saveAttestation(context, created.value); }); - return created; + return saved.accepted ? { accepted: true, value: saved.value } : rejected('CONFLICT'); } public async verify( diff --git a/services/api/test/features/aud/audit-attestation-repository.test.ts b/services/api/test/features/aud/audit-attestation-repository.test.ts index aec06a2e..a93be641 100644 --- a/services/api/test/features/aud/audit-attestation-repository.test.ts +++ b/services/api/test/features/aud/audit-attestation-repository.test.ts @@ -77,13 +77,15 @@ function attestation(): AuditSealAttestationV1 { void test('[AUD-015, AUD-016] attestation storage is immutable and scope isolated', async () => { const repository = new InMemoryAuditAttestationRepositoryAdapter(); const value = attestation(); - await repository.saveAttestation(context(), value); + const created = await repository.saveAttestation(context(), value); + assert.deepEqual(created, { accepted: true, value, replayed: false }); assert.deepEqual(await repository.findAttestation(context(), stable(value.attestationId)), value); assert.deepEqual(await repository.listAttestations(context(siblingWorkspaceId)), []); - await repository.saveAttestation(context(), value); - await assert.rejects( - repository.saveAttestation(context(), { ...value, signature: 'tampered' }), - /AUD_IMMUTABLE_ATTESTATION/, + const replayed = await repository.saveAttestation(context(), value); + assert.deepEqual(replayed, { accepted: true, value, replayed: true }); + assert.deepEqual( + await repository.saveAttestation(context(), { ...value, signature: 'tampered' }), + { accepted: false, code: 'CONFLICT' }, ); }); @@ -92,7 +94,8 @@ void test('[AUD-007, AUD-015] attestation writes roll back transactionally', asy const value = attestation(); await assert.rejects( repository.withTransaction(context(), async (transaction) => { - await transaction.saveAttestation(context(), value); + const result = await transaction.saveAttestation(context(), value); + assert.equal(result.accepted, true); throw new Error('rollback'); }), /rollback/, diff --git a/services/api/test/features/aud/audit-attestation.service.test.ts b/services/api/test/features/aud/audit-attestation.service.test.ts index 39449071..1e9bb767 100644 --- a/services/api/test/features/aud/audit-attestation.service.test.ts +++ b/services/api/test/features/aud/audit-attestation.service.test.ts @@ -95,6 +95,24 @@ void test('[AUD-015, AUD-016] service signs only a persisted exact-scope seal an await attestationRepository.findAttestation(context(), stable(attestationId)), created.value, ); + const replayed = await service.create(context(), { + attestationId, + signerKeyId: 'audit-key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root-digest', + }); + assert.deepEqual(replayed, created); + assert.deepEqual( + await service.create(context(), { + attestationId, + signerKeyId: 'different-key', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root-digest', + }), + { accepted: false, code: 'CONFLICT' }, + ); }); void test('[AUD-015] service rejects missing seals and malformed selectors before signing', async () => { diff --git a/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts index 2d018ee7..815b269e 100644 --- a/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts @@ -129,13 +129,15 @@ function client(rows: Record[] = []): AuditAttestationDatabaseC void test('[AUD-015, AUD-016] Prisma attestation adapter persists immutable rows and scopes reads', async () => { const repository = new PrismaAuditAttestationRepositoryAdapter(client()); const value = attestation(); - await repository.saveAttestation(context(), value); + const created = await repository.saveAttestation(context(), value); + assert.deepEqual(created, { accepted: true, value, replayed: false }); assert.deepEqual(await repository.findAttestation(context(), stable(value.attestationId)), value); assert.deepEqual(await repository.listAttestations(context()), [value]); - await repository.saveAttestation(context(), value); - await assert.rejects( - repository.saveAttestation(context(), { ...value, signature: 'tampered' }), - /AUD_IMMUTABLE_ATTESTATION/, + const replayed = await repository.saveAttestation(context(), value); + assert.deepEqual(replayed, { accepted: true, value, replayed: true }); + assert.deepEqual( + await repository.saveAttestation(context(), { ...value, signature: 'tampered' }), + { accepted: false, code: 'CONFLICT' }, ); }); From 05149f6ff86338608c78915f805b3ad42831a07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:48:21 +0700 Subject: [PATCH 17/43] fix(iam): make registration outcomes indistinguishable --- services/api/openapi/v1.json | 20 +++------------- .../iam/api/registration.controller.ts | 22 +++++++---------- .../src/features/iam/api/registration.dto.ts | 19 ++------------- .../registration-repository.port.ts | 2 +- .../iam/application/registration.service.ts | 5 +++- .../iam/registration-controller.test.ts | 24 +++---------------- .../features/iam/registration-http.test.ts | 10 ++++---- .../features/iam/registration.service.test.ts | 16 ++++++------- 8 files changed, 34 insertions(+), 84 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3cc4ebd3..26207edf 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3083,7 +3083,7 @@ } }, "responses": { - "201": { + "202": { "description": "", "content": { "application/json": { @@ -10425,22 +10425,8 @@ }, "RegistrationResponseDto": { "type": "object", - "properties": { - "userId": { "type": "string", "format": "uuid" }, - "organizationId": { "type": "string", "format": "uuid" }, - "workspaceId": { "type": "string", "format": "uuid" }, - "projectId": { "type": "string", "format": "uuid" }, - "membershipId": { "type": "string", "format": "uuid" }, - "locale": { "type": "string", "enum": ["vi-VN", "en"] } - }, - "required": [ - "userId", - "organizationId", - "workspaceId", - "projectId", - "membershipId", - "locale" - ] + "properties": { "accepted": { "type": "boolean", "enum": [true], "example": true } }, + "required": ["accepted"] }, "RecoveryRequestDto": { "type": "object", diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts index 2f6e39cf..3d6ae0fe 100644 --- a/services/api/src/features/iam/api/registration.controller.ts +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, HttpCode, Inject, Optional, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, Inject, Optional, Post, Req } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBody, - ApiCreatedResponse, + ApiAcceptedResponse, ApiOperation, ApiServiceUnavailableResponse, ApiTags, @@ -26,16 +26,19 @@ export class RegistrationController { ) {} @Post('register') - @HttpCode(201) + @HttpCode(202) @ApiOperation({ summary: 'Create an account and personal organization hierarchy', description: 'Registration does not return bearer material; sign in separately after creation.', }) @ApiBody({ type: RegistrationDto }) - @ApiCreatedResponse({ type: RegistrationResponseDto }) + @ApiAcceptedResponse({ type: RegistrationResponseDto }) @ApiBadRequestResponse({ description: 'The registration request was rejected.' }) @ApiServiceUnavailableResponse({ description: 'Registration persistence is unavailable.' }) - async register(@Body() input: RegistrationDto): Promise { + async register( + @Body() input: RegistrationDto, + @Req() _request?: unknown, + ): Promise { if (this.registration === undefined) throw new RegistrationProblemError('REGISTRATION_UNAVAILABLE'); const result = await this.registration.register(input); @@ -46,13 +49,6 @@ export class RegistrationController { : 'REGISTRATION_REQUEST_REJECTED', ); } - return { - userId: result.value.bootstrap.user.id, - organizationId: result.value.bootstrap.organization.id, - workspaceId: result.value.bootstrap.workspace.id, - projectId: result.value.bootstrap.project.id, - membershipId: result.value.bootstrap.membership.id, - locale: result.value.bootstrap.user.locale, - }; + return { accepted: true }; } } diff --git a/services/api/src/features/iam/api/registration.dto.ts b/services/api/src/features/iam/api/registration.dto.ts index ba08833d..c95a0e33 100644 --- a/services/api/src/features/iam/api/registration.dto.ts +++ b/services/api/src/features/iam/api/registration.dto.ts @@ -26,21 +26,6 @@ export class RegistrationDto { } export class RegistrationResponseDto { - @ApiProperty({ format: 'uuid' }) - userId!: string; - - @ApiProperty({ format: 'uuid' }) - organizationId!: string; - - @ApiProperty({ format: 'uuid' }) - workspaceId!: string; - - @ApiProperty({ format: 'uuid' }) - projectId!: string; - - @ApiProperty({ format: 'uuid' }) - membershipId!: string; - - @ApiProperty({ enum: ['vi-VN', 'en'] }) - locale!: 'vi-VN' | 'en'; + @ApiProperty({ enum: [true], example: true }) + accepted!: true; } diff --git a/services/api/src/features/iam/application/registration-repository.port.ts b/services/api/src/features/iam/application/registration-repository.port.ts index fe5eeddb..75c64ee1 100644 --- a/services/api/src/features/iam/application/registration-repository.port.ts +++ b/services/api/src/features/iam/application/registration-repository.port.ts @@ -36,7 +36,7 @@ export type RegistrationFailureCodeV1 = | 'REGISTRATION_UNAVAILABLE'; export interface RegistrationValueV1 { - readonly bootstrap: PersonalOrganizationBootstrapV1; + readonly bootstrap?: PersonalOrganizationBootstrapV1; readonly email: string; } diff --git a/services/api/src/features/iam/application/registration.service.ts b/services/api/src/features/iam/application/registration.service.ts index 1154faa5..03d19b58 100644 --- a/services/api/src/features/iam/application/registration.service.ts +++ b/services/api/src/features/iam/application/registration.service.ts @@ -87,7 +87,10 @@ export class RegistrationService { try { return await this.ports.repository.withTransaction(async (transaction) => { if (await transaction.findByEmail(email.value)) - return Object.freeze({ accepted: false, code: 'REGISTRATION_REJECTED' as const }); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ email: email.value }), + }); await transaction.save({ email: email.value, credentialId: this.ports.ids.next(), diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index df534fe7..2eb5de7f 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -5,22 +5,11 @@ import { RegistrationController } from '../../../src/features/iam/api/registrati import { RegistrationProblemError } from '../../../src/features/iam/application/registration-problem.error.js'; import type { RegistrationService } from '../../../src/features/iam/application/registration.service.js'; -const value = { - bootstrap: { - user: { id: 'user-id', locale: 'vi-VN' }, - organization: { id: 'organization-id' }, - workspace: { id: 'workspace-id' }, - project: { id: 'project-id' }, - membership: { id: 'membership-id' }, - }, - email: 'user@example.com', -} as never; - -void test('[IAM-001] registration controller returns hierarchy identifiers without bearer material', async () => { +void test('[IAM-001] registration controller returns a generic accepted response without bearer material', async () => { const controller = new RegistrationController({ register: async () => { await Promise.resolve(); - return { accepted: true as const, value }; + return { accepted: true as const, value: { email: 'user@example.com' } }; }, } as unknown as RegistrationService); const response = await controller.register({ @@ -28,14 +17,7 @@ void test('[IAM-001] registration controller returns hierarchy identifiers witho displayName: 'Nguyen An', password: 'correct horse battery staple', }); - assert.deepEqual(response, { - userId: 'user-id', - organizationId: 'organization-id', - workspaceId: 'workspace-id', - projectId: 'project-id', - membershipId: 'membership-id', - locale: 'vi-VN', - }); + assert.deepEqual(response, { accepted: true }); assert.equal('email' in response, false); assert.equal('accessToken' in response, false); }); diff --git a/services/api/test/features/iam/registration-http.test.ts b/services/api/test/features/iam/registration-http.test.ts index 3793f49d..0f3d3f63 100644 --- a/services/api/test/features/iam/registration-http.test.ts +++ b/services/api/test/features/iam/registration-http.test.ts @@ -37,10 +37,9 @@ void test('[IAM-001, IAM-009, IAM-016] registration HTTP creates a personal hier password: 'correct horse battery staple', }, }); - assert.equal(first.statusCode, 201); + assert.equal(first.statusCode, 202); const body = first.json>(); - assert.match(String(body['userId']), /^[0-9a-f-]{36}$/u); - assert.equal(body['locale'], 'vi-VN'); + assert.deepEqual(body, { accepted: true }); assert.equal('accessToken' in body, false); assert.equal('email' in body, false); @@ -53,9 +52,8 @@ void test('[IAM-001, IAM-009, IAM-016] registration HTTP creates a personal hier password: 'correct horse battery staple', }, }); - assert.equal(duplicate.statusCode, 400); - assert.match(duplicate.headers['content-type'] ?? '', /^application\/problem\+json/u); - assert.equal(duplicate.json<{ code: string }>().code, 'REGISTRATION_REQUEST_REJECTED'); + assert.equal(duplicate.statusCode, 202); + assert.deepEqual(duplicate.json(), { accepted: true }); } finally { await app.close(); } diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts index 10d47406..46f04067 100644 --- a/services/api/test/features/iam/registration.service.test.ts +++ b/services/api/test/features/iam/registration.service.test.ts @@ -45,6 +45,8 @@ void test('[IAM-001, IAM-009, IAM-016] registration atomically creates a Vietnam assert.equal(result.accepted, true); if (!result.accepted) return; assert.equal(result.value.email, 'user@example.com'); + assert.ok(result.value.bootstrap); + if (!result.value.bootstrap) return; assert.equal(result.value.bootstrap.user.locale, 'vi-VN'); assert.equal(result.value.bootstrap.membership.roleId, 'owner'); assert.equal(result.value.bootstrap.organization.personal, true); @@ -72,14 +74,12 @@ void test('[IAM-001] registration rejects duplicate email without disclosing acc ).accepted, true, ); - assert.deepEqual( - await service.register({ - email: 'SAME@example.com', - displayName: 'Two', - password: 'valid password here', - }), - { accepted: false, code: 'REGISTRATION_REJECTED' }, - ); + const duplicate = await service.register({ + email: 'SAME@example.com', + displayName: 'Two', + password: 'valid password here', + }); + assert.deepEqual(duplicate, { accepted: true, value: { email: 'same@example.com' } }); }); void test('[IAM-001] registration validates input before persistence and maps hash failure safely', async () => { From c61bb1cfc31c572ed851957109994f9b132f2ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 11:53:22 +0700 Subject: [PATCH 18/43] fix(iam): throttle registration before password hashing --- .../iam/api/registration.controller.ts | 48 +++++++++++++++++++ .../registration-repository.port.ts | 7 +++ services/api/src/features/iam/iam.module.ts | 37 ++++++++++++++ .../iam/registration-controller.test.ts | 46 ++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts index 3d6ae0fe..b2b639e7 100644 --- a/services/api/src/features/iam/api/registration.controller.ts +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { Body, Controller, HttpCode, Inject, Optional, Post, Req } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -12,8 +14,28 @@ import { IAM_REGISTRATION_SERVICE, type RegistrationService, } from '../application/registration.service.js'; +import { + IAM_REGISTRATION_EMAIL_ADMISSION, + IAM_REGISTRATION_IP_ADMISSION, + type RegistrationAdmissionPortV1, +} from '../application/registration-repository.port.js'; import { RegistrationProblemError } from '../application/registration-problem.error.js'; import { RegistrationDto, RegistrationResponseDto } from './registration.dto.js'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; + +function admissionDigest(kind: 'ip' | 'email', value: string): string { + return createHash('sha256') + .update(`databreeze:iam:registration:${kind}:v1:${value}`, 'utf8') + .digest('hex'); +} + +function requestIp(request: unknown): string { + if (typeof request !== 'object' || request === null || !('ip' in request)) return 'unknown'; + const candidate = (request as { readonly ip?: unknown }).ip; + if (typeof candidate !== 'string') return 'unknown'; + const normalized = candidate.trim(); + return normalized.length > 0 && normalized.length <= 128 ? normalized : 'unknown'; +} /** IAM-001/IAM-009: account registration creates a safe personal hierarchy without a session. */ @ApiTags('auth') @@ -23,6 +45,12 @@ export class RegistrationController { @Optional() @Inject(IAM_REGISTRATION_SERVICE) private readonly registration: RegistrationService | undefined, + @Optional() + @Inject(IAM_REGISTRATION_IP_ADMISSION) + private readonly ipAdmission?: RegistrationAdmissionPortV1, + @Optional() + @Inject(IAM_REGISTRATION_EMAIL_ADMISSION) + private readonly emailAdmission?: RegistrationAdmissionPortV1, ) {} @Post('register') @@ -41,6 +69,26 @@ export class RegistrationController { ): Promise { if (this.registration === undefined) throw new RegistrationProblemError('REGISTRATION_UNAVAILABLE'); + + // Admission happens before Argon2 work and never stores a raw IP or email. The in-memory + // unit-test fallback intentionally skips this gate when no providers are composed; the + // production module always supplies bounded adapters. + const normalizedEmail = normalizeEmailAddressV1(input.email); + if (normalizedEmail.accepted && this.ipAdmission && this.emailAdmission) { + const issuedAt = new Date().toISOString(); + let admitted = false; + try { + const [ipAllowed, emailAllowed] = await Promise.all([ + this.ipAdmission.allow(admissionDigest('ip', requestIp(_request)), issuedAt), + this.emailAdmission.allow(admissionDigest('email', normalizedEmail.value), issuedAt), + ]); + admitted = ipAllowed && emailAllowed; + } catch { + admitted = false; + } + if (!admitted) throw new RegistrationProblemError('REGISTRATION_REQUEST_REJECTED'); + } + const result = await this.registration.register(input); if (!result.accepted) { throw new RegistrationProblemError( diff --git a/services/api/src/features/iam/application/registration-repository.port.ts b/services/api/src/features/iam/application/registration-repository.port.ts index 75c64ee1..e0ea08c9 100644 --- a/services/api/src/features/iam/application/registration-repository.port.ts +++ b/services/api/src/features/iam/application/registration-repository.port.ts @@ -2,6 +2,13 @@ import type { PasswordCredentialV1 } from '../domain/password-credential.js'; import type { PersonalOrganizationBootstrapV1 } from '@databreeze/domain/identity/v1'; export const IAM_REGISTRATION_REPOSITORY_PORT = Symbol('IAM_REGISTRATION_REPOSITORY_PORT'); +export const IAM_REGISTRATION_IP_ADMISSION = Symbol('IAM_REGISTRATION_IP_ADMISSION'); +export const IAM_REGISTRATION_EMAIL_ADMISSION = Symbol('IAM_REGISTRATION_EMAIL_ADMISSION'); + +/** Abuse-control boundary for registration requests; callers pass only a one-way digest. */ +export interface RegistrationAdmissionPortV1 { + allow(keyDigest: string, issuedAt: string): Promise; +} export interface RegistrationPersistenceInputV1 { readonly email: string; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 57977a3f..7d07d3e1 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -101,7 +101,10 @@ import { type RegistrationDatabaseClientV1, } from './adapter/prisma-registration-repository.adapter.js'; import { + IAM_REGISTRATION_EMAIL_ADMISSION, + IAM_REGISTRATION_IP_ADMISSION, IAM_REGISTRATION_REPOSITORY_PORT, + type RegistrationAdmissionPortV1, type RegistrationRepositoryPortV1, } from './application/registration-repository.port.js'; import { @@ -231,6 +234,12 @@ export interface IamModuleOptions { readonly registrationService?: RegistrationService; readonly registrationIdGenerator?: RegistrationIdGeneratorV1; readonly registrationClock?: RegistrationClockV1; + readonly registrationIpAdmission?: RegistrationAdmissionPortV1; + readonly registrationIpAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly registrationIpAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; + readonly registrationEmailAdmission?: RegistrationAdmissionPortV1; + readonly registrationEmailAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly registrationEmailAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; readonly recoveryRepository?: RecoveryRepositoryPortV1; readonly recoveryDatabase?: RecoveryDatabaseClientV1; readonly recoveryService?: RecoveryService; @@ -384,6 +393,26 @@ export class IamModule { ...(options.registrationClock ? { clock: options.registrationClock } : {}), }) : undefined); + const registrationIpAdmission = + options.registrationIpAdmission ?? + (options.registrationIpAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 5, windowSeconds: 15 * 60 }) + : new RedisRecoveryAdmissionAdapter(options.registrationIpAdmissionCounter, { + keyPrefix: 'databreeze:iam:registration:ip:v1:', + maxAttempts: 5, + windowSeconds: 15 * 60, + ...options.registrationIpAdmissionOptions, + })); + const registrationEmailAdmission = + options.registrationEmailAdmission ?? + (options.registrationEmailAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 5, windowSeconds: 15 * 60 }) + : new RedisRecoveryAdmissionAdapter(options.registrationEmailAdmissionCounter, { + keyPrefix: 'databreeze:iam:registration:email:v1:', + maxAttempts: 5, + windowSeconds: 15 * 60, + ...options.registrationEmailAdmissionOptions, + })); const recoveryRepository = options.recoveryRepository ?? (options.recoveryDatabase === undefined @@ -616,6 +645,14 @@ export class IamModule { }, ] : []), + { + provide: IAM_REGISTRATION_IP_ADMISSION, + useValue: registrationIpAdmission, + }, + { + provide: IAM_REGISTRATION_EMAIL_ADMISSION, + useValue: registrationEmailAdmission, + }, ...(recoveryRepository ? [ { diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index 2eb5de7f..361c0e1f 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -67,3 +67,49 @@ void test('[IAM-001] registration controller fails closed when registration is n error instanceof RegistrationProblemError && error.code === 'REGISTRATION_UNAVAILABLE', ); }); + +void test('[IAM-001] registration admission rejects before invoking password hashing or persistence', async () => { + let serviceCalls = 0; + let ipCalls = 0; + let emailCalls = 0; + const controller = new RegistrationController( + { + register: async () => { + serviceCalls += 1; + return { accepted: true as const, value: { email: 'user@example.com' } }; + }, + } as unknown as RegistrationService, + { + allow: async (keyDigest: string, issuedAt: string) => { + ipCalls += 1; + assert.match(keyDigest, /^[a-f0-9]{64}$/u); + assert.match(issuedAt, /^\\d{4}-\\d{2}-\\d{2}T/u); + return false; + }, + }, + { + allow: async (keyDigest: string, issuedAt: string) => { + emailCalls += 1; + assert.match(keyDigest, /^[a-f0-9]{64}$/u); + assert.match(issuedAt, /^\\d{4}-\\d{2}-\\d{2}T/u); + return true; + }, + }, + ); + + await assert.rejects( + controller.register( + { + email: 'User@example.com', + displayName: 'Name', + password: 'valid password here', + }, + { ip: '203.0.113.10' }, + ), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_REQUEST_REJECTED', + ); + assert.equal(ipCalls, 1); + assert.equal(emailCalls, 1); + assert.equal(serviceCalls, 0); +}); From fa15e4b20fda052291fa3803e0897c13cf56c292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:06:24 +0700 Subject: [PATCH 19/43] fix(iam): honor service-account create idempotency --- .../migration.sql | 34 ++++ services/api/prisma/schema/iam.prisma | 5 + ...mory-service-account-repository.adapter.ts | 77 ++++++++- ...isma-service-account-repository.adapter.ts | 109 +++++++++++- ...service-account-secret-envelope.adapter.ts | 67 ++++++++ .../iam/api/service-account.controller.ts | 11 +- .../service-account-repository.port.ts | 29 +++- .../application/service-account.service.ts | 159 +++++++++++++++--- services/api/src/features/iam/iam.module.ts | 14 ++ .../prisma-service-account-repository.test.ts | 41 +++++ ...ce-account-secret-envelope.adapter.test.ts | 26 +++ .../iam/service-account.controller.test.ts | 11 +- .../iam/service-account.service.test.ts | 45 +++++ services/api/test/prisma-foundation.test.mjs | 20 +++ 14 files changed, 604 insertions(+), 44 deletions(-) create mode 100644 services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql create mode 100644 services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts create mode 100644 services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts diff --git a/services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql b/services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql new file mode 100644 index 00000000..9db2d0f5 --- /dev/null +++ b/services/api/prisma/migrations/20260804010000_iam_service_account_create_idempotency/migration.sql @@ -0,0 +1,34 @@ +-- IAM-013/INT-004: bind service-account create retries to one actor, target scope, and request hash. +-- The replayable one-time secret is stored only as an application-encrypted envelope. +ALTER TABLE "iam"."service_accounts" + ADD COLUMN "created_by_actor_id" UUID, + ADD COLUMN "create_idempotency_key" VARCHAR(200), + ADD COLUMN "create_request_hash" CHAR(64), + ADD COLUMN "create_secret_envelope" TEXT; + +CREATE UNIQUE INDEX "service_accounts_create_idempotency_key" +ON "iam"."service_accounts"( + "organization_id", + "workspace_id", + "created_by_actor_id", + "create_idempotency_key" +); + +-- PostgreSQL treats NULL values as distinct in a composite unique index. These partial +-- constraints close the organization-scope and workspace-scope retry races explicitly. +CREATE UNIQUE INDEX "service_accounts_create_idempotency_org_key" +ON "iam"."service_accounts"("organization_id", "created_by_actor_id", "create_idempotency_key") +WHERE "workspace_id" IS NULL + AND "created_by_actor_id" IS NOT NULL + AND "create_idempotency_key" IS NOT NULL; + +CREATE UNIQUE INDEX "service_accounts_create_idempotency_workspace_key" +ON "iam"."service_accounts"( + "organization_id", + "workspace_id", + "created_by_actor_id", + "create_idempotency_key" +) +WHERE "workspace_id" IS NOT NULL + AND "created_by_actor_id" IS NOT NULL + AND "create_idempotency_key" IS NOT NULL; diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 4e1a6404..4391be83 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -300,9 +300,14 @@ model ServiceAccountRecord { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) revision Int @default(1) + createdByActorId String? @map("created_by_actor_id") @db.Uuid + createIdempotencyKey String? @map("create_idempotency_key") @db.VarChar(200) + createRequestHash String? @map("create_request_hash") @db.Char(64) + createSecretEnvelope String? @map("create_secret_envelope") @db.Text @@index([organizationId, workspaceId, status], map: "service_accounts_scope_status_idx") @@index([secretExpiresAt, status], map: "service_accounts_expiry_status_idx") + @@unique([organizationId, workspaceId, createdByActorId, createIdempotencyKey], map: "service_accounts_create_idempotency_key") @@map("service_accounts") @@schema("iam") } diff --git a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts index 4aaf01a9..b10a98ec 100644 --- a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -1,8 +1,14 @@ -import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + tenantScopeContainsV1, + tenantScopeKeyV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; import type { + ServiceAccountCreateIdempotencyV1, + ServiceAccountCreateReplayV1, ServiceAccountRepositoryPortV1, ServiceAccountTransactionPortV1, } from '../application/service-account-repository.port.js'; @@ -33,9 +39,34 @@ function clone(account: ServiceAccountV1): ServiceAccountV1 { return Object.freeze({ ...account, permissions: Object.freeze([...account.permissions]) }); } +function cloneReplay(replay: ServiceAccountCreateReplayV1): ServiceAccountCreateReplayV1 { + return Object.freeze({ + ...replay, + account: clone(replay.account), + }); +} + +function createKey(record: ServiceAccountCreateReplayV1): string { + return `${record.actorId}:${tenantScopeKeyV1(accountScope(record.account))}:${record.idempotencyKey}`; +} + +function sameCreateRecord( + left: ServiceAccountCreateReplayV1, + right: ServiceAccountCreateReplayV1, +): boolean { + return ( + left.requestHash === right.requestHash && + left.actorId === right.actorId && + left.idempotencyKey === right.idempotencyKey && + left.secretEnvelope === right.secretEnvelope && + JSON.stringify(left.account) === JSON.stringify(right.account) + ); +} + /** Deterministic local adapter with the same visibility and optimistic-write rules as PostgreSQL. */ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRepositoryPortV1 { private accounts = new Map(); + private createRecords = new Map(); private transactionTail: Promise = Promise.resolve(); public async findServiceAccount( @@ -68,9 +99,27 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe .map(clone); } + public async findServiceAccountByIdempotency( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + idempotencyKey: string, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, targetScope)) return undefined; + const record = [...this.createRecords.values()].find( + (candidate) => + candidate.actorId === context.actorId && + candidate.idempotencyKey === idempotencyKey && + tenantScopeKeyV1(accountScope(candidate.account)) === tenantScopeKeyV1(targetScope) && + visibleInScope(context, candidate.account), + ); + return record ? cloneReplay(record) : undefined; + } + public async saveServiceAccount( context: IamTenantContextV1, account: ServiceAccountV1, + createIdempotency?: ServiceAccountCreateIdempotencyV1, ): Promise { await Promise.resolve(); if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); @@ -84,6 +133,29 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe (candidate) => candidate.secretDigest === account.secretDigest, ); if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + if (createIdempotency) { + if ( + createIdempotency.actorId !== context.actorId || + createIdempotency.idempotencyKey.length === 0 || + createIdempotency.idempotencyKey.length > 200 || + !/^[a-f0-9]{64}$/u.test(createIdempotency.requestHash) || + createIdempotency.secretEnvelope.length === 0 || + createIdempotency.secretEnvelope.length > 16_384 || + /\p{Cc}/u.test(createIdempotency.secretEnvelope) + ) + throw new Error('IAM_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + const replay = Object.freeze({ + account: clone(account), + actorId: createIdempotency.actorId, + idempotencyKey: createIdempotency.idempotencyKey, + requestHash: createIdempotency.requestHash, + secretEnvelope: createIdempotency.secretEnvelope, + }); + const existingReplay = this.createRecords.get(createKey(replay)); + if (existingReplay && !sameCreateRecord(existingReplay, replay)) + throw new Error('IDEMPOTENCY_KEY_REUSED'); + this.createRecords.set(createKey(replay), replay); + } this.accounts.set(account.id, clone(account)); } @@ -117,16 +189,19 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe }); await previous; const before = new Map(this.accounts); + const beforeCreateRecords = new Map(this.createRecords); try { return await work({ findServiceAccount: this.findServiceAccount.bind(this), findServiceAccountByDigest: this.findServiceAccountByDigest.bind(this), listServiceAccounts: this.listServiceAccounts.bind(this), + findServiceAccountByIdempotency: this.findServiceAccountByIdempotency.bind(this), saveServiceAccount: this.saveServiceAccount.bind(this), replaceServiceAccount: this.replaceServiceAccount.bind(this), }); } catch (error) { this.accounts = before; + this.createRecords = beforeCreateRecords; throw error; } finally { release(); diff --git a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts index d652a262..c2d30897 100644 --- a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -4,6 +4,7 @@ import { } from '@databreeze/domain/service-account/v1'; import { parseStrictUtcTimestampV1, + parseStableIdentifierV1, tenantScopeContainsV1, type StableIdentifierV1, type StrictUtcTimestampV1, @@ -12,6 +13,8 @@ import { import type { IamTenantContextV1 } from '../application/tenant-context.js'; import type { + ServiceAccountCreateIdempotencyV1, + ServiceAccountCreateReplayV1, ServiceAccountRepositoryPortV1, ServiceAccountTransactionPortV1, } from '../application/service-account-repository.port.js'; @@ -31,6 +34,10 @@ export interface ServiceAccountDatabaseRowV1 { readonly createdAt: Date; readonly revokedAt: Date | null; readonly revision: number; + readonly createdByActorId?: string | null; + readonly createIdempotencyKey?: string | null; + readonly createRequestHash?: string | null; + readonly createSecretEnvelope?: string | null; } interface ServiceAccountDelegateV1 { @@ -122,8 +129,11 @@ function accountFromRow(row: ServiceAccountDatabaseRowV1): ServiceAccountV1 { }); } -function accountData(account: ServiceAccountV1): Readonly> { - return { +function accountData( + account: ServiceAccountV1, + createIdempotency?: ServiceAccountCreateIdempotencyV1, +): Readonly> { + const data: Record = { id: account.id, organizationId: account.organizationId, workspaceId: account.workspaceId ?? null, @@ -139,6 +149,47 @@ function accountData(account: ServiceAccountV1): Readonly 200 || + typeof requestHash !== 'string' || + !/^[a-f0-9]{64}$/u.test(requestHash) || + typeof secretEnvelope !== 'string' || + secretEnvelope.length === 0 || + secretEnvelope.length > 16_384 || + /\p{Cc}/u.test(secretEnvelope) + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + return Object.freeze({ + account: accountFromRow(row), + actorId: actor.value, + idempotencyKey: row.createIdempotencyKey, + requestHash, + secretEnvelope, + }); } function scopeWhere(context: IamTenantContextV1): Readonly> { @@ -187,11 +238,44 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio return rows.map(accountFromRow); } + public async findServiceAccountByIdempotency( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + idempotencyKey: string, + ): Promise { + if ( + !tenantScopeContainsV1(context.tenantScope, targetScope) || + (targetScope.scopeType !== 'organization' && targetScope.scopeType !== 'workspace') + ) + return undefined; + const row = await this.client.serviceAccount.findFirst({ + where: { + organizationId: targetScope.organizationId, + workspaceId: targetScope.scopeType === 'workspace' ? targetScope.workspaceId : null, + createdByActorId: context.actorId, + createIdempotencyKey: idempotencyKey, + }, + }); + return row ? persistedReplay(row) : undefined; + } + public async saveServiceAccount( context: IamTenantContextV1, account: ServiceAccountV1, + createIdempotency?: ServiceAccountCreateIdempotencyV1, ): Promise { if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + if ( + createIdempotency && + (createIdempotency.actorId !== context.actorId || + createIdempotency.idempotencyKey.length === 0 || + createIdempotency.idempotencyKey.length > 200 || + !/^[a-f0-9]{64}$/u.test(createIdempotency.requestHash) || + createIdempotency.secretEnvelope.length === 0 || + createIdempotency.secretEnvelope.length > 16_384 || + /\p{Cc}/u.test(createIdempotency.secretEnvelope)) + ) + throw new Error('IAM_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); const existing = await this.client.serviceAccount.findFirst({ where: { id: account.id, organizationId: account.organizationId }, }); @@ -201,7 +285,7 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio return; } try { - await this.client.serviceAccount.create({ data: accountData(account) }); + await this.client.serviceAccount.create({ data: accountData(account, createIdempotency) }); } catch (error) { if (isUniqueConflict(error)) throw new Error('SERVICE_ACCOUNT_CONFLICT'); throw error; @@ -249,10 +333,27 @@ export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepo ); } - public saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1) { + public saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + createIdempotency?: ServiceAccountCreateIdempotencyV1, + ) { return new PrismaServiceAccountTransactionAdapter(this.client).saveServiceAccount( context, account, + createIdempotency, + ); + } + + public findServiceAccountByIdempotency( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + idempotencyKey: string, + ) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccountByIdempotency( + context, + targetScope, + idempotencyKey, ); } diff --git a/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts b/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts new file mode 100644 index 00000000..63ed303f --- /dev/null +++ b/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts @@ -0,0 +1,67 @@ +import { createDecipheriv, createCipheriv, randomBytes } from 'node:crypto'; + +import type { ServiceAccountSecretEnvelopePortV1 } from '../application/service-account.service.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +function keyBytes(input: string | Uint8Array): Buffer { + const key = typeof input === 'string' ? Buffer.from(input, 'base64url') : Buffer.from(input); + if (key.length !== 32) throw new Error('IAM_SERVICE_ACCOUNT_ENVELOPE_KEY_INVALID'); + return key; +} + +/** Encrypts replayable one-time secrets before an idempotency record reaches durable storage. */ +export class AesGcmServiceAccountSecretEnvelopeAdapter + implements ServiceAccountSecretEnvelopePortV1 +{ + private readonly key: Buffer; + + public constructor(input: string | Uint8Array) { + this.key = keyBytes(input); + } + + public seal(secret: string): string { + if ( + typeof secret !== 'string' || + secret.length === 0 || + secret.length > 512 || + /\p{Cc}/u.test(secret) + ) + throw new Error('IAM_SERVICE_ACCOUNT_SECRET_INVALID'); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, this.key, iv); + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`; + } + + public open(envelope: string): string | undefined { + if (typeof envelope !== 'string') return undefined; + const [version, ivEncoded, tagEncoded, ciphertextEncoded] = envelope.split('.'); + if (version !== 'v1' || !ivEncoded || !tagEncoded || !ciphertextEncoded) return undefined; + try { + const iv = Buffer.from(ivEncoded, 'base64url'); + const tag = Buffer.from(tagEncoded, 'base64url'); + const ciphertext = Buffer.from(ciphertextEncoded, 'base64url'); + if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES || ciphertext.length === 0) + return undefined; + const decipher = createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + 'utf8', + ); + if (plaintext.length === 0 || plaintext.length > 512 || /\p{Cc}/u.test(plaintext)) + return undefined; + return plaintext; + } catch { + return undefined; + } + } +} + +/** Process-local fallback for tests and the private alpha when no durable key is configured. */ +export function randomServiceAccountSecretEnvelopeAdapter(): AesGcmServiceAccountSecretEnvelopeAdapter { + return new AesGcmServiceAccountSecretEnvelopeAdapter(randomBytes(32)); +} diff --git a/services/api/src/features/iam/api/service-account.controller.ts b/services/api/src/features/iam/api/service-account.controller.ts index 26994555..a3041fcc 100644 --- a/services/api/src/features/iam/api/service-account.controller.ts +++ b/services/api/src/features/iam/api/service-account.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Headers, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; @@ -69,13 +69,10 @@ export class ServiceAccountController { summary: 'Create an action-scoped service account and return its one-time secret', }) @ApiBody({ type: CreateServiceAccountDto }) - async create( - @Req() request: unknown, - @Headers('idempotency-key') _idempotencyKey: string | undefined, - @Body() input: CreateServiceAccountDto, - ): Promise { + async create(@Req() request: unknown, @Body() input: CreateServiceAccountDto): Promise { const context = await this.requestContext.resolve(request); - void _idempotencyKey; + // The authenticated request-context adapter derives and validates Idempotency-Key. Keeping + // that value in the immutable context prevents a controller-only header from being ignored. return this.execute(() => this.serviceAccounts.create(context, input)); } diff --git a/services/api/src/features/iam/application/service-account-repository.port.ts b/services/api/src/features/iam/application/service-account-repository.port.ts index c7fc438c..fcdcaeac 100644 --- a/services/api/src/features/iam/application/service-account-repository.port.ts +++ b/services/api/src/features/iam/application/service-account-repository.port.ts @@ -1,10 +1,26 @@ import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; -import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from './tenant-context.js'; export const SERVICE_ACCOUNT_REPOSITORY_PORT = Symbol('SERVICE_ACCOUNT_REPOSITORY_PORT'); +export interface ServiceAccountCreateIdempotencyV1 { + readonly idempotencyKey: string; + readonly requestHash: string; + readonly actorId: StableIdentifierV1; + /** Opaque AEAD envelope; raw one-time secrets never enter a repository. */ + readonly secretEnvelope: string; +} + +export interface ServiceAccountCreateReplayV1 { + readonly account: ServiceAccountV1; + readonly idempotencyKey: string; + readonly requestHash: string; + readonly actorId: StableIdentifierV1; + readonly secretEnvelope: string; +} + export interface ServiceAccountTransactionPortV1 { findServiceAccount( context: IamTenantContextV1, @@ -15,7 +31,16 @@ export interface ServiceAccountTransactionPortV1 { secretDigest: string, ): Promise; listServiceAccounts(context: IamTenantContextV1): Promise; - saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1): Promise; + findServiceAccountByIdempotency( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + idempotencyKey: string, + ): Promise; + saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + createIdempotency?: ServiceAccountCreateIdempotencyV1, + ): Promise; replaceServiceAccount( context: IamTenantContextV1, account: ServiceAccountV1, diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts index d643e990..bb2adf29 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -18,13 +18,17 @@ import { } from '@databreeze/domain/permissions/v1'; import { parseStableIdentifierV1, + parseStrictUtcTimestampV1, tenantScopeContainsV1, type StableIdentifierV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; import type { IamRepositoryPortV1 } from './iam-repository.port.js'; -import type { ServiceAccountRepositoryPortV1 } from './service-account-repository.port.js'; +import type { + ServiceAccountCreateReplayV1, + ServiceAccountRepositoryPortV1, +} from './service-account-repository.port.js'; import type { IamTenantContextV1 } from './tenant-context.js'; export const SERVICE_ACCOUNT_SERVICE = Symbol('SERVICE_ACCOUNT_SERVICE'); @@ -38,6 +42,11 @@ export interface ServiceAccountSecretIssuerV1 { issue(): ServiceAccountSecretIssueV1; } +export interface ServiceAccountSecretEnvelopePortV1 { + seal(secret: string): string; + open(envelope: string): string | undefined; +} + export type ServiceAccountClockV1 = () => Date; export type ServiceAccountIdGeneratorV1 = () => string; @@ -183,6 +192,56 @@ function serviceAccountPermissions(input: unknown): input is readonly Permission ); } +function normalizedName(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > 200) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= 200 ? normalized : undefined; +} + +function normalizedExpiry(input: unknown): string | undefined { + if (input === undefined) return undefined; + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function createRequestHash( + input: CreateServiceAccountInputV1, + workspaceId: StableIdentifierV1 | undefined, +): string | undefined { + const name = normalizedName(input.name); + const expiry = normalizedExpiry(input.secretExpiresAt); + if (!name || !serviceAccountPermissions(input.permissions)) return undefined; + if (input.secretExpiresAt !== undefined && expiry === undefined) return undefined; + try { + return createHash('sha256') + .update( + JSON.stringify({ + name, + workspaceId: workspaceId ?? null, + permissions: input.permissions, + secretExpiresAt: expiry ?? null, + }), + 'utf8', + ) + .digest('hex'); + } catch { + return undefined; + } +} + +function replayCreate( + replay: ServiceAccountCreateReplayV1, + requestHash: string, + envelope: ServiceAccountSecretEnvelopePortV1, +): ServiceAccountApplicationResultV1 { + if (replay.requestHash !== requestHash) return rejected('CONFLICT'); + const secret = envelope.open(replay.secretEnvelope); + if (!secret || !safeDigestEqual(digestSecret(secret) ?? '', replay.account.secretDigest)) + return rejected('UNAVAILABLE'); + return accepted(Object.freeze({ account: safeView(replay.account), secret })); +} + /** IAM-013: action-scoped service identities with one-time credential issuance. */ export class ServiceAccountService { public constructor( @@ -191,6 +250,7 @@ export class ServiceAccountService { private readonly secretIssuer: ServiceAccountSecretIssuerV1, private readonly clock: ServiceAccountClockV1 = () => new Date(), private readonly idGenerator: ServiceAccountIdGeneratorV1 = () => randomUUID(), + private readonly secretEnvelope?: ServiceAccountSecretEnvelopePortV1, ) {} public async create( @@ -209,33 +269,80 @@ export class ServiceAccountService { ); if (authorization !== 'ALLOWED') return rejected(authorization); if (!serviceAccountPermissions(input.permissions)) return rejected('INVALID_INPUT'); - let now: string; - let id: string; - let secret: ServiceAccountSecretIssueV1; + const requestHash = createRequestHash(input, workspaceId); + if (!requestHash) return rejected('INVALID_INPUT'); + if (!this.secretEnvelope) return rejected('UNAVAILABLE'); + const secretEnvelopePort = this.secretEnvelope; try { - now = this.clock().toISOString(); - id = this.idGenerator(); - secret = this.secretIssuer.issue(); - } catch { - return rejected('UNAVAILABLE'); - } - const candidate = createServiceAccountV1({ - id, - organizationId: context.tenantScope.organizationId, - ...(workspaceId === undefined ? {} : { workspaceId }), - name: input.name, - permissions: input.permissions, - secretDigest: secret.digest, - secretIssuedAt: now, - ...(input.secretExpiresAt === undefined ? {} : { secretExpiresAt: input.secretExpiresAt }), - createdAt: now, - }); - if (!candidate.accepted) return rejected(mapDomainCode(candidate.code)); - try { - await this.repository.saveServiceAccount(context, candidate.value); - return accepted(Object.freeze({ account: safeView(candidate.value), secret: secret.secret })); + return await this.repository.withTransaction(context, async (transaction) => { + const replay = await transaction.findServiceAccountByIdempotency( + context, + targetScope, + context.idempotencyKey, + ); + if (replay) return replayCreate(replay, requestHash, secretEnvelopePort); + + let now: string; + let id: string; + let secret: ServiceAccountSecretIssueV1; + try { + now = this.clock().toISOString(); + id = this.idGenerator(); + secret = this.secretIssuer.issue(); + } catch { + return rejected('UNAVAILABLE'); + } + const candidate = createServiceAccountV1({ + id, + organizationId: context.tenantScope.organizationId, + ...(workspaceId === undefined ? {} : { workspaceId }), + name: input.name, + permissions: input.permissions, + secretDigest: secret.digest, + secretIssuedAt: now, + ...(input.secretExpiresAt === undefined + ? {} + : { secretExpiresAt: input.secretExpiresAt }), + createdAt: now, + }); + if (!candidate.accepted) return rejected(mapDomainCode(candidate.code)); + let secretEnvelope: string; + try { + secretEnvelope = secretEnvelopePort.seal(secret.secret); + } catch { + return rejected('UNAVAILABLE'); + } + if ( + secretEnvelope.length === 0 || + secretEnvelope.length > 16_384 || + /\p{Cc}/u.test(secretEnvelope) + ) + return rejected('UNAVAILABLE'); + await transaction.saveServiceAccount(context, candidate.value, { + actorId: context.actorId, + idempotencyKey: context.idempotencyKey, + requestHash, + secretEnvelope, + }); + return accepted( + Object.freeze({ account: safeView(candidate.value), secret: secret.secret }), + ); + }); } catch (error) { - return rejected(mapRepositoryError(error)); + const mapped = mapRepositoryError(error); + if (mapped === 'CONFLICT') { + try { + const replay = await this.repository.findServiceAccountByIdempotency( + context, + targetScope, + context.idempotencyKey, + ); + if (replay) return replayCreate(replay, requestHash, secretEnvelopePort); + } catch { + return rejected('UNAVAILABLE'); + } + } + return rejected(mapped); } } diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 7d07d3e1..b9f9ada9 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -180,6 +180,7 @@ import { UnavailableServiceAccountService, type ServiceAccountClockV1, type ServiceAccountIdGeneratorV1, + type ServiceAccountSecretEnvelopePortV1, type ServiceAccountSecretIssuerV1, } from './application/service-account.service.js'; import { InMemoryServiceAccountRepositoryAdapter } from './adapter/in-memory-service-account-repository.adapter.js'; @@ -188,6 +189,10 @@ import { type ServiceAccountDatabaseClientV1, } from './adapter/prisma-service-account-repository.adapter.js'; import { RandomServiceAccountSecretIssuer } from './adapter/random-service-account-secret.adapter.js'; +import { + AesGcmServiceAccountSecretEnvelopeAdapter, + randomServiceAccountSecretEnvelopeAdapter, +} from './adapter/service-account-secret-envelope.adapter.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -263,6 +268,9 @@ export interface IamModuleOptions { readonly serviceAccountRepository?: ServiceAccountRepositoryPortV1; readonly serviceAccountDatabase?: ServiceAccountDatabaseClientV1; readonly serviceAccountSecretIssuer?: ServiceAccountSecretIssuerV1; + readonly serviceAccountSecretEnvelope?: ServiceAccountSecretEnvelopePortV1; + /** Base64url-encoded 32-byte key; use a managed secret in durable environments. */ + readonly serviceAccountSecretEnvelopeKey?: string; readonly serviceAccountClock?: ServiceAccountClockV1; readonly serviceAccountIdGenerator?: ServiceAccountIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -478,6 +486,11 @@ export class IamModule { (options.serviceAccountDatabase === undefined ? new InMemoryServiceAccountRepositoryAdapter() : new PrismaServiceAccountRepositoryAdapter(options.serviceAccountDatabase)); + const serviceAccountSecretEnvelope = + options.serviceAccountSecretEnvelope ?? + (options.serviceAccountSecretEnvelopeKey + ? new AesGcmServiceAccountSecretEnvelopeAdapter(options.serviceAccountSecretEnvelopeKey) + : randomServiceAccountSecretEnvelopeAdapter()); const serviceAccountService = options.serviceAccountService ?? (iamRepository === undefined @@ -488,6 +501,7 @@ export class IamModule { options.serviceAccountSecretIssuer ?? new RandomServiceAccountSecretIssuer(), options.serviceAccountClock, options.serviceAccountIdGenerator, + serviceAccountSecretEnvelope, )); const exports = [ DEVICE_IDENTITY_REPOSITORY_PORT, diff --git a/services/api/test/features/iam/prisma-service-account-repository.test.ts b/services/api/test/features/iam/prisma-service-account-repository.test.ts index 0e8690cd..6b103faf 100644 --- a/services/api/test/features/iam/prisma-service-account-repository.test.ts +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -187,6 +187,47 @@ void test('[IAM-013] Prisma service-account adapter persists and filters workspa ); }); +void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scoped create idempotency metadata', async () => { + const rows: Record[] = []; + const repository = new PrismaServiceAccountRepositoryAdapter(client(rows)); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'create-key'); + const value = account(); + await repository.saveServiceAccount(organizationContext, value, { + actorId: organizationContext.actorId, + idempotencyKey: 'create-key', + requestHash: 'b'.repeat(64), + secretEnvelope: 'v1.encrypted-envelope', + }); + const replay = await repository.findServiceAccountByIdempotency( + organizationContext, + { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + 'create-key', + ); + assert.equal(replay?.account.id, value.id); + assert.equal(replay?.requestHash, 'b'.repeat(64)); + assert.equal(replay?.secretEnvelope, 'v1.encrypted-envelope'); + assert.equal(JSON.stringify(rows[0]).includes('dbsa'), false); + await repository.replaceServiceAccount( + organizationContext, + Object.freeze({ ...value, name: 'Changed after create', revision: 2 }), + 1, + ); + const replayAfterLifecycleWrite = await repository.findServiceAccountByIdempotency( + organizationContext, + { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + 'create-key', + ); + assert.equal(replayAfterLifecycleWrite?.secretEnvelope, 'v1.encrypted-envelope'); +}); + void test('[IAM-013] Prisma service-account adapter uses optimistic revisions and rejects races', async () => { const repository = new PrismaServiceAccountRepositoryAdapter(client([rowFor()])); const next = Object.freeze({ ...account(), name: 'Changed', revision: 2 }); diff --git a/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts new file mode 100644 index 00000000..fd5ab3dc --- /dev/null +++ b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + AesGcmServiceAccountSecretEnvelopeAdapter, + randomServiceAccountSecretEnvelopeAdapter, +} from '../../../src/features/iam/adapter/service-account-secret-envelope.adapter.js'; + +void test('[IAM-013] service-account replay envelopes decrypt only with the configured key', () => { + const adapter = new AesGcmServiceAccountSecretEnvelopeAdapter('a'.repeat(43)); + const envelope = adapter.seal('dbsa-secret'); + assert.notEqual(envelope, 'dbsa-secret'); + assert.equal(adapter.open(envelope), 'dbsa-secret'); + assert.equal(adapter.open(`${envelope}tampered`), undefined); + assert.equal( + new AesGcmServiceAccountSecretEnvelopeAdapter('b'.repeat(43)).open(envelope), + undefined, + ); +}); + +void test('[IAM-013] the private-alpha envelope fallback still keeps raw secrets out of records', () => { + const adapter = randomServiceAccountSecretEnvelopeAdapter(); + const envelope = adapter.seal('dbsa-alpha'); + assert.equal(envelope.includes('dbsa-alpha'), false); + assert.equal(adapter.open(envelope), 'dbsa-alpha'); +}); diff --git a/services/api/test/features/iam/service-account.controller.test.ts b/services/api/test/features/iam/service-account.controller.test.ts index 8e0ef183..4fe8f9dd 100644 --- a/services/api/test/features/iam/service-account.controller.test.ts +++ b/services/api/test/features/iam/service-account.controller.test.ts @@ -47,10 +47,13 @@ void test('[IAM-013] controller exposes safe list/create/rotate/revoke results', const instance = controller(); assert.deepEqual(await instance.list({}, organizationId), [{ id: serviceAccountId }]); assert.deepEqual( - await instance.create({}, 'request-key', { - name: 'Import worker', - permissions: ['artifact.record.read'], - }), + await instance.create( + {}, + { + name: 'Import worker', + permissions: ['artifact.record.read'], + }, + ), { account: { id: serviceAccountId }, secret: 'one-time' }, ); assert.deepEqual(await instance.rotate({}, serviceAccountId, { expectedRevision: 1 }), { diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts index 7af15cb2..522de150 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-repository.adapter.js'; import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { randomServiceAccountSecretEnvelopeAdapter } from '../../../src/features/iam/adapter/service-account-secret-envelope.adapter.js'; import { ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; import { @@ -81,6 +82,7 @@ function service(secretIssuerInput?: { secretIssuer, () => new Date('2026-01-01T00:00:00.000Z'), () => accountId, + randomServiceAccountSecretEnvelopeAdapter(), ); return service; } @@ -101,6 +103,49 @@ void test('[IAM-013] authorized creation returns a one-time secret but never the assert.equal(result.value.account.status, 'ACTIVE'); }); +void test('[IAM-013] service-account creation replays the same idempotent result without reissuing a secret', async () => { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership()]); + const repository = new InMemoryServiceAccountRepositoryAdapter(); + let issued = 0; + const accountService = new ServiceAccountService( + repository, + iam, + { + issue: () => { + issued += 1; + const secret = issued === 1 ? 'dbsa_idempotent_first' : 'dbsa_idempotent_second'; + return { secret, digest: createHash('sha256').update(secret, 'utf8').digest('hex') }; + }, + }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + randomServiceAccountSecretEnvelopeAdapter(), + ); + const request = { + name: 'Idempotent worker', + permissions: ['artifact.record.read'], + }; + const first = await accountService.create( + context({ scopeType: 'organization', organizationId }, 'create-idempotency'), + request, + ); + const replay = await accountService.create( + context({ scopeType: 'organization', organizationId }, 'create-idempotency'), + request, + ); + assert.deepEqual(replay, first); + assert.equal(issued, 1); + assert.deepEqual( + await accountService.create( + context({ scopeType: 'organization', organizationId }, 'create-idempotency'), + { ...request, name: 'Changed request' }, + ), + { accepted: false, code: 'CONFLICT' }, + ); + assert.equal(issued, 1); +}); + void test('[IAM-013] invalid service-account permissions fail before secret issuance', async () => { let issued = 0; const accountService = service({ diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 3b70d781..e671eec0 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -136,6 +136,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803070000_bua_entitlement_leases', '20260803080000_aud_seal_attestations', '20260804000000_iam_invitation_active_membership_unique', + '20260804010000_iam_service_account_create_idempotency', 'migration_lock.toml', ]); const migration = await readFile( @@ -573,4 +574,23 @@ test('the schema diff and centrally ordered migration inventory establish platfo activeInvitationMigration, /CREATE UNIQUE INDEX "invitation_tokens_active_membership_key"/u, ); + const serviceAccountIdempotencyMigration = await readFile( + path.join( + migrationsDirectory, + '20260804010000_iam_service_account_create_idempotency', + 'migration.sql', + ), + 'utf8', + ); + for (const statement of [ + 'ADD COLUMN "created_by_actor_id" UUID', + 'ADD COLUMN "create_secret_envelope" TEXT', + 'CREATE UNIQUE INDEX "service_accounts_create_idempotency_org_key"', + 'CREATE UNIQUE INDEX "service_accounts_create_idempotency_workspace_key"', + ]) { + assert.match( + serviceAccountIdempotencyMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 40af06b9728b083b46f6ad364f2da50cc816bc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:07:13 +0700 Subject: [PATCH 20/43] test(iam): exercise registration transaction rollback --- .../features/iam/registration.service.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts index 46f04067..44bc5c00 100644 --- a/services/api/test/features/iam/registration.service.test.ts +++ b/services/api/test/features/iam/registration.service.test.ts @@ -126,16 +126,15 @@ void test('[IAM-001] registration rolls back when persistence fails after stagin const service = new RegistrationService({ repository: { withTransaction: async (work) => - work({ - findByEmail: async () => { - await Promise.resolve(); - return false; - }, - save: async () => { - await Promise.resolve(); - throw new Error('database unavailable'); - }, - }), + repository.withTransaction(async (transaction) => + work({ + findByEmail: transaction.findByEmail.bind(transaction), + save: async () => { + await Promise.resolve(); + throw new Error('database unavailable'); + }, + }), + ), }, passwordCredentials: passwordCredentials(), ids: ids(), From 397087b32cc8533eaca5c5ad0c5752da93a4c9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:07:41 +0700 Subject: [PATCH 21/43] test(prisma): fix migration assertion escaping --- services/api/test/prisma-foundation.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index e671eec0..ac23cb99 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -559,7 +559,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo ]) { assert.match( invitationMigration, - new RegExp(statement.replaceAll(/[.*+?^${}()|[\\]\\]/g, '\\$&')), + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } const activeInvitationMigration = await readFile( From 5f1bc30a71652cd6021a98a4fff0edb1805c8ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:08:00 +0700 Subject: [PATCH 22/43] docs(iam): refresh invitation slice checkpoint --- docs/operations/iam-010-invitation-token-2026-08-03.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/operations/iam-010-invitation-token-2026-08-03.md b/docs/operations/iam-010-invitation-token-2026-08-03.md index 67d4db89..0a5dc2ef 100644 --- a/docs/operations/iam-010-invitation-token-2026-08-03.md +++ b/docs/operations/iam-010-invitation-token-2026-08-03.md @@ -14,6 +14,9 @@ complete. - `PrismaIamInvitationRepositoryAdapter` maps persisted rows through domain validation, enforces tenant scope visibility, rejects sibling-token reads, prevents multiple active invitations per membership, and uses compare-and-set revisions for redemption and membership activation. +- The versioned invitation controller and IAM module composition now expose the acceptance flow + through the same replaceable repository and delivery ports; the HTTP boundary never returns the + raw token. - The Prisma foundation test proves the schema diff and migration inventory include the new table. ## Verification @@ -25,7 +28,6 @@ complete. ## Explicitly not complete -The invitation HTTP/controller and production composition wiring, transactional AUD append, -registration for unknown recipients, resend/revocation administration, email-provider adapter, -and production PostgreSQL/backup/security evidence remain future work. IAM-010 therefore remains -`partial` and `not-verified` in the requirement manifest. +Transactional AUD append, registration for unknown recipients, resend/revocation administration, +an SMTP/SES delivery adapter, and production PostgreSQL/backup/security evidence remain future +work. IAM-010 therefore remains `partial` and `not-verified` in the requirement manifest. From 8b48661b34bfa866eb3eec3ebd0b20a3315b7e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:09:14 +0700 Subject: [PATCH 23/43] test(iam): verify registration admission composition --- .../iam/registration-composition.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts index 27e7805e..4721f1e0 100644 --- a/services/api/test/features/iam/registration-composition.test.ts +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -2,6 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { PrismaRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-registration-repository.adapter.js'; +import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; +import { + IAM_REGISTRATION_EMAIL_ADMISSION, + IAM_REGISTRATION_IP_ADMISSION, +} from '../../../src/features/iam/application/registration-repository.port.js'; import { IAM_REGISTRATION_REPOSITORY_PORT } from '../../../src/features/iam/application/registration-repository.port.js'; import { IAM_REGISTRATION_SERVICE, @@ -62,3 +67,29 @@ void test('[IAM-001] durable registration requires password credentials before c assert.ok(repository.useValue instanceof PrismaRegistrationRepositoryAdapter); assert.ok(service.useValue instanceof RegistrationService); }); + +void test('[IAM-001] registration admission uses separate shared Redis namespaces for IP and email', async () => { + const keys: string[] = []; + const counter = { + incrementWindow: async (input: { readonly key: string; readonly ttlMs: number }) => { + keys.push(input.key); + return 1; + }, + }; + const configured = IamModule.register({ + registrationService: {} as RegistrationService, + registrationIpAdmissionCounter: counter, + registrationEmailAdmissionCounter: counter, + }); + const ip = provider(configured, IAM_REGISTRATION_IP_ADMISSION); + const email = provider(configured, IAM_REGISTRATION_EMAIL_ADMISSION); + assert.ok(ip && 'useValue' in ip && ip.useValue instanceof RedisRecoveryAdmissionAdapter); + assert.ok( + email && 'useValue' in email && email.useValue instanceof RedisRecoveryAdmissionAdapter, + ); + if (!ip || !('useValue' in ip) || !email || !('useValue' in email)) return; + await ip.useValue.allow('a'.repeat(64), '2026-01-01T00:00:00.000Z'); + await email.useValue.allow('b'.repeat(64), '2026-01-01T00:00:00.000Z'); + assert.match(keys[0] ?? '', /databreeze:iam:registration:ip:v1:/u); + assert.match(keys[1] ?? '', /databreeze:iam:registration:email:v1:/u); +}); From 108cc0a06c191e31390eb505c69980fe19faf3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:11:54 +0700 Subject: [PATCH 24/43] test(iam): prove registration rollback after staged write --- services/api/test/features/iam/registration.service.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts index 44bc5c00..1f97d1ae 100644 --- a/services/api/test/features/iam/registration.service.test.ts +++ b/services/api/test/features/iam/registration.service.test.ts @@ -129,8 +129,8 @@ void test('[IAM-001] registration rolls back when persistence fails after stagin repository.withTransaction(async (transaction) => work({ findByEmail: transaction.findByEmail.bind(transaction), - save: async () => { - await Promise.resolve(); + save: async (input) => { + await transaction.save(input); throw new Error('database unavailable'); }, }), From 000554cce8f01f75f22af27ac0b7cdb262a06b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:12:38 +0700 Subject: [PATCH 25/43] fix(iam): distinguish revoked invitation tokens --- packages/domain/src/invitation/v1.ts | 3 ++- packages/domain/test/invitation-v1.test.mjs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts index 3b1f40c6..0394b205 100644 --- a/packages/domain/src/invitation/v1.ts +++ b/packages/domain/src/invitation/v1.ts @@ -136,7 +136,8 @@ export function consumeInvitationTokenV1( ): InvitationTokenResultV1 { const timestampValue = timestamp(at); if (!timestampValue) return rejected('INVALID_TIMESTAMP'); - if (token.status !== 'ACTIVE') return rejected('ALREADY_CONSUMED'); + if (token.status === 'REDEEMED') return rejected('ALREADY_CONSUMED'); + if (token.status !== 'ACTIVE') return rejected('INVALID_STATE'); const nowMs = Date.parse(timestampValue); const issuedMs = Date.parse(token.issuedAt); const expiresMs = Date.parse(token.expiresAt); diff --git a/packages/domain/test/invitation-v1.test.mjs b/packages/domain/test/invitation-v1.test.mjs index a9e56825..63c94f4f 100644 --- a/packages/domain/test/invitation-v1.test.mjs +++ b/packages/domain/test/invitation-v1.test.mjs @@ -90,3 +90,14 @@ void test('[IAM-010] consuming after expiry or before issue fails closed', () => { accepted: false, code: 'INVALID_TIMESTAMP' }, ); }); + +void test('[IAM-010] consuming a revoked token returns an invalid-state result', () => { + const created = createInvitationTokenV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const revoked = { ...created.value, status: 'REVOKED' }; + assert.deepEqual(consumeInvitationTokenV1(revoked, issuedAt), { + accepted: false, + code: 'INVALID_STATE', + }); +}); From 417885ed00d5029c90683468b7cd340e5444ec20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:13:02 +0700 Subject: [PATCH 26/43] docs(iam): refresh registration slice evidence --- docs/operations/iam-registration-2026-08-03.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/operations/iam-registration-2026-08-03.md b/docs/operations/iam-registration-2026-08-03.md index 68c8028c..aeafe729 100644 --- a/docs/operations/iam-registration-2026-08-03.md +++ b/docs/operations/iam-registration-2026-08-03.md @@ -9,7 +9,8 @@ It does not claim that IAM-001 or the IAM plan is complete. - Hash the password through the existing Argon2id password port; raw passwords never enter persistence. - Create the user, credential, personal organization, workspace, internal project, and owner membership in one transaction. - Keep duplicate-email responses generic and map persistence races to a safe rejection. -- Return only hierarchy identifiers and locale from `POST /v1/auth/register`; the endpoint never returns bearer material or automatically creates a session. +- Return a generic `202 Accepted` body (`{"accepted":true}`) from `POST /v1/auth/register`; the endpoint never returns hierarchy identifiers, bearer material, or an account-existence signal and never automatically creates a session. +- Apply bounded, domain-separated admission before Argon2id hashing: the control plane hashes the normalized client IP and normalized email into separate rate-limit namespaces, and the durable Redis adapter fails closed on counter errors. - Select the Prisma registration adapter only when durable registration storage and the password boundary are configured; otherwise the endpoint fails closed. ## Evidence @@ -23,4 +24,4 @@ It does not claim that IAM-001 or the IAM plan is complete. ## Verification -The scoped API TypeScript build, registration tests, i18n tests, OpenAPI generation/check, and Redocly validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until the complete IAM release gates, audit integration, recovery, MFA, and restoration evidence are delivered. +The scoped API TypeScript build, registration tests, i18n tests, OpenAPI generation/check, and Redocly validation passed on 2026-08-04. The rollback test stages the row in the transaction before injecting a persistence failure, proving no partial hierarchy remains. Admission tests prove IP/email throttling occurs before password hashing and persistence. The requirement remains `partial` and `not-verified` until the complete IAM release gates, audit integration, recovery, MFA, and restoration evidence are delivered. From 6231dfd436e63a3a99db4a5e70b2efe6ac5fa6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:13:57 +0700 Subject: [PATCH 27/43] test(iam): fail closed on unreadable account replay --- .../iam/service-account.service.test.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts index 522de150..c7fac27d 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -4,7 +4,10 @@ import { createHash } from 'node:crypto'; import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-repository.adapter.js'; import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; -import { randomServiceAccountSecretEnvelopeAdapter } from '../../../src/features/iam/adapter/service-account-secret-envelope.adapter.js'; +import { + AesGcmServiceAccountSecretEnvelopeAdapter, + randomServiceAccountSecretEnvelopeAdapter, +} from '../../../src/features/iam/adapter/service-account-secret-envelope.adapter.js'; import { ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; import { @@ -20,6 +23,10 @@ const actorId = '00000000-0000-4000-8000-000000000713'; const correlationId = '00000000-0000-4000-8000-000000000714'; const accountId = '00000000-0000-4000-8000-000000000715'; +function digestSecret(secret: string): string { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + function stable(value: string): StableIdentifierV1 { const parsed = parseStableIdentifierV1(value); assert.equal(parsed.accepted, true); @@ -146,6 +153,52 @@ void test('[IAM-013] service-account creation replays the same idempotent result assert.equal(issued, 1); }); +void test('[IAM-013] a replay envelope that cannot be opened fails closed without issuing again', async () => { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership()]); + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const request = { + name: 'Tamper-resistant worker', + permissions: ['artifact.record.read'], + }; + const first = new ServiceAccountService( + repository, + iam, + { issue: () => ({ secret: 'dbsa_tamper', digest: digestSecret('dbsa_tamper') }) }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + new AesGcmServiceAccountSecretEnvelopeAdapter('a'.repeat(43)), + ); + const created = await first.create( + context({ scopeType: 'organization', organizationId }, 'tampered-replay'), + request, + ); + assert.equal(created.accepted, true); + + let issued = 0; + const replay = new ServiceAccountService( + repository, + iam, + { + issue: () => { + issued += 1; + return { secret: 'dbsa_should-not-issue', digest: digestSecret('dbsa_should-not-issue') }; + }, + }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + new AesGcmServiceAccountSecretEnvelopeAdapter('b'.repeat(43)), + ); + assert.deepEqual( + await replay.create( + context({ scopeType: 'organization', organizationId }, 'tampered-replay'), + request, + ), + { accepted: false, code: 'UNAVAILABLE' }, + ); + assert.equal(issued, 0); +}); + void test('[IAM-013] invalid service-account permissions fail before secret issuance', async () => { let issued = 0; const accountService = service({ From e1ff67f4ee8f9432312c2a8351cf91e6e776bf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:15:22 +0700 Subject: [PATCH 28/43] test(iam): verify registration admission digest boundaries --- .../iam/registration-controller.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index 361c0e1f..ab19e5cc 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -113,3 +113,58 @@ void test('[IAM-001] registration admission rejects before invoking password has assert.equal(emailCalls, 1); assert.equal(serviceCalls, 0); }); + +void test('[IAM-001] registration admission digests are normalized, domain-separated, and content-free', async () => { + const ipDigests: string[] = []; + const emailDigests: string[] = []; + const controller = new RegistrationController( + { + register: async () => ({ accepted: true as const, value: { email: 'user@example.com' } }), + } as unknown as RegistrationService, + { + allow: async (digest: string) => { + ipDigests.push(digest); + return true; + }, + }, + { + allow: async (digest: string) => { + emailDigests.push(digest); + return true; + }, + }, + ); + + await controller.register( + { + email: 'User@example.com', + displayName: 'Name', + password: 'valid password here', + }, + { ip: '203.0.113.10' }, + ); + await controller.register( + { + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }, + { ip: '203.0.113.10' }, + ); + + assert.equal(ipDigests.length, 2); + assert.equal(emailDigests.length, 2); + assert.match(ipDigests[0] ?? '', /^[a-f0-9]{64}$/u); + assert.match(emailDigests[0] ?? '', /^[a-f0-9]{64}$/u); + assert.notEqual(ipDigests[0], emailDigests[0]); + assert.equal(ipDigests[0], ipDigests[1]); + assert.equal(emailDigests[0], emailDigests[1]); + assert.equal( + ipDigests.some((digest) => digest.includes('203.0.113.10')), + false, + ); + assert.equal( + emailDigests.some((digest) => digest.includes('user@example.com')), + false, + ); +}); From 277e699c202f6604e0b3772e52be462c81e1c620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:16:09 +0700 Subject: [PATCH 29/43] test(iam): cover replay envelope bounds --- .../service-account-secret-envelope.adapter.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts index fd5ab3dc..c717c5e6 100644 --- a/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts +++ b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts @@ -24,3 +24,15 @@ void test('[IAM-013] the private-alpha envelope fallback still keeps raw secrets assert.equal(envelope.includes('dbsa-alpha'), false); assert.equal(adapter.open(envelope), 'dbsa-alpha'); }); + +void test('[IAM-013] replay envelopes enforce key, plaintext, and framing bounds', () => { + assert.throws( + () => new AesGcmServiceAccountSecretEnvelopeAdapter('short'), + /IAM_SERVICE_ACCOUNT_ENVELOPE_KEY_INVALID/u, + ); + const adapter = new AesGcmServiceAccountSecretEnvelopeAdapter('c'.repeat(43)); + assert.throws(() => adapter.seal('contains\u0000control'), /IAM_SERVICE_ACCOUNT_SECRET_INVALID/u); + assert.throws(() => adapter.seal('x'.repeat(513)), /IAM_SERVICE_ACCOUNT_SECRET_INVALID/u); + assert.equal(adapter.open('v1.invalid.invalid.invalid'), undefined); + assert.equal(adapter.open('v1.a.a.a.extra'), undefined); +}); From 0bdd655da2c7b1a229e707ff204afac1603effe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:17:38 +0700 Subject: [PATCH 30/43] test(iam): correct registration timestamp assertions --- .../api/test/features/iam/registration-controller.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index ab19e5cc..1ab0d6db 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -83,7 +83,7 @@ void test('[IAM-001] registration admission rejects before invoking password has allow: async (keyDigest: string, issuedAt: string) => { ipCalls += 1; assert.match(keyDigest, /^[a-f0-9]{64}$/u); - assert.match(issuedAt, /^\\d{4}-\\d{2}-\\d{2}T/u); + assert.match(issuedAt, /^\d{4}-\d{2}-\d{2}T/u); return false; }, }, @@ -91,7 +91,7 @@ void test('[IAM-001] registration admission rejects before invoking password has allow: async (keyDigest: string, issuedAt: string) => { emailCalls += 1; assert.match(keyDigest, /^[a-f0-9]{64}$/u); - assert.match(issuedAt, /^\\d{4}-\\d{2}-\\d{2}T/u); + assert.match(issuedAt, /^\d{4}-\d{2}-\d{2}T/u); return true; }, }, From ba41a8bc71aaf9b5c8c54d9bd780a76ffa8d41cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:18:35 +0700 Subject: [PATCH 31/43] test(iam): preserve service-account create replay --- .../iam/service-account-repository.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/services/api/test/features/iam/service-account-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts index ea5633d0..48d92c38 100644 --- a/services/api/test/features/iam/service-account-repository.test.ts +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -158,3 +158,36 @@ void test('[IAM-013] replacement cannot move an account across workspace scope', undefined, ); }); + +void test('[IAM-013, INT-004] lifecycle writes do not change the original create replay outcome', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context( + { scopeType: 'organization', organizationId }, + 'create-replay-preservation', + ); + const created = account({ name: 'Original worker' }); + await repository.saveServiceAccount(organizationContext, created, { + actorId: organizationContext.actorId, + idempotencyKey: organizationContext.idempotencyKey, + requestHash: 'b'.repeat(64), + secretEnvelope: 'v1.encrypted-envelope', + }); + + await repository.replaceServiceAccount( + organizationContext, + Object.freeze({ ...created, name: 'Rotated worker', revision: 2 }), + 1, + ); + const replay = await repository.findServiceAccountByIdempotency( + organizationContext, + { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + organizationContext.idempotencyKey, + ); + assert.equal(replay?.account.name, 'Original worker'); + assert.equal(replay?.account.revision, 1); + assert.equal(replay?.secretEnvelope, 'v1.encrypted-envelope'); +}); From e957e434dd0fcb997c02791c21eb9594a782be7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:21:54 +0700 Subject: [PATCH 32/43] test(iam): keep admission fixtures lint-clean --- .../api/test/features/iam/recovery.service.test.ts | 2 ++ .../test/features/iam/registration-composition.test.ts | 1 + .../test/features/iam/registration-controller.test.ts | 10 +++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index ad8ffa6a..13c9649a 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -142,6 +142,7 @@ void test('[IAM-015] recovery persists the challenge before delivery', async () let persistedDuringDelivery = false; const recovery = service(repository, { deliver: async () => { + await Promise.resolve(); persistedDuringDelivery = repository.challenge('a'.repeat(64))?.status === 'ACTIVE'; }, }); @@ -166,6 +167,7 @@ void test('[IAM-015] recovery revokes a previous challenge before delivering its repository, { deliver: async () => { + await Promise.resolve(); replacementState = { previous: repository.challenge(digests[0])?.status, replacement: repository.challenge(digests[1])?.status, diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts index 4721f1e0..44fc848b 100644 --- a/services/api/test/features/iam/registration-composition.test.ts +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -72,6 +72,7 @@ void test('[IAM-001] registration admission uses separate shared Redis namespace const keys: string[] = []; const counter = { incrementWindow: async (input: { readonly key: string; readonly ttlMs: number }) => { + await Promise.resolve(); keys.push(input.key); return 1; }, diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index 1ab0d6db..e9da2568 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -75,12 +75,14 @@ void test('[IAM-001] registration admission rejects before invoking password has const controller = new RegistrationController( { register: async () => { + await Promise.resolve(); serviceCalls += 1; return { accepted: true as const, value: { email: 'user@example.com' } }; }, } as unknown as RegistrationService, { allow: async (keyDigest: string, issuedAt: string) => { + await Promise.resolve(); ipCalls += 1; assert.match(keyDigest, /^[a-f0-9]{64}$/u); assert.match(issuedAt, /^\d{4}-\d{2}-\d{2}T/u); @@ -89,6 +91,7 @@ void test('[IAM-001] registration admission rejects before invoking password has }, { allow: async (keyDigest: string, issuedAt: string) => { + await Promise.resolve(); emailCalls += 1; assert.match(keyDigest, /^[a-f0-9]{64}$/u); assert.match(issuedAt, /^\d{4}-\d{2}-\d{2}T/u); @@ -119,16 +122,21 @@ void test('[IAM-001] registration admission digests are normalized, domain-separ const emailDigests: string[] = []; const controller = new RegistrationController( { - register: async () => ({ accepted: true as const, value: { email: 'user@example.com' } }), + register: async () => { + await Promise.resolve(); + return { accepted: true as const, value: { email: 'user@example.com' } }; + }, } as unknown as RegistrationService, { allow: async (digest: string) => { + await Promise.resolve(); ipDigests.push(digest); return true; }, }, { allow: async (digest: string) => { + await Promise.resolve(); emailDigests.push(digest); return true; }, From bb276d8f6f7a967b8b7ac79002bf1abb4603e936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:49:38 +0700 Subject: [PATCH 33/43] fix(iam): align replay and entitlement contracts --- docs/operations/secret-rotation.md | 7 +++++++ services/api/openapi/v1.json | 4 ++-- services/api/src/features/bua/api/entitlement-lease.dto.ts | 4 ++-- .../iam/adapter/service-account-secret-envelope.adapter.ts | 4 +++- .../features/iam/application/service-account.service.ts | 2 +- .../api/test/features/iam/service-account.service.test.ts | 5 ++--- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/operations/secret-rotation.md b/docs/operations/secret-rotation.md index 9c48f713..35779b56 100644 --- a/docs/operations/secret-rotation.md +++ b/docs/operations/secret-rotation.md @@ -5,6 +5,13 @@ job envelopes, or client storage. Rotation is performed by an authorized operator through the provider adapter and is audited without recording the plaintext value. +Service-account create-replay envelopes require a stable, managed 32-byte +envelope key in every durable environment. The process-local random fallback +is limited to tests and private alpha instances with no durable service-account +repository; it must not be used by replicas or after a restart. Rotate the +managed key through an overlap plan and invalidate old replay envelopes before +retiring the previous key. + ## General procedure 1. Identify the secret reference, owning organization/workspace, provider, diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 26207edf..04c9077f 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -9799,13 +9799,13 @@ "name": "snapshotRevision", "required": true, "in": "query", - "schema": { "minimum": 1, "type": "number" } + "schema": { "minimum": 1, "maximum": 9007199254740991, "type": "integer" } }, { "name": "securityEpoch", "required": true, "in": "query", - "schema": { "minimum": 1, "type": "number" } + "schema": { "minimum": 1, "maximum": 9007199254740991, "type": "integer" } }, { "name": "X-Correlation-Id", diff --git a/services/api/src/features/bua/api/entitlement-lease.dto.ts b/services/api/src/features/bua/api/entitlement-lease.dto.ts index 553f99b1..c41a165f 100644 --- a/services/api/src/features/bua/api/entitlement-lease.dto.ts +++ b/services/api/src/features/bua/api/entitlement-lease.dto.ts @@ -9,14 +9,14 @@ export class IssueEntitlementLeaseDto { } export class VerifyEntitlementLeaseDto { - @ApiProperty({ minimum: 1 }) + @ApiProperty({ type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) @Type(() => Number) @IsInt() @Min(1) @Max(Number.MAX_SAFE_INTEGER) snapshotRevision!: number; - @ApiProperty({ minimum: 1 }) + @ApiProperty({ type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) @Type(() => Number) @IsInt() @Min(1) diff --git a/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts b/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts index 63ed303f..9a57d1fd 100644 --- a/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts +++ b/services/api/src/features/iam/adapter/service-account-secret-envelope.adapter.ts @@ -39,7 +39,9 @@ export class AesGcmServiceAccountSecretEnvelopeAdapter public open(envelope: string): string | undefined { if (typeof envelope !== 'string') return undefined; - const [version, ivEncoded, tagEncoded, ciphertextEncoded] = envelope.split('.'); + const parts = envelope.split('.'); + if (parts.length !== 4) return undefined; + const [version, ivEncoded, tagEncoded, ciphertextEncoded] = parts; if (version !== 'v1' || !ivEncoded || !tagEncoded || !ciphertextEncoded) return undefined; try { const iv = Buffer.from(ivEncoded, 'base64url'); diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts index bb2adf29..5a233f19 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -219,7 +219,7 @@ function createRequestHash( JSON.stringify({ name, workspaceId: workspaceId ?? null, - permissions: input.permissions, + permissions: [...input.permissions].sort(), secretExpiresAt: expiry ?? null, }), 'utf8', diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts index c7fac27d..9fc0b7f8 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -73,10 +73,9 @@ function service(secretIssuerInput?: { }) { const iam = new InMemoryIamRepositoryAdapter(); iam.seed([membership()]); - const digest = (secret: string) => createHash('sha256').update(secret, 'utf8').digest('hex'); const secrets = [ - { secret: 'dbsa_first', digest: digest('dbsa_first') }, - { secret: 'dbsa_second', digest: digest('dbsa_second') }, + { secret: 'dbsa_first', digest: digestSecret('dbsa_first') }, + { secret: 'dbsa_second', digest: digestSecret('dbsa_second') }, ]; const secretIssuer = secretIssuerInput ?? From 5acded3735543c02de81d3dc586575f59141170c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:50:13 +0700 Subject: [PATCH 34/43] fix(iam): require durable service-account envelope keys --- services/api/src/features/iam/iam.module.ts | 6 ++++++ .../iam/service-account-composition.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index b9f9ada9..c83cb095 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -486,6 +486,12 @@ export class IamModule { (options.serviceAccountDatabase === undefined ? new InMemoryServiceAccountRepositoryAdapter() : new PrismaServiceAccountRepositoryAdapter(options.serviceAccountDatabase)); + if ( + options.serviceAccountDatabase !== undefined && + options.serviceAccountSecretEnvelope === undefined && + options.serviceAccountSecretEnvelopeKey === undefined + ) + throw new Error('IAM_SERVICE_ACCOUNT_ENVELOPE_KEY_REQUIRED'); const serviceAccountSecretEnvelope = options.serviceAccountSecretEnvelope ?? (options.serviceAccountSecretEnvelopeKey diff --git a/services/api/test/features/iam/service-account-composition.test.ts b/services/api/test/features/iam/service-account-composition.test.ts index 5ac715ec..1291bc3a 100644 --- a/services/api/test/features/iam/service-account-composition.test.ts +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -44,3 +44,22 @@ void test('[IAM-013] IAM database composition enables service-account lifecycle if (!provider || !('useValue' in provider)) return; assert.ok(provider.useValue instanceof ServiceAccountService); }); + +void test('[IAM-013] durable service-account storage requires a stable envelope key', () => { + assert.throws( + () => IamModule.register({ iamDatabase: {} as never, serviceAccountDatabase: {} as never }), + /IAM_SERVICE_ACCOUNT_ENVELOPE_KEY_REQUIRED/u, + ); + const key = Buffer.alloc(32, 7).toString('base64url'); + const registered = IamModule.register({ + iamDatabase: {} as never, + serviceAccountDatabase: {} as never, + serviceAccountSecretEnvelopeKey: key, + }); + assert.ok(registered.providers?.some((provider) => + typeof provider === 'object' && + provider !== null && + 'provide' in provider && + provider.provide === SERVICE_ACCOUNT_SERVICE, + )); +}); From a2e79b617926f926f15f6cef9ff24219ceff3331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:54:51 +0700 Subject: [PATCH 35/43] fix(iam): require MFA reenrollment state at auth boundaries --- .../prisma-credential-lookup.adapter.ts | 5 ++-- .../prisma-session-lifecycle.adapter.ts | 6 ++--- .../iam/api/authentication.controller.ts | 4 +-- .../iam/application/authentication.port.ts | 2 +- .../iam/application/authentication.service.ts | 2 ++ .../iam/application/tenant-context.ts | 6 ++--- .../http/session-tenant-context.adapter.ts | 6 ++--- .../features/iam/auth-composition.test.ts | 1 + .../test/features/iam/authentication.test.ts | 27 +++++++++++++++++++ .../iam/prisma-credential-lookup.test.ts | 2 ++ .../iam/prisma-session-lifecycle.test.ts | 1 + .../features/iam/session-lifecycle.test.ts | 1 + services/api/test/http-contract.test.ts | 5 ++++ .../http/session-tenant-context.test.ts | 19 +++++++++++++ 14 files changed, 70 insertions(+), 17 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index 8c8d641c..2039de90 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -182,6 +182,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspace.status !== 'ACTIVE' ) return undefined; + if (typeof user.mfaReenrollmentRequired !== 'boolean') return undefined; return Object.freeze({ principal: Object.freeze({ @@ -190,9 +191,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspaceId, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, - ...(user.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: user.mfaReenrollmentRequired }), + mfaReenrollmentRequired: user.mfaReenrollmentRequired, }), credential: Object.freeze({ algorithm: 'argon2id' as const, diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index cff1d55a..f14fb747 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -62,7 +62,7 @@ export interface SessionUserDatabaseRowV1 { readonly id: string; readonly status: string; readonly securityEpoch: number; - readonly mfaReenrollmentRequired?: boolean; + readonly mfaReenrollmentRequired: boolean; } export interface SessionMembershipDatabaseRowV1 { @@ -562,9 +562,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { workspaceId: workspaceId.value, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, - ...(user.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: user.mfaReenrollmentRequired }), + mfaReenrollmentRequired: user.mfaReenrollmentRequired, }); } } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 5c80276c..14624d59 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -68,7 +68,7 @@ export class AuthenticationController { : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, mfaRequired: context.mfaRequired ?? false, - mfaReenrollmentRequired: context.mfaReenrollmentRequired ?? false, + mfaReenrollmentRequired: context.mfaReenrollmentRequired, }; } @@ -110,7 +110,7 @@ export class AuthenticationController { accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, - mfaReenrollmentRequired: result.value.principal.mfaReenrollmentRequired ?? false, + mfaReenrollmentRequired: result.value.principal.mfaReenrollmentRequired, }; } diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index fe085d3d..63969d55 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -11,7 +11,7 @@ export interface AuthenticatedPrincipalV1 { readonly securityEpoch: number; readonly mfaRequired: boolean; /** Recovery keeps this gate live until a new factor is verified. */ - readonly mfaReenrollmentRequired?: boolean; + readonly mfaReenrollmentRequired: boolean; } export interface CredentialLookupPortV1 { diff --git a/services/api/src/features/iam/application/authentication.service.ts b/services/api/src/features/iam/application/authentication.service.ts index 16fc83c6..4048fc01 100644 --- a/services/api/src/features/iam/application/authentication.service.ts +++ b/services/api/src/features/iam/application/authentication.service.ts @@ -18,6 +18,8 @@ export class AuthenticationService implements AuthenticationUseCaseV1 { try { const found = await this.ports.credentials.findCredential(email.value); if (!found) return { accepted: false, code: 'INVALID_CREDENTIALS' }; + if (typeof found.principal.mfaReenrollmentRequired !== 'boolean') + return { accepted: false, code: 'AUTHENTICATION_UNAVAILABLE' }; const valid = await this.ports.passwordCredentials.verify(found.credential, input.password); if (!valid) return { accepted: false, code: 'INVALID_CREDENTIALS' }; const session = await this.ports.sessions.issue(found.principal, input.clientPlatform); diff --git a/services/api/src/features/iam/application/tenant-context.ts b/services/api/src/features/iam/application/tenant-context.ts index 5e7e0498..1de080e1 100644 --- a/services/api/src/features/iam/application/tenant-context.ts +++ b/services/api/src/features/iam/application/tenant-context.ts @@ -12,7 +12,7 @@ export interface IamTenantContextV1 { readonly idempotencyKey: string; readonly authorizationEpoch: number; readonly mfaRequired?: boolean; - readonly mfaReenrollmentRequired?: boolean; + readonly mfaReenrollmentRequired: boolean; readonly expectedRevision?: number; } @@ -82,9 +82,7 @@ export function createIamTenantContextV1(input: { idempotencyKey: input.idempotencyKey, authorizationEpoch: input.authorizationEpoch, ...(input.mfaRequired === undefined ? {} : { mfaRequired: input.mfaRequired }), - ...(input.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: input.mfaReenrollmentRequired }), + mfaReenrollmentRequired: input.mfaReenrollmentRequired ?? false, ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), }), }); diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 6ca30025..d523cfc5 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -82,6 +82,8 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP } if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + if (typeof principal.mfaReenrollmentRequired !== 'boolean') + throw new RequestTenantContextProblemError('CONTEXT_INVALID'); const context = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', @@ -93,9 +95,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP idempotencyKey: idempotencyKey(input), authorizationEpoch: principal.securityEpoch, mfaRequired: principal.mfaRequired, - ...(principal.mfaReenrollmentRequired === undefined - ? {} - : { mfaReenrollmentRequired: principal.mfaReenrollmentRequired }), + mfaReenrollmentRequired: principal.mfaReenrollmentRequired, }); if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); return context.value; diff --git a/services/api/test/features/iam/auth-composition.test.ts b/services/api/test/features/iam/auth-composition.test.ts index 234e7325..f58cf7ab 100644 --- a/services/api/test/features/iam/auth-composition.test.ts +++ b/services/api/test/features/iam/auth-composition.test.ts @@ -13,6 +13,7 @@ const principal = { workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, }; void test('[IAM-001, IAM-005, IAM-006] configured credentials and sessions issue sign-in responses', async () => { diff --git a/services/api/test/features/iam/authentication.test.ts b/services/api/test/features/iam/authentication.test.ts index a4671fd5..9afd6963 100644 --- a/services/api/test/features/iam/authentication.test.ts +++ b/services/api/test/features/iam/authentication.test.ts @@ -11,6 +11,7 @@ const principal = { workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, }; void test('[IAM-001, IAM-005, IAM-006] authentication normalizes email and delegates opaque session issuance', async () => { @@ -121,3 +122,29 @@ void test('[IAM-006] authentication hides session-provider failures behind a ret { accepted: false, code: 'AUTHENTICATION_UNAVAILABLE' }, ); }); + +void test('[IAM-005] authentication rejects a principal without the MFA re-enrollment state', async () => { + const passwordCredentials = new PasswordCredentialService(new Argon2PasswordHasherAdapter()); + const created = await passwordCredentials.create('correct horse battery staple'); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const service = new AuthenticationService({ + passwordCredentials, + credentials: { + findCredential: () => + Promise.resolve({ + principal: { ...principal, mfaReenrollmentRequired: undefined } as never, + credential: created.value, + }), + }, + sessions: { issue: () => Promise.reject(new Error('must not issue')) }, + }); + assert.deepEqual( + await service.signIn({ + email: 'user@example.com', + password: 'correct horse battery staple', + clientPlatform: 'web', + }), + { accepted: false, code: 'AUTHENTICATION_UNAVAILABLE' }, + ); +}); diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index f15a9423..a2a131e2 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -22,6 +22,7 @@ function database( email: 'user@example.com', status: 'ACTIVE', securityEpoch: 3, + mfaReenrollmentRequired: false, }), }, passwordCredential: { @@ -70,6 +71,7 @@ void test('[IAM-001, IAM-002, IAM-009] credential lookup returns only an active, workspaceId, securityEpoch: 3, mfaRequired: true, + mfaReenrollmentRequired: false, }, credential: { algorithm: 'argon2id', diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 4ead346c..bef8edf9 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -19,6 +19,7 @@ const principal = { workspaceId, securityEpoch: 4, mfaRequired: true, + mfaReenrollmentRequired: false, }; function createDatabase(): { diff --git a/services/api/test/features/iam/session-lifecycle.test.ts b/services/api/test/features/iam/session-lifecycle.test.ts index d6dcccbc..2d1fd998 100644 --- a/services/api/test/features/iam/session-lifecycle.test.ts +++ b/services/api/test/features/iam/session-lifecycle.test.ts @@ -9,6 +9,7 @@ const principal = { workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, }; void test('[IAM-005, IAM-006] session issuer creates opaque bounded sessions', async () => { diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 6bca20aa..1a25bb93 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -302,6 +302,7 @@ void test('sign-in returns a session DTO and maps authentication failures withou workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 2, mfaRequired: true, + mfaReenrollmentRequired: false, }, session: { sessionId: '00000000-0000-4000-8000-000000000010', @@ -491,6 +492,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, }; await withApp( { @@ -639,6 +641,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 3, mfaRequired: false, + mfaReenrollmentRequired: false, }; await withApp( { @@ -764,6 +767,7 @@ void test('audit read outages return retryable service-unavailable problems', as workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, } as const; await withApp( { @@ -801,6 +805,7 @@ void test('audit integrity failures are non-retryable and do not look transient' workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 1, mfaRequired: false, + mfaReenrollmentRequired: false, } as const; await withApp( { diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index b31c6ac9..dc0bc1ae 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -9,6 +9,7 @@ const principal = { workspaceId: '00000000-0000-4000-8000-000000000003', securityEpoch: 7, mfaRequired: false, + mfaReenrollmentRequired: false, }; const correlationId = '00000000-0000-4000-8000-000000000010'; @@ -46,6 +47,7 @@ void test('derives a workspace tenant context from a bearer session and never ac idempotencyKey: 'mutation-001', authorizationEpoch: principal.securityEpoch, mfaRequired: principal.mfaRequired, + mfaReenrollmentRequired: false, }); }); @@ -95,6 +97,23 @@ void test('[IAM-015] carries the live recovery re-enrollment gate into protected assert.equal(context.mfaReenrollmentRequired, true); }); +void test('[IAM-005] rejects a session principal that omits the MFA re-enrollment state', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => + Promise.resolve({ ...principal, mfaReenrollmentRequired: undefined } as never), + }); + await assert.rejects( + adapter.resolve({ + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID'); + return true; + }, + ); +}); + void test('requires an explicit idempotency key for authenticated mutations', async () => { const adapter = new SessionRequestTenantContextAdapter({ findPrincipalByAccessToken: () => Promise.resolve(principal), From 1a90d5b17678b9f81a3efb9c98773d5a3751f27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 12:59:53 +0700 Subject: [PATCH 36/43] fix(iam): bound and snapshot service-account replays --- .../migration.sql | 8 ++ services/api/prisma/schema/iam.prisma | 2 + ...mory-service-account-repository.adapter.ts | 17 ++- ...isma-service-account-repository.adapter.ts | 107 ++++++++++++++++-- .../service-account-repository.port.ts | 7 ++ .../application/service-account.service.ts | 30 ++++- .../prisma-service-account-repository.test.ts | 4 + .../iam/service-account-repository.test.ts | 2 + services/api/test/prisma-foundation.test.mjs | 1 + 9 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 services/api/prisma/migrations/20260804020000_iam_service_account_replay_bounds/migration.sql diff --git a/services/api/prisma/migrations/20260804020000_iam_service_account_replay_bounds/migration.sql b/services/api/prisma/migrations/20260804020000_iam_service_account_replay_bounds/migration.sql new file mode 100644 index 00000000..90c94ad9 --- /dev/null +++ b/services/api/prisma/migrations/20260804020000_iam_service_account_replay_bounds/migration.sql @@ -0,0 +1,8 @@ +-- IAM-013/INT-004: bound replayable create envelopes and preserve create-time state. +ALTER TABLE "iam"."service_accounts" + ADD COLUMN "create_idempotency_expires_at" TIMESTAMPTZ(6), + ADD COLUMN "create_account_snapshot" TEXT; + +-- The composite unique index already covers non-null workspace identities. Keep the +-- organization partial index for NULL workspace rows, but remove the redundant copy. +DROP INDEX IF EXISTS "service_accounts_create_idempotency_workspace_key"; diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 4391be83..79b4579d 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -304,6 +304,8 @@ model ServiceAccountRecord { createIdempotencyKey String? @map("create_idempotency_key") @db.VarChar(200) createRequestHash String? @map("create_request_hash") @db.Char(64) createSecretEnvelope String? @map("create_secret_envelope") @db.Text + createIdempotencyExpiresAt DateTime? @map("create_idempotency_expires_at") @db.Timestamptz(6) + createAccountSnapshot String? @map("create_account_snapshot") @db.Text @@index([organizationId, workspaceId, status], map: "service_accounts_scope_status_idx") @@index([secretExpiresAt, status], map: "service_accounts_expiry_status_idx") diff --git a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts index b10a98ec..b18963eb 100644 --- a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -5,6 +5,7 @@ import { } from '@databreeze/domain/tenant-scope/v1'; import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import { parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; import type { ServiceAccountCreateIdempotencyV1, @@ -59,6 +60,7 @@ function sameCreateRecord( left.actorId === right.actorId && left.idempotencyKey === right.idempotencyKey && left.secretEnvelope === right.secretEnvelope && + left.expiresAt === right.expiresAt && JSON.stringify(left.account) === JSON.stringify(right.account) ); } @@ -141,7 +143,9 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe !/^[a-f0-9]{64}$/u.test(createIdempotency.requestHash) || createIdempotency.secretEnvelope.length === 0 || createIdempotency.secretEnvelope.length > 16_384 || - /\p{Cc}/u.test(createIdempotency.secretEnvelope) + /\p{Cc}/u.test(createIdempotency.secretEnvelope) || + !parseStrictUtcTimestampV1(createIdempotency.expiresAt).accepted || + JSON.stringify(createIdempotency.accountSnapshot) !== JSON.stringify(account) ) throw new Error('IAM_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); const replay = Object.freeze({ @@ -150,6 +154,7 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe idempotencyKey: createIdempotency.idempotencyKey, requestHash: createIdempotency.requestHash, secretEnvelope: createIdempotency.secretEnvelope, + expiresAt: createIdempotency.expiresAt, }); const existingReplay = this.createRecords.get(createKey(replay)); if (existingReplay && !sameCreateRecord(existingReplay, replay)) @@ -163,6 +168,7 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe context: IamTenantContextV1, account: ServiceAccountV1, expectedRevision: number, + clearCreateReplay = false, ): Promise { await Promise.resolve(); if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); @@ -176,6 +182,15 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe ); if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); this.accounts.set(account.id, clone(account)); + if (clearCreateReplay) { + for (const [key, replay] of this.createRecords) { + if (replay.account.id !== account.id) continue; + this.createRecords.set( + key, + Object.freeze({ ...replay, secretEnvelope: '', expiresAt: replay.expiresAt }), + ); + } + } } public async withTransaction( diff --git a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts index c2d30897..a8f16721 100644 --- a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -38,6 +38,8 @@ export interface ServiceAccountDatabaseRowV1 { readonly createIdempotencyKey?: string | null; readonly createRequestHash?: string | null; readonly createSecretEnvelope?: string | null; + readonly createIdempotencyExpiresAt?: Date | null; + readonly createAccountSnapshot?: string | null; } interface ServiceAccountDelegateV1 { @@ -132,6 +134,7 @@ function accountFromRow(row: ServiceAccountDatabaseRowV1): ServiceAccountV1 { function accountData( account: ServiceAccountV1, createIdempotency?: ServiceAccountCreateIdempotencyV1, + clearCreateReplay = false, ): Readonly> { const data: Record = { id: account.id, @@ -154,10 +157,88 @@ function accountData( data['createIdempotencyKey'] = createIdempotency.idempotencyKey; data['createRequestHash'] = createIdempotency.requestHash; data['createSecretEnvelope'] = createIdempotency.secretEnvelope; + data['createIdempotencyExpiresAt'] = new Date(createIdempotency.expiresAt); + data['createAccountSnapshot'] = JSON.stringify(createIdempotency.accountSnapshot); + } else if (clearCreateReplay) { + data['createSecretEnvelope'] = null; + data['createIdempotencyExpiresAt'] = new Date(0); } return data; } +function accountFromSnapshot( + snapshot: unknown, + row: ServiceAccountDatabaseRowV1, +): ServiceAccountV1 { + if (typeof snapshot !== 'string' || snapshot.length === 0 || snapshot.length > 16_384) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + let value: unknown; + try { + value = JSON.parse(snapshot); + } catch { + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + const candidate = value as Record; + const account = createServiceAccountV1({ + id: candidate['id'], + organizationId: candidate['organizationId'], + ...(candidate['workspaceId'] === undefined ? {} : { workspaceId: candidate['workspaceId'] }), + name: candidate['name'], + permissions: candidate['permissions'], + secretDigest: candidate['secretDigest'], + secretIssuedAt: candidate['secretIssuedAt'], + ...(candidate['secretExpiresAt'] === undefined + ? {} + : { secretExpiresAt: candidate['secretExpiresAt'] }), + createdAt: candidate['createdAt'], + }); + if ( + !account.accepted || + account.value.id !== row.id || + account.value.organizationId !== row.organizationId + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + if ( + (candidate['status'] !== 'ACTIVE' && candidate['status'] !== 'REVOKED') || + !Number.isSafeInteger(candidate['secretVersion']) || + (candidate['secretVersion'] as number) < 1 || + !Number.isSafeInteger(candidate['revision']) || + (candidate['revision'] as number) < 1 + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + const secretExpiresAt = candidate['secretExpiresAt'] + ? parseStrictUtcTimestampV1(candidate['secretExpiresAt']) + : undefined; + const lastUsedAt = candidate['lastUsedAt'] + ? parseStrictUtcTimestampV1(candidate['lastUsedAt']) + : undefined; + const revokedAt = candidate['revokedAt'] + ? parseStrictUtcTimestampV1(candidate['revokedAt']) + : undefined; + if ( + (candidate['secretExpiresAt'] !== undefined && !secretExpiresAt?.accepted) || + (candidate['lastUsedAt'] !== undefined && !lastUsedAt?.accepted) || + (candidate['revokedAt'] !== undefined && !revokedAt?.accepted) || + (candidate['status'] === 'ACTIVE' && candidate['revokedAt'] !== undefined) || + (candidate['status'] === 'REVOKED' && !revokedAt?.accepted) + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); + const status = candidate['status'] as 'ACTIVE' | 'REVOKED'; + const secretVersion = candidate['secretVersion'] as number; + const revision = candidate['revision'] as number; + return Object.freeze({ + ...account.value, + status, + secretVersion, + revision, + ...(secretExpiresAt?.accepted ? { secretExpiresAt: secretExpiresAt.value } : {}), + ...(lastUsedAt?.accepted ? { lastUsedAt: lastUsedAt.value } : {}), + ...(revokedAt?.accepted ? { revokedAt: revokedAt.value } : {}), + }); +} + function persistedReplay( row: ServiceAccountDatabaseRowV1, ): ServiceAccountCreateReplayV1 | undefined { @@ -170,6 +251,7 @@ function persistedReplay( const actor = parseStableIdentifierV1(row.createdByActorId); const requestHash = row.createRequestHash; const secretEnvelope = row.createSecretEnvelope; + const expiresAt = timestamp(row.createIdempotencyExpiresAt); if ( !actor.accepted || typeof row.createIdempotencyKey !== 'string' || @@ -177,18 +259,22 @@ function persistedReplay( row.createIdempotencyKey.length > 200 || typeof requestHash !== 'string' || !/^[a-f0-9]{64}$/u.test(requestHash) || - typeof secretEnvelope !== 'string' || - secretEnvelope.length === 0 || - secretEnvelope.length > 16_384 || - /\p{Cc}/u.test(secretEnvelope) + (secretEnvelope !== null && + secretEnvelope !== undefined && + (typeof secretEnvelope !== 'string' || + secretEnvelope.length > 16_384 || + /\p{Cc}/u.test(secretEnvelope))) || + !expiresAt || + typeof row.createAccountSnapshot !== 'string' ) throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); return Object.freeze({ - account: accountFromRow(row), + account: accountFromSnapshot(row.createAccountSnapshot, row), actorId: actor.value, idempotencyKey: row.createIdempotencyKey, requestHash, - secretEnvelope, + secretEnvelope: secretEnvelope ?? '', + expiresAt, }); } @@ -273,7 +359,9 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio !/^[a-f0-9]{64}$/u.test(createIdempotency.requestHash) || createIdempotency.secretEnvelope.length === 0 || createIdempotency.secretEnvelope.length > 16_384 || - /\p{Cc}/u.test(createIdempotency.secretEnvelope)) + /\p{Cc}/u.test(createIdempotency.secretEnvelope) || + !parseStrictUtcTimestampV1(createIdempotency.expiresAt).accepted || + JSON.stringify(createIdempotency.accountSnapshot) !== JSON.stringify(account)) ) throw new Error('IAM_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); const existing = await this.client.serviceAccount.findFirst({ @@ -296,6 +384,7 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio context: IamTenantContextV1, account: ServiceAccountV1, expectedRevision: number, + clearCreateReplay = false, ): Promise { if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); const current = await this.findServiceAccount(context, account.id); @@ -310,7 +399,7 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio workspaceId: account.workspaceId ?? null, revision: expectedRevision, }, - data: accountData(account), + data: accountData(account, undefined, clearCreateReplay), }); if (updated.count !== 1) throw new Error('REVISION_CONFLICT'); } catch (error) { @@ -379,11 +468,13 @@ export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepo context: IamTenantContextV1, account: ServiceAccountV1, expectedRevision: number, + clearCreateReplay = false, ) { return new PrismaServiceAccountTransactionAdapter(this.client).replaceServiceAccount( context, account, expectedRevision, + clearCreateReplay, ); } } diff --git a/services/api/src/features/iam/application/service-account-repository.port.ts b/services/api/src/features/iam/application/service-account-repository.port.ts index fcdcaeac..6c813604 100644 --- a/services/api/src/features/iam/application/service-account-repository.port.ts +++ b/services/api/src/features/iam/application/service-account-repository.port.ts @@ -5,12 +5,17 @@ import type { IamTenantContextV1 } from './tenant-context.js'; export const SERVICE_ACCOUNT_REPOSITORY_PORT = Symbol('SERVICE_ACCOUNT_REPOSITORY_PORT'); +export const SERVICE_ACCOUNT_CREATE_REPLAY_MAX_SECONDS_V1 = 24 * 60 * 60; + export interface ServiceAccountCreateIdempotencyV1 { readonly idempotencyKey: string; readonly requestHash: string; readonly actorId: StableIdentifierV1; /** Opaque AEAD envelope; raw one-time secrets never enter a repository. */ readonly secretEnvelope: string; + /** Create-time account state; lifecycle writes must never replace this snapshot. */ + readonly accountSnapshot: ServiceAccountV1; + readonly expiresAt: string; } export interface ServiceAccountCreateReplayV1 { @@ -19,6 +24,7 @@ export interface ServiceAccountCreateReplayV1 { readonly requestHash: string; readonly actorId: StableIdentifierV1; readonly secretEnvelope: string; + readonly expiresAt: string; } export interface ServiceAccountTransactionPortV1 { @@ -45,6 +51,7 @@ export interface ServiceAccountTransactionPortV1 { context: IamTenantContextV1, account: ServiceAccountV1, expectedRevision: number, + clearCreateReplay?: boolean, ): Promise; } diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts index 5a233f19..66fcb691 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -29,6 +29,7 @@ import type { ServiceAccountCreateReplayV1, ServiceAccountRepositoryPortV1, } from './service-account-repository.port.js'; +import { SERVICE_ACCOUNT_CREATE_REPLAY_MAX_SECONDS_V1 } from './service-account-repository.port.js'; import type { IamTenantContextV1 } from './tenant-context.js'; export const SERVICE_ACCOUNT_SERVICE = Symbol('SERVICE_ACCOUNT_SERVICE'); @@ -234,8 +235,15 @@ function replayCreate( replay: ServiceAccountCreateReplayV1, requestHash: string, envelope: ServiceAccountSecretEnvelopePortV1, + now: string, ): ServiceAccountApplicationResultV1 { if (replay.requestHash !== requestHash) return rejected('CONFLICT'); + if ( + !replay.secretEnvelope || + !parseStrictUtcTimestampV1(replay.expiresAt).accepted || + Date.parse(now) >= Date.parse(replay.expiresAt) + ) + return rejected('UNAVAILABLE'); const secret = envelope.open(replay.secretEnvelope); if (!secret || !safeDigestEqual(digestSecret(secret) ?? '', replay.account.secretDigest)) return rejected('UNAVAILABLE'); @@ -272,6 +280,8 @@ export class ServiceAccountService { const requestHash = createRequestHash(input, workspaceId); if (!requestHash) return rejected('INVALID_INPUT'); if (!this.secretEnvelope) return rejected('UNAVAILABLE'); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); const secretEnvelopePort = this.secretEnvelope; try { return await this.repository.withTransaction(context, async (transaction) => { @@ -280,13 +290,11 @@ export class ServiceAccountService { targetScope, context.idempotencyKey, ); - if (replay) return replayCreate(replay, requestHash, secretEnvelopePort); + if (replay) return replayCreate(replay, requestHash, secretEnvelopePort, now); - let now: string; let id: string; let secret: ServiceAccountSecretIssueV1; try { - now = this.clock().toISOString(); id = this.idGenerator(); secret = this.secretIssuer.issue(); } catch { @@ -318,11 +326,16 @@ export class ServiceAccountService { /\p{Cc}/u.test(secretEnvelope) ) return rejected('UNAVAILABLE'); + const replayExpiresAt = new Date( + Date.parse(now) + SERVICE_ACCOUNT_CREATE_REPLAY_MAX_SECONDS_V1 * 1_000, + ).toISOString(); await transaction.saveServiceAccount(context, candidate.value, { actorId: context.actorId, idempotencyKey: context.idempotencyKey, requestHash, secretEnvelope, + accountSnapshot: candidate.value, + expiresAt: replayExpiresAt, }); return accepted( Object.freeze({ account: safeView(candidate.value), secret: secret.secret }), @@ -337,7 +350,12 @@ export class ServiceAccountService { targetScope, context.idempotencyKey, ); - if (replay) return replayCreate(replay, requestHash, secretEnvelopePort); + if (replay) { + const replayNow = this.now(); + return replayNow + ? replayCreate(replay, requestHash, secretEnvelopePort, replayNow) + : rejected('UNAVAILABLE'); + } } catch { return rejected('UNAVAILABLE'); } @@ -430,7 +448,7 @@ export class ServiceAccountService { }); if (!rotated.accepted) return rejected(mapDomainCode(rotated.code)); try { - await transaction.replaceServiceAccount(context, rotated.value, current.revision); + await transaction.replaceServiceAccount(context, rotated.value, current.revision, true); return accepted( Object.freeze({ account: safeView(rotated.value), secret: secret.secret }), ); @@ -469,7 +487,7 @@ export class ServiceAccountService { const revoked = revokeServiceAccountV1(current, now, expectedRevisionInput); if (!revoked.accepted) return rejected(mapDomainCode(revoked.code)); try { - await transaction.replaceServiceAccount(context, revoked.value, current.revision); + await transaction.replaceServiceAccount(context, revoked.value, current.revision, true); return accepted(safeView(revoked.value)); } catch (error) { return rejected(mapRepositoryError(error)); diff --git a/services/api/test/features/iam/prisma-service-account-repository.test.ts b/services/api/test/features/iam/prisma-service-account-repository.test.ts index 6b103faf..4c520c47 100644 --- a/services/api/test/features/iam/prisma-service-account-repository.test.ts +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -197,6 +197,8 @@ void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scop idempotencyKey: 'create-key', requestHash: 'b'.repeat(64), secretEnvelope: 'v1.encrypted-envelope', + accountSnapshot: value, + expiresAt: '2026-01-02T00:00:00.000Z', }); const replay = await repository.findServiceAccountByIdempotency( organizationContext, @@ -210,6 +212,7 @@ void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scop assert.equal(replay?.account.id, value.id); assert.equal(replay?.requestHash, 'b'.repeat(64)); assert.equal(replay?.secretEnvelope, 'v1.encrypted-envelope'); + assert.equal(replay?.expiresAt, '2026-01-02T00:00:00.000Z'); assert.equal(JSON.stringify(rows[0]).includes('dbsa'), false); await repository.replaceServiceAccount( organizationContext, @@ -226,6 +229,7 @@ void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scop 'create-key', ); assert.equal(replayAfterLifecycleWrite?.secretEnvelope, 'v1.encrypted-envelope'); + assert.equal(replayAfterLifecycleWrite?.account.name, value.name); }); void test('[IAM-013] Prisma service-account adapter uses optimistic revisions and rejects races', async () => { diff --git a/services/api/test/features/iam/service-account-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts index 48d92c38..7ed73215 100644 --- a/services/api/test/features/iam/service-account-repository.test.ts +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -171,6 +171,8 @@ void test('[IAM-013, INT-004] lifecycle writes do not change the original create idempotencyKey: organizationContext.idempotencyKey, requestHash: 'b'.repeat(64), secretEnvelope: 'v1.encrypted-envelope', + accountSnapshot: created, + expiresAt: '2026-01-02T00:00:00.000Z', }); await repository.replaceServiceAccount( diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index ac23cb99..4d93058f 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -137,6 +137,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803080000_aud_seal_attestations', '20260804000000_iam_invitation_active_membership_unique', '20260804010000_iam_service_account_create_idempotency', + '20260804020000_iam_service_account_replay_bounds', 'migration_lock.toml', ]); const migration = await readFile( From 9c50840c1e61363cc5c094b30d127ed514e73f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:01:43 +0700 Subject: [PATCH 37/43] fix(iam): key registration admission and require durable counters --- .../iam-registration-crypto.adapter.ts | 46 ++++++++++++++++++ .../iam/api/registration.controller.ts | 31 +++++++----- .../registration-repository.port.ts | 7 +++ services/api/src/features/iam/iam.module.ts | 47 ++++++++++++++----- .../iam/registration-composition.test.ts | 17 ++++++- .../iam/registration-controller.test.ts | 3 ++ .../features/iam/registration-crypto.test.ts | 28 +++++++++++ 7 files changed, 155 insertions(+), 24 deletions(-) create mode 100644 services/api/src/features/iam/adapter/iam-registration-crypto.adapter.ts create mode 100644 services/api/test/features/iam/registration-crypto.test.ts diff --git a/services/api/src/features/iam/adapter/iam-registration-crypto.adapter.ts b/services/api/src/features/iam/adapter/iam-registration-crypto.adapter.ts new file mode 100644 index 00000000..8de1965e --- /dev/null +++ b/services/api/src/features/iam/adapter/iam-registration-crypto.adapter.ts @@ -0,0 +1,46 @@ +import { createHmac } from 'node:crypto'; + +import type { + RegistrationAdmissionDigestKeyV1, + RegistrationAdmissionDigestPortV1, +} from '../application/registration-repository.port.js'; + +function validKey(key: RegistrationAdmissionDigestKeyV1): boolean { + return ( + (typeof key === 'string' && Buffer.byteLength(key, 'utf8') >= 32) || + (key instanceof Uint8Array && key.byteLength >= 32) + ); +} + +function boundedInput(value: string): string { + if (value.length === 0 || value.length > 4096 || /\p{Cc}/u.test(value)) + throw new Error('IAM_REGISTRATION_ADMISSION_INPUT_INVALID'); + return value.normalize('NFC'); +} + +/** Keyed, versioned admission identifiers; previous keys permit bounded rotation overlap. */ +export class HmacSha256IamRegistrationAdmissionDigestAdapter + implements RegistrationAdmissionDigestPortV1 +{ + private readonly keys: readonly RegistrationAdmissionDigestKeyV1[]; + + public constructor( + currentKey: RegistrationAdmissionDigestKeyV1, + previousKeys: readonly RegistrationAdmissionDigestKeyV1[] = [], + ) { + if (!validKey(currentKey) || previousKeys.some((key) => !validKey(key))) + throw new Error('IAM_REGISTRATION_ADMISSION_KEY_INVALID'); + this.keys = Object.freeze([currentKey, ...previousKeys]); + } + + public digestCandidates(kind: 'ip' | 'email', value: string): readonly string[] { + const normalized = boundedInput(value); + return Object.freeze( + this.keys.map((key) => + createHmac('sha256', key) + .update(`databreeze:iam:registration:${kind}:v1\u0000${normalized}`, 'utf8') + .digest('hex'), + ), + ); + } +} diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts index b2b639e7..128db5c2 100644 --- a/services/api/src/features/iam/api/registration.controller.ts +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; - import { Body, Controller, HttpCode, Inject, Optional, Post, Req } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -17,18 +15,14 @@ import { import { IAM_REGISTRATION_EMAIL_ADMISSION, IAM_REGISTRATION_IP_ADMISSION, + IAM_REGISTRATION_ADMISSION_DIGEST, + type RegistrationAdmissionDigestPortV1, type RegistrationAdmissionPortV1, } from '../application/registration-repository.port.js'; import { RegistrationProblemError } from '../application/registration-problem.error.js'; import { RegistrationDto, RegistrationResponseDto } from './registration.dto.js'; import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; -function admissionDigest(kind: 'ip' | 'email', value: string): string { - return createHash('sha256') - .update(`databreeze:iam:registration:${kind}:v1:${value}`, 'utf8') - .digest('hex'); -} - function requestIp(request: unknown): string { if (typeof request !== 'object' || request === null || !('ip' in request)) return 'unknown'; const candidate = (request as { readonly ip?: unknown }).ip; @@ -51,6 +45,9 @@ export class RegistrationController { @Optional() @Inject(IAM_REGISTRATION_EMAIL_ADMISSION) private readonly emailAdmission?: RegistrationAdmissionPortV1, + @Optional() + @Inject(IAM_REGISTRATION_ADMISSION_DIGEST) + private readonly admissionDigest?: RegistrationAdmissionDigestPortV1, ) {} @Post('register') @@ -74,13 +71,25 @@ export class RegistrationController { // unit-test fallback intentionally skips this gate when no providers are composed; the // production module always supplies bounded adapters. const normalizedEmail = normalizeEmailAddressV1(input.email); - if (normalizedEmail.accepted && this.ipAdmission && this.emailAdmission) { + if (normalizedEmail.accepted && this.ipAdmission && this.emailAdmission && this.admissionDigest) { const issuedAt = new Date().toISOString(); let admitted = false; try { + const allowAny = async ( + admission: RegistrationAdmissionPortV1, + candidates: readonly string[], + ): Promise => { + for (const candidate of candidates) { + if (await admission.allow(candidate, issuedAt)) return true; + } + return false; + }; const [ipAllowed, emailAllowed] = await Promise.all([ - this.ipAdmission.allow(admissionDigest('ip', requestIp(_request)), issuedAt), - this.emailAdmission.allow(admissionDigest('email', normalizedEmail.value), issuedAt), + allowAny(this.ipAdmission, this.admissionDigest.digestCandidates('ip', requestIp(_request))), + allowAny( + this.emailAdmission, + this.admissionDigest.digestCandidates('email', normalizedEmail.value), + ), ]); admitted = ipAllowed && emailAllowed; } catch { diff --git a/services/api/src/features/iam/application/registration-repository.port.ts b/services/api/src/features/iam/application/registration-repository.port.ts index e0ea08c9..96516533 100644 --- a/services/api/src/features/iam/application/registration-repository.port.ts +++ b/services/api/src/features/iam/application/registration-repository.port.ts @@ -4,6 +4,13 @@ import type { PersonalOrganizationBootstrapV1 } from '@databreeze/domain/identit export const IAM_REGISTRATION_REPOSITORY_PORT = Symbol('IAM_REGISTRATION_REPOSITORY_PORT'); export const IAM_REGISTRATION_IP_ADMISSION = Symbol('IAM_REGISTRATION_IP_ADMISSION'); export const IAM_REGISTRATION_EMAIL_ADMISSION = Symbol('IAM_REGISTRATION_EMAIL_ADMISSION'); +export const IAM_REGISTRATION_ADMISSION_DIGEST = Symbol('IAM_REGISTRATION_ADMISSION_DIGEST'); + +export type RegistrationAdmissionDigestKeyV1 = string | Uint8Array; + +export interface RegistrationAdmissionDigestPortV1 { + digestCandidates(kind: 'ip' | 'email', value: string): readonly string[]; +} /** Abuse-control boundary for registration requests; callers pass only a one-way digest. */ export interface RegistrationAdmissionPortV1 { diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index c83cb095..6b3188b7 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -103,6 +103,9 @@ import { import { IAM_REGISTRATION_EMAIL_ADMISSION, IAM_REGISTRATION_IP_ADMISSION, + IAM_REGISTRATION_ADMISSION_DIGEST, + type RegistrationAdmissionDigestPortV1, + type RegistrationAdmissionDigestKeyV1, IAM_REGISTRATION_REPOSITORY_PORT, type RegistrationAdmissionPortV1, type RegistrationRepositoryPortV1, @@ -136,6 +139,7 @@ import { type IamRecoveryDigestKeyV1, } from './adapter/iam-recovery-crypto.adapter.js'; import { InMemoryRecoveryAdmissionAdapter } from './adapter/in-memory-recovery-admission.adapter.js'; +import { HmacSha256IamRegistrationAdmissionDigestAdapter } from './adapter/iam-registration-crypto.adapter.js'; import { RedisRecoveryAdmissionAdapter, type RecoveryAdmissionCounterPortV1, @@ -245,6 +249,9 @@ export interface IamModuleOptions { readonly registrationEmailAdmission?: RegistrationAdmissionPortV1; readonly registrationEmailAdmissionCounter?: RecoveryAdmissionCounterPortV1; readonly registrationEmailAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; + readonly registrationAdmissionDigest?: RegistrationAdmissionDigestPortV1; + readonly registrationAdmissionKey?: RegistrationAdmissionDigestKeyV1; + readonly registrationAdmissionPreviousKeys?: readonly RegistrationAdmissionDigestKeyV1[]; readonly recoveryRepository?: RecoveryRepositoryPortV1; readonly recoveryDatabase?: RecoveryDatabaseClientV1; readonly recoveryService?: RecoveryService; @@ -401,10 +408,18 @@ export class IamModule { ...(options.registrationClock ? { clock: options.registrationClock } : {}), }) : undefined); + const registrationAdmissionDigest = + options.registrationAdmissionDigest ?? + (options.registrationAdmissionKey === undefined + ? undefined + : new HmacSha256IamRegistrationAdmissionDigestAdapter( + options.registrationAdmissionKey, + options.registrationAdmissionPreviousKeys, + )); const registrationIpAdmission = options.registrationIpAdmission ?? (options.registrationIpAdmissionCounter === undefined - ? new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 5, windowSeconds: 15 * 60 }) + ? undefined : new RedisRecoveryAdmissionAdapter(options.registrationIpAdmissionCounter, { keyPrefix: 'databreeze:iam:registration:ip:v1:', maxAttempts: 5, @@ -414,13 +429,20 @@ export class IamModule { const registrationEmailAdmission = options.registrationEmailAdmission ?? (options.registrationEmailAdmissionCounter === undefined - ? new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 5, windowSeconds: 15 * 60 }) + ? undefined : new RedisRecoveryAdmissionAdapter(options.registrationEmailAdmissionCounter, { keyPrefix: 'databreeze:iam:registration:email:v1:', maxAttempts: 5, windowSeconds: 15 * 60, - ...options.registrationEmailAdmissionOptions, - })); + ...options.registrationEmailAdmissionOptions, + })); + if ( + registrationService !== undefined && + (registrationIpAdmission === undefined || + registrationEmailAdmission === undefined || + registrationAdmissionDigest === undefined) + ) + throw new Error('IAM_REGISTRATION_ADMISSION_REQUIRED'); const recoveryRepository = options.recoveryRepository ?? (options.recoveryDatabase === undefined @@ -665,14 +687,15 @@ export class IamModule { }, ] : []), - { - provide: IAM_REGISTRATION_IP_ADMISSION, - useValue: registrationIpAdmission, - }, - { - provide: IAM_REGISTRATION_EMAIL_ADMISSION, - useValue: registrationEmailAdmission, - }, + ...(registrationIpAdmission + ? [{ provide: IAM_REGISTRATION_IP_ADMISSION, useValue: registrationIpAdmission }] + : []), + ...(registrationEmailAdmission + ? [{ provide: IAM_REGISTRATION_EMAIL_ADMISSION, useValue: registrationEmailAdmission }] + : []), + ...(registrationAdmissionDigest + ? [{ provide: IAM_REGISTRATION_ADMISSION_DIGEST, useValue: registrationAdmissionDigest }] + : []), ...(recoveryRepository ? [ { diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts index 44fc848b..0ceaea5f 100644 --- a/services/api/test/features/iam/registration-composition.test.ts +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -14,6 +14,12 @@ import { } from '../../../src/features/iam/application/registration.service.js'; import { IamModule } from '../../../src/features/iam/iam.module.js'; import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; +import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; +import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../../../src/features/iam/adapter/iam-registration-crypto.adapter.js'; + +const registrationAdmissionDigest = new HmacSha256IamRegistrationAdmissionDigestAdapter( + 'r'.repeat(32), +); function provider(module: ReturnType, token: symbol) { return module.providers?.find( @@ -42,7 +48,12 @@ const passwordCredentials = new PasswordCredentialService({ void test('[IAM-001] registration composition exports an explicitly supplied service and controller', () => { const service = {} as RegistrationService; - const registered = IamModule.register({ registrationService: service }); + const registered = IamModule.register({ + registrationService: service, + registrationIpAdmission: new InMemoryRecoveryAdmissionAdapter(), + registrationEmailAdmission: new InMemoryRecoveryAdmissionAdapter(), + registrationAdmissionDigest, + }); const configured = provider(registered, IAM_REGISTRATION_SERVICE); assert.ok(configured && 'useValue' in configured); if (!configured || !('useValue' in configured)) return; @@ -58,6 +69,9 @@ void test('[IAM-001] durable registration requires password credentials before c const configured = IamModule.register({ registrationDatabase: {} as never, passwordCredentials, + registrationIpAdmissionCounter: { incrementWindow: async () => 1 }, + registrationEmailAdmissionCounter: { incrementWindow: async () => 1 }, + registrationAdmissionDigest, }); const repository = provider(configured, IAM_REGISTRATION_REPOSITORY_PORT); const service = provider(configured, IAM_REGISTRATION_SERVICE); @@ -81,6 +95,7 @@ void test('[IAM-001] registration admission uses separate shared Redis namespace registrationService: {} as RegistrationService, registrationIpAdmissionCounter: counter, registrationEmailAdmissionCounter: counter, + registrationAdmissionDigest, }); const ip = provider(configured, IAM_REGISTRATION_IP_ADMISSION); const email = provider(configured, IAM_REGISTRATION_EMAIL_ADMISSION); diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index e9da2568..5b82c50b 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { RegistrationController } from '../../../src/features/iam/api/registration.controller.js'; import { RegistrationProblemError } from '../../../src/features/iam/application/registration-problem.error.js'; import type { RegistrationService } from '../../../src/features/iam/application/registration.service.js'; +import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../../../src/features/iam/adapter/iam-registration-crypto.adapter.js'; void test('[IAM-001] registration controller returns a generic accepted response without bearer material', async () => { const controller = new RegistrationController({ @@ -98,6 +99,7 @@ void test('[IAM-001] registration admission rejects before invoking password has return true; }, }, + new HmacSha256IamRegistrationAdmissionDigestAdapter('r'.repeat(32)), ); await assert.rejects( @@ -141,6 +143,7 @@ void test('[IAM-001] registration admission digests are normalized, domain-separ return true; }, }, + new HmacSha256IamRegistrationAdmissionDigestAdapter('r'.repeat(32)), ); await controller.register( diff --git a/services/api/test/features/iam/registration-crypto.test.ts b/services/api/test/features/iam/registration-crypto.test.ts new file mode 100644 index 00000000..6c5b4c10 --- /dev/null +++ b/services/api/test/features/iam/registration-crypto.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../../../src/features/iam/adapter/iam-registration-crypto.adapter.js'; + +void test('[IAM-001] registration admission digests are keyed and support bounded rotation overlap', () => { + assert.throws( + () => new HmacSha256IamRegistrationAdmissionDigestAdapter('short'), + /IAM_REGISTRATION_ADMISSION_KEY_INVALID/u, + ); + const adapter = new HmacSha256IamRegistrationAdmissionDigestAdapter('r'.repeat(32), [ + 'p'.repeat(32), + ]); + const candidates = adapter.digestCandidates('email', 'User@example.com'); + assert.equal(candidates.length, 2); + assert.notEqual(candidates[0], candidates[1]); + assert.match(candidates[0] ?? '', /^[a-f0-9]{64}$/u); + assert.equal(candidates.some((digest) => digest.includes('user@example.com')), false); + assert.deepEqual(candidates, adapter.digestCandidates('email', 'User@example.com')); +}); + +void test('[IAM-001] registration admission domains remain distinct', () => { + const adapter = new HmacSha256IamRegistrationAdmissionDigestAdapter('r'.repeat(32)); + assert.notEqual( + adapter.digestCandidates('ip', '203.0.113.10')[0], + adapter.digestCandidates('email', '203.0.113.10')[0], + ); +}); From df8bfc847d80c94d99b2a7995f4ce8c6681db0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:03:34 +0700 Subject: [PATCH 38/43] fix(iam): durably block recovery compensation failures --- .../migration.sql | 7 +++ services/api/prisma/schema/iam.prisma | 9 ++++ .../in-memory-recovery-repository.adapter.ts | 11 +++++ .../prisma-recovery-repository.adapter.ts | 27 ++++++++++++ .../application/recovery-repository.port.ts | 2 + .../iam/application/recovery.service.ts | 17 ++++++- .../features/iam/recovery.service.test.ts | 44 ++++++++++++++++++- services/api/test/prisma-foundation.test.mjs | 1 + 8 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 services/api/prisma/migrations/20260804030000_iam_recovery_compensation_failures/migration.sql diff --git a/services/api/prisma/migrations/20260804030000_iam_recovery_compensation_failures/migration.sql b/services/api/prisma/migrations/20260804030000_iam_recovery_compensation_failures/migration.sql new file mode 100644 index 00000000..0cff00db --- /dev/null +++ b/services/api/prisma/migrations/20260804030000_iam_recovery_compensation_failures/migration.sql @@ -0,0 +1,7 @@ +-- IAM-015: a delivery failure whose compensating revoke cannot be persisted is +-- durably blocked from token completion until an operator resolves the marker. +CREATE TABLE "iam"."recovery_compensation_failures" ( + "token_digest" CHAR(64) NOT NULL, + "recorded_at" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "recovery_compensation_failures_pkey" PRIMARY KEY ("token_digest") +); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 79b4579d..ab495e81 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -139,6 +139,15 @@ model RecoveryChallengeRecord { @@schema("iam") } +/// IAM-015: delivery-compensation failures remain blocked across restarts until reviewed. +model RecoveryCompensationFailureRecord { + tokenDigest String @id @map("token_digest") @db.Char(64) + recordedAt DateTime @map("recorded_at") @db.Timestamptz(6) + + @@map("recovery_compensation_failures") + @@schema("iam") +} + model SessionRecord { id String @id @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts index 9f8e978b..47f18c42 100644 --- a/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts @@ -24,6 +24,7 @@ function cloneChallenge(value: RecoveryChallengeV1): RecoveryChallengeV1 { export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPortV1 { private accounts = new Map(); private challenges = new Map(); + private compensationFailures = new Map(); private transactionTail: Promise = Promise.resolve(); public seed(input: { @@ -69,6 +70,7 @@ export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPort ]), ); const beforeChallenges = new Map(this.challenges); + const beforeCompensationFailures = new Map(this.compensationFailures); const transaction: RecoveryTransactionPortV1 = { findUserIdByEmail: async (email) => { await Promise.resolve(); @@ -85,6 +87,14 @@ export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPort (challenge) => challenge.userId === userId && challenge.status === 'ACTIVE', ); }, + isChallengeCompensationBlocked: async (tokenDigest) => { + await Promise.resolve(); + return this.compensationFailures.has(tokenDigest); + }, + recordChallengeCompensationFailure: async (tokenDigest, recordedAt) => { + await Promise.resolve(); + this.compensationFailures.set(tokenDigest, recordedAt); + }, saveChallenge: async (challenge) => { await Promise.resolve(); const existing = this.challenges.get(challenge.tokenDigest); @@ -114,6 +124,7 @@ export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPort } catch (error) { this.accounts = beforeAccounts; this.challenges = beforeChallenges; + this.compensationFailures = beforeCompensationFailures; throw error; } finally { release(); diff --git a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts index a4151d2f..b847586f 100644 --- a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts @@ -41,6 +41,11 @@ export interface RecoverySessionDatabaseRowV1 { readonly familyId: string; } +interface RecoveryCompensationFailureDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; + create(input: { readonly data: Readonly> }): Promise; +} + interface UniqueDelegateV1 { findUnique(input: { readonly where: Readonly> }): Promise; } @@ -101,6 +106,7 @@ export interface RecoveryDatabaseClientV1 { readonly refreshTokenRecord: UpdateManyDelegateV1; readonly accessTokenRecord: UpdateManyDelegateV1; readonly mfaFactor: UpdateManyDelegateV1; + readonly recoveryCompensationFailure: RecoveryCompensationFailureDelegateV1; $transaction( work: (transaction: RecoveryDatabaseClientV1) => Promise, ): Promise; @@ -200,6 +206,27 @@ class PrismaRecoveryTransactionAdapter implements RecoveryTransactionPortV1 { return row ? challengeFromRow(row) : undefined; } + public async isChallengeCompensationBlocked(tokenDigest: string): Promise { + const row = await this.client.recoveryCompensationFailure.findUnique({ + where: { tokenDigest }, + }); + return row !== null; + } + + public async recordChallengeCompensationFailure( + tokenDigest: string, + recordedAt: string, + ): Promise { + try { + await this.client.recoveryCompensationFailure.create({ + data: { tokenDigest, recordedAt: new Date(recordedAt) }, + }); + } catch (error) { + if (isConflict(error)) return; + throw error; + } + } + public async saveChallenge(challenge: RecoveryChallengeV1): Promise { if (!stable(challenge.id) || !stable(challenge.userId)) throw new Error('IAM_RECOVERY_INVALID_IDENTIFIER'); diff --git a/services/api/src/features/iam/application/recovery-repository.port.ts b/services/api/src/features/iam/application/recovery-repository.port.ts index 72616f2f..f84c4a17 100644 --- a/services/api/src/features/iam/application/recovery-repository.port.ts +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -19,6 +19,8 @@ export interface RecoveryTransactionPortV1 { findUserIdByEmail(email: string): Promise; findChallengeByTokenDigest(tokenDigest: string): Promise; findActiveChallengeForUser(userId: StableIdentifierV1): Promise; + isChallengeCompensationBlocked(tokenDigest: string): Promise; + recordChallengeCompensationFailure(tokenDigest: string, recordedAt: string): Promise; saveChallenge(challenge: RecoveryChallengeV1): Promise; completeRecovery(input: RecoveryCompletionInputV1): Promise; } diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index 2463aace..f7f67e44 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -83,6 +83,8 @@ function inputRejected(code: RecoveryFailureCodeV1): { /** Public recovery flow: generic requests, hashed single-use tokens, and atomic credential reset. */ export class RecoveryService { + private readonly compensationBlocked = new Set(); + public constructor(private readonly ports: RecoveryServicePortsV1) {} public async request(emailInput: unknown): Promise { @@ -169,7 +171,17 @@ export class RecoveryService { await transaction.saveChallenge(revoked.value); }); } catch { - // The challenge remains unusable only if the compensating revocation also fails. + try { + await this.ports.repository.withTransaction(async (transaction) => { + await transaction.recordChallengeCompensationFailure( + issued.tokenDigest, + timestamp(this.ports.clock) ?? issuedAt, + ); + }); + } catch { + // Keep a process-local fail-closed marker while the durable marker is unavailable. + this.compensationBlocked.add(issued.tokenDigest); + } } return unavailable(); } @@ -201,11 +213,14 @@ export class RecoveryService { } } + if (this.compensationBlocked.has(digest)) return inputRejected('INVALID_TOKEN'); + // Resolve and validate the challenge before doing expensive password work. This // keeps unknown, expired, and already-consumed tokens cheap and indistinguishable. let candidate: ReturnType | undefined; try { candidate = await this.ports.repository.withTransaction(async (transaction) => { + if (await transaction.isChallengeCompensationBlocked(digest)) return undefined; const challenge = await transaction.findChallengeByTokenDigest(digest); return challenge ? consumeRecoveryChallengeV1(challenge, now) : undefined; }); diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 13c9649a..285dcd6b 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -5,7 +5,10 @@ import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/ada import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; import { RecoveryService } from '../../../src/features/iam/application/recovery.service.js'; -import type { RecoveryDeliveryPortV1 } from '../../../src/features/iam/application/recovery-repository.port.js'; +import type { + RecoveryDeliveryPortV1, + RecoveryRepositoryPortV1, +} from '../../../src/features/iam/application/recovery-repository.port.js'; const userId = '00000000-0000-4000-8000-000000000001'; const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; @@ -256,6 +259,45 @@ void test('[IAM-015] recovery delivery failures revoke the new challenge', async assert.equal(repository.challenge('a'.repeat(64))?.status, 'REVOKED'); }); +void test('[IAM-015] recovery blocks completion when compensating revocation cannot be persisted', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const failingCompensationRepository: RecoveryRepositoryPortV1 = { + withTransaction: (work) => + repository.withTransaction((transaction) => + work({ + ...transaction, + saveChallenge: async (challenge) => { + if (challenge.status === 'REVOKED') throw new Error('compensation unavailable'); + await transaction.saveChallenge(challenge); + }, + }), + ), + }; + const recovery = new RecoveryService({ + repository: failingCompensationRepository, + passwordCredentials: credentials(), + digest: { digestToken: () => 'a'.repeat(64), digestEmail: () => 'b'.repeat(64) }, + delivery: { + deliver: async () => { + throw new Error('provider down'); + }, + }, + ids: { next: () => '00000000-0000-4000-8000-000000000002' }, + tokens: { next: () => token }, + clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: false, + code: 'RECOVERY_UNAVAILABLE', + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); + assert.deepEqual(await recovery.complete(token, 'new correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); +}); + void test('[IAM-015] recovery persistence conflicts preserve an existing active challenge', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 4d93058f..1a3f53b3 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -138,6 +138,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260804000000_iam_invitation_active_membership_unique', '20260804010000_iam_service_account_create_idempotency', '20260804020000_iam_service_account_replay_bounds', + '20260804030000_iam_recovery_compensation_failures', 'migration_lock.toml', ]); const migration = await readFile( From 888aa7d2196836511fb93ed1663cdb060020419d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:04:38 +0700 Subject: [PATCH 39/43] test(iam): prove replay invalidation across lifecycle --- .../iam/service-account.service.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts index 9fc0b7f8..f156e5e0 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -306,3 +306,58 @@ void test('[IAM-013] credential authentication is digest-bound, updates last use { accepted: false, code: 'INVALID_CREDENTIALS' }, ); }); + +void test('[IAM-013, INT-004] reordered permissions replay the same create outcome', async () => { + const accountService = service(); + const organizationContext = context( + { scopeType: 'organization', organizationId }, + 'permission-order-replay', + ); + const first = await accountService.create(organizationContext, { + name: 'Ordered worker', + permissions: ['artifact.record.read', 'project.record.read'], + }); + const replay = await accountService.create(organizationContext, { + name: 'Ordered worker', + permissions: ['project.record.read', 'artifact.record.read'], + }); + assert.deepEqual(replay, first); +}); + +void test('[IAM-013, INT-004] rotation and revocation clear replay envelopes without reissuing', async () => { + const accountService = service(); + const organizationContext = context( + { scopeType: 'organization', organizationId }, + 'lifecycle-replay', + ); + const created = await accountService.create(organizationContext, { + name: 'Lifecycle replay worker', + permissions: ['artifact.record.read'], + }); + assert.equal(created.accepted, true); + const rotated = await accountService.rotate(organizationContext, accountId, 1); + assert.equal(rotated.accepted, true); + assert.deepEqual( + await accountService.create(organizationContext, { + name: 'Lifecycle replay worker', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'UNAVAILABLE' }, + ); + + const revokeService = service(); + const revokeContext = context({ scopeType: 'organization', organizationId }, 'revoke-replay'); + const revokeCreated = await revokeService.create(revokeContext, { + name: 'Revoked replay worker', + permissions: ['artifact.record.read'], + }); + assert.equal(revokeCreated.accepted, true); + assert.equal((await revokeService.revoke(revokeContext, accountId, 1)).accepted, true); + assert.deepEqual( + await revokeService.create(revokeContext, { + name: 'Revoked replay worker', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'UNAVAILABLE' }, + ); +}); From 5e2307be7a7e83beab206882bef11336855fd685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:07:43 +0700 Subject: [PATCH 40/43] fix(iam): revoke and block failed invitation delivery --- packages/domain/src/invitation/v1.ts | 19 +++++++++++ .../migration.sql | 6 ++++ services/api/prisma/schema/iam.prisma | 9 +++++ ...emory-iam-invitation-repository.adapter.ts | 11 ++++++ ...risma-iam-invitation-repository.adapter.ts | 32 +++++++++++++++++ .../application/invitation-repository.port.ts | 6 ++++ .../iam/application/invitation.service.ts | 34 +++++++++++++++++-- .../features/iam/invitation-service.test.ts | 29 ++++++++++++++-- services/api/test/prisma-foundation.test.mjs | 1 + 9 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 services/api/prisma/migrations/20260804040000_iam_invitation_delivery_failures/migration.sql diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts index 0394b205..93b6c89b 100644 --- a/packages/domain/src/invitation/v1.ts +++ b/packages/domain/src/invitation/v1.ts @@ -152,3 +152,22 @@ export function consumeInvitationTokenV1( }), ); } + +/** Revoke a token after a delivery acknowledgement failure; no bearer is reusable. */ +export function revokeInvitationTokenV1( + token: InvitationTokenV1, + at: unknown, +): InvitationTokenResultV1 { + const timestampValue = timestamp(at); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (token.status !== 'ACTIVE') return rejected('INVALID_STATE'); + if (Date.parse(timestampValue) < Date.parse(token.issuedAt)) + return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...token, + status: 'REVOKED' as const, + revision: token.revision + 1, + }), + ); +} diff --git a/services/api/prisma/migrations/20260804040000_iam_invitation_delivery_failures/migration.sql b/services/api/prisma/migrations/20260804040000_iam_invitation_delivery_failures/migration.sql new file mode 100644 index 00000000..52d7039a --- /dev/null +++ b/services/api/prisma/migrations/20260804040000_iam_invitation_delivery_failures/migration.sql @@ -0,0 +1,6 @@ +-- IAM-010: retain a durable block when an invitation delivery acknowledgement fails. +CREATE TABLE "iam"."invitation_delivery_failures" ( + "token_digest" CHAR(64) NOT NULL, + "recorded_at" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "invitation_delivery_failures_pkey" PRIMARY KEY ("token_digest") +); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index ab495e81..555f0919 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -118,6 +118,15 @@ model InvitationTokenRecord { @@schema("iam") } +/// IAM-010: delivery failures block bearer acceptance across restarts. +model InvitationDeliveryFailureRecord { + tokenDigest String @id @map("token_digest") @db.Char(64) + recordedAt DateTime @map("recorded_at") @db.Timestamptz(6) + + @@map("invitation_delivery_failures") + @@schema("iam") +} + /// IAM-015: recovery bearers are short-lived, email-bound, and hashed at rest. model RecoveryChallengeRecord { id String @id @db.Uuid diff --git a/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts index 545d4242..32a17da7 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts @@ -43,6 +43,7 @@ function sameInvitationIdentity(left: InvitationTokenV1, right: InvitationTokenV export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepositoryPortV1 { private memberships: IamMembershipRecordV1[]; private invitations: InvitationTokenV1[] = []; + private deliveryFailures = new Set(); private transactionTail: Promise = Promise.resolve(); public constructor(memberships: readonly IamMembershipRecordV1[] = []) { @@ -65,18 +66,28 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo await prior; const membershipsBefore = this.memberships.map(cloneMembership); const invitationsBefore = this.invitations.map(cloneInvitation); + const deliveryFailuresBefore = new Set(this.deliveryFailures); try { return await work({ findMembershipForPrincipal: this.findMembershipForPrincipal.bind(this), findMembershipById: this.findMembershipById.bind(this), findInvitationByDigest: this.findInvitationByDigest.bind(this), findActiveInvitationForMembership: this.findActiveInvitationForMembership.bind(this), + isDeliveryBlocked: async (_context, tokenDigest) => { + await Promise.resolve(); + return this.deliveryFailures.has(tokenDigest); + }, + recordDeliveryFailure: async (_context, tokenDigest) => { + await Promise.resolve(); + this.deliveryFailures.add(tokenDigest); + }, saveInvitation: this.saveInvitation.bind(this), saveMembership: this.saveMembership.bind(this), }); } catch (error) { this.memberships = membershipsBefore; this.invitations = invitationsBefore; + this.deliveryFailures = deliveryFailuresBefore; throw error; } finally { release(); diff --git a/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts index 80039d33..a491aef4 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts @@ -84,9 +84,15 @@ interface IamInvitationTokenDelegateV1 { }): Promise<{ readonly count: number }>; } +interface IamInvitationDeliveryFailureDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; + create(input: { readonly data: Readonly> }): Promise; +} + interface IamInvitationTransactionDatabaseClientV1 { readonly membershipIdentity: IamInvitationMembershipDelegateV1; readonly invitationTokenRecord: IamInvitationTokenDelegateV1; + readonly invitationDeliveryFailure?: IamInvitationDeliveryFailureDelegateV1; } export interface IamInvitationDatabaseClientV1 extends IamInvitationTransactionDatabaseClientV1 { @@ -269,6 +275,32 @@ class PrismaIamInvitationTransactionAdapter implements IamInvitationTransactionP return tenantScopeContainsV1(context.tenantScope, invitation.scope) ? invitation : undefined; } + public async isDeliveryBlocked( + _context: IamTenantContextV1, + tokenDigest: string, + ): Promise { + const delegate = this.client.invitationDeliveryFailure; + if (!delegate) throw new Error('IAM_INVITATION_DELIVERY_MARKER_UNAVAILABLE'); + return (await delegate.findUnique({ where: { tokenDigest } })) !== null; + } + + public async recordDeliveryFailure( + _context: IamTenantContextV1, + tokenDigest: string, + recordedAt: string, + ): Promise { + const delegate = this.client.invitationDeliveryFailure; + if (!delegate) throw new Error('IAM_INVITATION_DELIVERY_MARKER_UNAVAILABLE'); + try { + await delegate.create({ + data: { tokenDigest, recordedAt: new Date(recordedAt) }, + }); + } catch (error) { + if (uniqueConstraint(error)) return; + throw error; + } + } + public async saveInvitation( context: IamTenantContextV1, invitation: InvitationTokenV1, diff --git a/services/api/src/features/iam/application/invitation-repository.port.ts b/services/api/src/features/iam/application/invitation-repository.port.ts index 15ff1ff2..53589779 100644 --- a/services/api/src/features/iam/application/invitation-repository.port.ts +++ b/services/api/src/features/iam/application/invitation-repository.port.ts @@ -23,6 +23,12 @@ export interface IamInvitationTransactionPortV1 { context: IamTenantContextV1, membershipId: StableIdentifierV1, ): Promise; + isDeliveryBlocked?(context: IamTenantContextV1, tokenDigest: string): Promise; + recordDeliveryFailure?( + context: IamTenantContextV1, + tokenDigest: string, + recordedAt: string, + ): Promise; saveInvitation(context: IamTenantContextV1, invitation: InvitationTokenV1): Promise; saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1): Promise; } diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts index c5ff4318..2696317c 100644 --- a/services/api/src/features/iam/application/invitation.service.ts +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -3,6 +3,7 @@ import { timingSafeEqual } from 'node:crypto'; import { consumeInvitationTokenV1, createInvitationTokenV1, + revokeInvitationTokenV1, type InvitationTokenV1, } from '@databreeze/domain/invitation/v1'; import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; @@ -115,6 +116,8 @@ function applicationError(error: unknown): IamInvitationApplicationCodeV1 { /** IAM-010: issue and redeem one-time invitations without exposing bearer material. */ export class IamInvitationService { + private readonly deliveryBlocked = new Set(); + public constructor( private readonly repository: IamInvitationRepositoryPortV1, private readonly principalEmails: IamPrincipalEmailLookupPortV1, @@ -227,8 +230,32 @@ export class IamInvitationService { expiresAt: pendingDelivery.token.expiresAt, }); } catch { - // Keep the committed token active: a provider may have accepted the message - // before reporting an error, and a later resend can use the same bearer. + try { + await this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.findInvitationByDigest( + context, + pendingDelivery.token.tokenDigest, + ); + if (!current || current.status !== 'ACTIVE') return; + const revoked = revokeInvitationTokenV1(current, this.now() ?? issuedAt); + if (!revoked.accepted) throw new Error('IAM_INVITATION_REVOCATION_INVALID'); + await transaction.saveInvitation(context, revoked.value); + }); + } catch { + try { + await this.repository.withTransaction(context, async (transaction) => { + if (!transaction.recordDeliveryFailure) + throw new Error('IAM_INVITATION_MARKER_UNAVAILABLE'); + await transaction.recordDeliveryFailure( + context, + pendingDelivery.token.tokenDigest, + this.now() ?? issuedAt, + ); + }); + } catch { + this.deliveryBlocked.add(pendingDelivery.token.tokenDigest); + } + } return rejected('DELIVERY_UNAVAILABLE'); } return accepted({ @@ -252,7 +279,10 @@ export class IamInvitationService { return rejected('UNAVAILABLE'); } try { + if (this.deliveryBlocked.has(digest)) return rejected('INVALID_TOKEN'); return await this.repository.withTransaction(context, async (transaction) => { + if (transaction.isDeliveryBlocked && (await transaction.isDeliveryBlocked(context, digest))) + return rejected('INVALID_TOKEN'); const token = await transaction.findInvitationByDigest(context, digest); if (!token) return rejected('INVALID_TOKEN'); if (token.principalId !== context.actorId) return rejected('INVALID_TOKEN'); diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts index d7837868..dbc8ab8f 100644 --- a/services/api/test/features/iam/invitation-service.test.ts +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -185,7 +185,10 @@ class Delivery implements IamInvitationDeliveryPortV1 { } } -function service(repository: Repository, delivery = new Delivery()) { +function service( + repository: Repository, + delivery: IamInvitationDeliveryPortV1 = new Delivery(), +) { const idsQueue: string[] = [ids.invitation, ids.invitation]; const idGenerator: IamInvitationIdGeneratorV1 = () => { const next = idsQueue.shift(); @@ -203,7 +206,7 @@ function service(repository: Repository, delivery = new Delivery()) { delivery, () => now, ), - delivery, + delivery: delivery as Delivery, }; } @@ -244,6 +247,28 @@ void test('[IAM-010] invitation persistence commits before raw-token delivery', assert.equal(persistedDuringDelivery, true); }); +void test('[IAM-010] delivery acknowledgement failures revoke and block the invitation bearer', async () => { + const repository = new Repository(); + const composed = service(repository, { + deliver: async () => { + await Promise.resolve(); + throw new Error('provider acknowledgement unavailable'); + }, + }); + assert.deepEqual( + await composed.service.issue(context(ids.owner, 'invitation-delivery-failure'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }), + { accepted: false, code: 'DELIVERY_UNAVAILABLE' }, + ); + assert.equal(repository.invitations[0]?.status, 'REVOKED'); + assert.deepEqual( + await composed.service.accept(context(ids.invitee, 'invitation-delivery-accept'), RAW_TOKEN), + { accepted: false, code: 'INVALID_TOKEN' }, + ); +}); + void test('[IAM-010] email mismatch and non-owner issuance are denied without persistence', async () => { const repository = new Repository(); const composed = service(repository); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 1a3f53b3..5a963714 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -139,6 +139,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260804010000_iam_service_account_create_idempotency', '20260804020000_iam_service_account_replay_bounds', '20260804030000_iam_recovery_compensation_failures', + '20260804040000_iam_invitation_delivery_failures', 'migration_lock.toml', ]); const migration = await readFile( From 70f2b27ef0b72824bbfcaea7943a48682c4577de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:14:47 +0700 Subject: [PATCH 41/43] test(iam): tighten replay framing and migration evidence --- .../prisma-service-account-repository.test.ts | 6 ++- .../features/iam/registration-http.test.ts | 9 ++++ ...ce-account-secret-envelope.adapter.test.ts | 3 +- services/api/test/prisma-foundation.test.mjs | 44 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/services/api/test/features/iam/prisma-service-account-repository.test.ts b/services/api/test/features/iam/prisma-service-account-repository.test.ts index 4c520c47..7a824776 100644 --- a/services/api/test/features/iam/prisma-service-account-repository.test.ts +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -213,7 +213,11 @@ void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scop assert.equal(replay?.requestHash, 'b'.repeat(64)); assert.equal(replay?.secretEnvelope, 'v1.encrypted-envelope'); assert.equal(replay?.expiresAt, '2026-01-02T00:00:00.000Z'); - assert.equal(JSON.stringify(rows[0]).includes('dbsa'), false); + assert.equal(rows[0]?.['createSecretEnvelope'], 'v1.encrypted-envelope'); + assert.equal( + String(rows[0]?.['createAccountSnapshot']).includes('one-time-secret'), + false, + ); await repository.replaceServiceAccount( organizationContext, Object.freeze({ ...value, name: 'Changed after create', revision: 2 }), diff --git a/services/api/test/features/iam/registration-http.test.ts b/services/api/test/features/iam/registration-http.test.ts index 0f3d3f63..7c83052f 100644 --- a/services/api/test/features/iam/registration-http.test.ts +++ b/services/api/test/features/iam/registration-http.test.ts @@ -2,9 +2,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createApiApplication } from '../../../src/bootstrap.js'; +import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../../../src/features/iam/adapter/iam-registration-crypto.adapter.js'; +import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; import { InMemoryRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-registration-repository.adapter.js'; import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; +const registrationAdmissionDigest = new HmacSha256IamRegistrationAdmissionDigestAdapter( + 'r'.repeat(32), +); + function credentials() { return new PasswordCredentialService({ hash: async () => { @@ -26,6 +32,9 @@ void test('[IAM-001, IAM-009, IAM-016] registration HTTP creates a personal hier const { app } = await createApiApplication({ registrationRepository: new InMemoryRegistrationRepositoryAdapter(), passwordCredentials: credentials(), + registrationIpAdmission: new InMemoryRecoveryAdmissionAdapter(), + registrationEmailAdmission: new InMemoryRecoveryAdmissionAdapter(), + registrationAdmissionDigest, }); try { const first = await app.inject({ diff --git a/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts index c717c5e6..615992ee 100644 --- a/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts +++ b/services/api/test/features/iam/service-account-secret-envelope.adapter.test.ts @@ -31,8 +31,9 @@ void test('[IAM-013] replay envelopes enforce key, plaintext, and framing bounds /IAM_SERVICE_ACCOUNT_ENVELOPE_KEY_INVALID/u, ); const adapter = new AesGcmServiceAccountSecretEnvelopeAdapter('c'.repeat(43)); + const valid = adapter.seal('bounded-secret'); assert.throws(() => adapter.seal('contains\u0000control'), /IAM_SERVICE_ACCOUNT_SECRET_INVALID/u); assert.throws(() => adapter.seal('x'.repeat(513)), /IAM_SERVICE_ACCOUNT_SECRET_INVALID/u); assert.equal(adapter.open('v1.invalid.invalid.invalid'), undefined); - assert.equal(adapter.open('v1.a.a.a.extra'), undefined); + assert.equal(adapter.open(`${valid}.extra`), undefined); }); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 5a963714..fc9a0fab 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -88,6 +88,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."service_accounts"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."recovery_compensation_failures"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_delivery_failures"/); assert.match(diff.stdout, /CREATE TABLE "bua"\."entitlement_leases"/); assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_seal_attestations"/); @@ -577,6 +579,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo activeInvitationMigration, /CREATE UNIQUE INDEX "invitation_tokens_active_membership_key"/u, ); + const invitationDeliveryFailureMigration = await readFile( + path.join( + migrationsDirectory, + '20260804040000_iam_invitation_delivery_failures', + 'migration.sql', + ), + 'utf8', + ); + assert.match( + invitationDeliveryFailureMigration, + /CREATE TABLE "iam"\."invitation_delivery_failures"/u, + ); const serviceAccountIdempotencyMigration = await readFile( path.join( migrationsDirectory, @@ -596,4 +610,34 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const replayBoundsMigration = await readFile( + path.join( + migrationsDirectory, + '20260804020000_iam_service_account_replay_bounds', + 'migration.sql', + ), + 'utf8', + ); + for (const statement of [ + 'ADD COLUMN "create_idempotency_expires_at" TIMESTAMPTZ(6)', + 'ADD COLUMN "create_account_snapshot" TEXT', + 'DROP INDEX IF EXISTS "service_accounts_create_idempotency_workspace_key"', + ]) { + assert.match( + replayBoundsMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } + const compensationFailureMigration = await readFile( + path.join( + migrationsDirectory, + '20260804030000_iam_recovery_compensation_failures', + 'migration.sql', + ), + 'utf8', + ); + assert.match( + compensationFailureMigration, + /CREATE TABLE "iam"\."recovery_compensation_failures"/u, + ); }); From 9c9af98310b95219742b5595c98d5cb1a5809d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:15:35 +0700 Subject: [PATCH 42/43] style(iam): normalize promotion formatting --- packages/domain/src/invitation/v1.ts | 3 +-- .../features/iam/api/registration.controller.ts | 12 ++++++++++-- services/api/src/features/iam/iam.module.ts | 4 ++-- .../test/features/iam/invitation-service.test.ts | 5 +---- .../iam/prisma-service-account-repository.test.ts | 5 +---- .../test/features/iam/registration-crypto.test.ts | 5 ++++- .../iam/service-account-composition.test.ts | 15 +++++++++------ 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts index 93b6c89b..0250e832 100644 --- a/packages/domain/src/invitation/v1.ts +++ b/packages/domain/src/invitation/v1.ts @@ -161,8 +161,7 @@ export function revokeInvitationTokenV1( const timestampValue = timestamp(at); if (!timestampValue) return rejected('INVALID_TIMESTAMP'); if (token.status !== 'ACTIVE') return rejected('INVALID_STATE'); - if (Date.parse(timestampValue) < Date.parse(token.issuedAt)) - return rejected('INVALID_TIMESTAMP'); + if (Date.parse(timestampValue) < Date.parse(token.issuedAt)) return rejected('INVALID_TIMESTAMP'); return accepted( Object.freeze({ ...token, diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts index 128db5c2..856aa775 100644 --- a/services/api/src/features/iam/api/registration.controller.ts +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -71,7 +71,12 @@ export class RegistrationController { // unit-test fallback intentionally skips this gate when no providers are composed; the // production module always supplies bounded adapters. const normalizedEmail = normalizeEmailAddressV1(input.email); - if (normalizedEmail.accepted && this.ipAdmission && this.emailAdmission && this.admissionDigest) { + if ( + normalizedEmail.accepted && + this.ipAdmission && + this.emailAdmission && + this.admissionDigest + ) { const issuedAt = new Date().toISOString(); let admitted = false; try { @@ -85,7 +90,10 @@ export class RegistrationController { return false; }; const [ipAllowed, emailAllowed] = await Promise.all([ - allowAny(this.ipAdmission, this.admissionDigest.digestCandidates('ip', requestIp(_request))), + allowAny( + this.ipAdmission, + this.admissionDigest.digestCandidates('ip', requestIp(_request)), + ), allowAny( this.emailAdmission, this.admissionDigest.digestCandidates('email', normalizedEmail.value), diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 6b3188b7..b0e783ff 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -434,8 +434,8 @@ export class IamModule { keyPrefix: 'databreeze:iam:registration:email:v1:', maxAttempts: 5, windowSeconds: 15 * 60, - ...options.registrationEmailAdmissionOptions, - })); + ...options.registrationEmailAdmissionOptions, + })); if ( registrationService !== undefined && (registrationIpAdmission === undefined || diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts index dbc8ab8f..032be04b 100644 --- a/services/api/test/features/iam/invitation-service.test.ts +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -185,10 +185,7 @@ class Delivery implements IamInvitationDeliveryPortV1 { } } -function service( - repository: Repository, - delivery: IamInvitationDeliveryPortV1 = new Delivery(), -) { +function service(repository: Repository, delivery: IamInvitationDeliveryPortV1 = new Delivery()) { const idsQueue: string[] = [ids.invitation, ids.invitation]; const idGenerator: IamInvitationIdGeneratorV1 = () => { const next = idsQueue.shift(); diff --git a/services/api/test/features/iam/prisma-service-account-repository.test.ts b/services/api/test/features/iam/prisma-service-account-repository.test.ts index 7a824776..76d0682b 100644 --- a/services/api/test/features/iam/prisma-service-account-repository.test.ts +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -214,10 +214,7 @@ void test('[IAM-013, INT-004] Prisma service-account adapter persists actor-scop assert.equal(replay?.secretEnvelope, 'v1.encrypted-envelope'); assert.equal(replay?.expiresAt, '2026-01-02T00:00:00.000Z'); assert.equal(rows[0]?.['createSecretEnvelope'], 'v1.encrypted-envelope'); - assert.equal( - String(rows[0]?.['createAccountSnapshot']).includes('one-time-secret'), - false, - ); + assert.equal(String(rows[0]?.['createAccountSnapshot']).includes('one-time-secret'), false); await repository.replaceServiceAccount( organizationContext, Object.freeze({ ...value, name: 'Changed after create', revision: 2 }), diff --git a/services/api/test/features/iam/registration-crypto.test.ts b/services/api/test/features/iam/registration-crypto.test.ts index 6c5b4c10..a3b61f16 100644 --- a/services/api/test/features/iam/registration-crypto.test.ts +++ b/services/api/test/features/iam/registration-crypto.test.ts @@ -15,7 +15,10 @@ void test('[IAM-001] registration admission digests are keyed and support bounde assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1]); assert.match(candidates[0] ?? '', /^[a-f0-9]{64}$/u); - assert.equal(candidates.some((digest) => digest.includes('user@example.com')), false); + assert.equal( + candidates.some((digest) => digest.includes('user@example.com')), + false, + ); assert.deepEqual(candidates, adapter.digestCandidates('email', 'User@example.com')); }); diff --git a/services/api/test/features/iam/service-account-composition.test.ts b/services/api/test/features/iam/service-account-composition.test.ts index 1291bc3a..334097d5 100644 --- a/services/api/test/features/iam/service-account-composition.test.ts +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -56,10 +56,13 @@ void test('[IAM-013] durable service-account storage requires a stable envelope serviceAccountDatabase: {} as never, serviceAccountSecretEnvelopeKey: key, }); - assert.ok(registered.providers?.some((provider) => - typeof provider === 'object' && - provider !== null && - 'provide' in provider && - provider.provide === SERVICE_ACCOUNT_SERVICE, - )); + assert.ok( + registered.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'provide' in provider && + provider.provide === SERVICE_ACCOUNT_SERVICE, + ), + ); }); From 63af721204cb36458ca8cd944f8874b15ec66c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 13:18:40 +0700 Subject: [PATCH 43/43] fix(iam): satisfy durable adapter lint boundaries --- .../adapter/prisma-iam-invitation-repository.adapter.ts | 8 ++++++-- .../iam/adapter/prisma-recovery-repository.adapter.ts | 8 ++++++-- .../adapter/prisma-service-account-repository.adapter.ts | 2 +- services/api/test/features/iam/recovery.service.test.ts | 4 +--- .../test/features/iam/registration-composition.test.ts | 4 ++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts index a491aef4..65194a43 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts @@ -85,8 +85,12 @@ interface IamInvitationTokenDelegateV1 { } interface IamInvitationDeliveryFailureDelegateV1 { - findUnique(input: { readonly where: Readonly> }): Promise; - create(input: { readonly data: Readonly> }): Promise; + findUnique(input: { + readonly where: Readonly>; + }): Promise> | null>; + create(input: { + readonly data: Readonly>; + }): Promise>>; } interface IamInvitationTransactionDatabaseClientV1 { diff --git a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts index b847586f..4264e028 100644 --- a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts @@ -42,8 +42,12 @@ export interface RecoverySessionDatabaseRowV1 { } interface RecoveryCompensationFailureDelegateV1 { - findUnique(input: { readonly where: Readonly> }): Promise; - create(input: { readonly data: Readonly> }): Promise; + findUnique(input: { + readonly where: Readonly>; + }): Promise> | null>; + create(input: { + readonly data: Readonly>; + }): Promise>>; } interface UniqueDelegateV1 { diff --git a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts index a8f16721..56023390 100644 --- a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -225,7 +225,7 @@ function accountFromSnapshot( (candidate['status'] === 'REVOKED' && !revokedAt?.accepted) ) throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_IDEMPOTENCY_INVALID'); - const status = candidate['status'] as 'ACTIVE' | 'REVOKED'; + const status = candidate['status']; const secretVersion = candidate['secretVersion'] as number; const revision = candidate['revision'] as number; return Object.freeze({ diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 285dcd6b..1a3a1af6 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -279,9 +279,7 @@ void test('[IAM-015] recovery blocks completion when compensating revocation can passwordCredentials: credentials(), digest: { digestToken: () => 'a'.repeat(64), digestEmail: () => 'b'.repeat(64) }, delivery: { - deliver: async () => { - throw new Error('provider down'); - }, + deliver: () => Promise.reject(new Error('provider down')), }, ids: { next: () => '00000000-0000-4000-8000-000000000002' }, tokens: { next: () => token }, diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts index 0ceaea5f..dd029e1c 100644 --- a/services/api/test/features/iam/registration-composition.test.ts +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -69,8 +69,8 @@ void test('[IAM-001] durable registration requires password credentials before c const configured = IamModule.register({ registrationDatabase: {} as never, passwordCredentials, - registrationIpAdmissionCounter: { incrementWindow: async () => 1 }, - registrationEmailAdmissionCounter: { incrementWindow: async () => 1 }, + registrationIpAdmissionCounter: { incrementWindow: () => Promise.resolve(1) }, + registrationEmailAdmissionCounter: { incrementWindow: () => Promise.resolve(1) }, registrationAdmissionDigest, }); const repository = provider(configured, IAM_REGISTRATION_REPOSITORY_PORT);