From c35f62dbb629b0e47986f76739e3300d995189d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:22:42 +0700 Subject: [PATCH 01/99] feat(iam): define hashed invitation token invariants --- packages/domain/package.json | 4 + packages/domain/src/invitation/v1.ts | 156 ++++++++++++++++++++ packages/domain/test/invitation-v1.test.mjs | 92 ++++++++++++ packages/domain/test/public-api-v1.test.mjs | 7 +- 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 packages/domain/src/invitation/v1.ts create mode 100644 packages/domain/test/invitation-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 3499f462..5a3c21fb 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -36,6 +36,10 @@ "types": "./src/mfa/v1.ts", "import": "./dist/mfa/v1.js" }, + "./invitation/v1": { + "types": "./src/invitation/v1.ts", + "import": "./dist/invitation/v1.js" + }, "./device-authorization/v1": { "types": "./src/device-authorization/v1.ts", "import": "./dist/device-authorization/v1.js" diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts new file mode 100644 index 00000000..9c258191 --- /dev/null +++ b/packages/domain/src/invitation/v1.ts @@ -0,0 +1,156 @@ +import { + INVITATION_MAX_SECONDS_V1, + type InitialRoleIdForIdentityV1, +} from '../identity/v1.js'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAM-010: persisted invitation tokens never contain the raw bearer value. */ +export const INVITATION_TOKEN_SCHEMA_VERSION_V1 = 1 as const; +export { INVITATION_MAX_SECONDS_V1 } from '../identity/v1.js'; + +export type InvitationTokenStatusV1 = 'ACTIVE' | 'REDEEMED' | 'REVOKED'; + +export interface InvitationTokenV1 { + readonly schemaVersion: typeof INVITATION_TOKEN_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly principalId: StableIdentifierV1; + readonly scope: TenantScopeV1; + readonly roleId: InitialRoleIdForIdentityV1; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly status: InvitationTokenStatusV1; + readonly consumedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type InvitationTokenErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_ROLE' + | 'INVALID_DIGEST' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_STATE' + | 'ALREADY_CONSUMED' + | 'EXPIRED'; + +export type InvitationTokenResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: InvitationTokenErrorCodeV1 }; + +function accepted(value: TValue): InvitationTokenResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: InvitationTokenErrorCodeV1): InvitationTokenResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function role(input: unknown): input is InitialRoleIdForIdentityV1 { + return ( + input === 'owner' || + input === 'admin' || + input === 'analyst' || + input === 'operator' || + input === 'approver' || + input === 'viewer' + ); +} + +function positiveRevision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +export function createInvitationTokenV1(input: { + readonly id: unknown; + readonly membershipId: unknown; + readonly principalId: unknown; + readonly scope: unknown; + readonly roleId: unknown; + readonly tokenDigest: unknown; + readonly emailDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly revision?: unknown; +}): InvitationTokenResultV1 { + const id = stable(input.id); + const membershipId = stable(input.membershipId); + const principalId = stable(input.principalId); + const scope = parseTenantScopeV1(input.scope); + const tokenDigest = digest(input.tokenDigest); + const emailDigest = digest(input.emailDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const revision = input.revision === undefined ? 1 : positiveRevision(input.revision); + if (!id || !membershipId || !principalId) return rejected('INVALID_IDENTIFIER'); + if (!scope.accepted) return rejected('INVALID_SCOPE'); + if (!role(input.roleId)) return rejected('INVALID_ROLE'); + if (!tokenDigest || !emailDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + if (!revision) return rejected('INVALID_STATE'); + const duration = Date.parse(expiresAt) - Date.parse(issuedAt); + if (!Number.isFinite(duration) || duration <= 0 || duration > INVITATION_MAX_SECONDS_V1 * 1_000) + return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: INVITATION_TOKEN_SCHEMA_VERSION_V1, + id, + membershipId, + principalId, + scope: scope.value, + roleId: input.roleId, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, + status: 'ACTIVE' as const, + revision, + }), + ); +} + +export function consumeInvitationTokenV1( + token: InvitationTokenV1, + at: unknown, +): InvitationTokenResultV1 { + const timestampValue = timestamp(at); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (token.status !== 'ACTIVE') return rejected('ALREADY_CONSUMED'); + const nowMs = Date.parse(timestampValue); + const issuedMs = Date.parse(token.issuedAt); + const expiresMs = Date.parse(token.expiresAt); + if (!Number.isFinite(nowMs) || nowMs < issuedMs) return rejected('INVALID_TIMESTAMP'); + if (nowMs >= expiresMs) return rejected('EXPIRED'); + return accepted( + Object.freeze({ + ...token, + status: 'REDEEMED' as const, + consumedAt: timestampValue, + revision: token.revision + 1, + }), + ); +} diff --git a/packages/domain/test/invitation-v1.test.mjs b/packages/domain/test/invitation-v1.test.mjs new file mode 100644 index 00000000..430e96c1 --- /dev/null +++ b/packages/domain/test/invitation-v1.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + INVITATION_MAX_SECONDS_V1, + consumeInvitationTokenV1, + createInvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; + +const ids = { + invitation: '00000000-0000-4000-8000-000000000301', + membership: '00000000-0000-4000-8000-000000000302', + principal: '00000000-0000-4000-8000-000000000303', + organization: '00000000-0000-4000-8000-000000000304', +}; +const scope = { scopeType: 'organization', organizationId: ids.organization }; +const issuedAt = '2026-08-03T00:00:00.000Z'; +const expiresAt = new Date( + Date.parse(issuedAt) + INVITATION_MAX_SECONDS_V1 * 1_000, +).toISOString(); + +function input(overrides = {}) { + return { + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.principal, + scope, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt, + expiresAt, + ...overrides, + }; +} + +void test('[IAM-010] invitation token binds exact scope, role, principal, and recipient digest', () => { + const result = createInvitationTokenV1(input()); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.status, 'ACTIVE'); + assert.equal(result.value.revision, 1); + assert.equal(result.value.scope.scopeType, 'organization'); + assert.equal(result.value.roleId, 'viewer'); + assert.equal(result.value.emailDigest, 'b'.repeat(64)); +}); + +void test('[IAM-010] invitation token cannot exceed seven days or carry invalid digests', () => { + assert.deepEqual( + createInvitationTokenV1({ + ...input(), + tokenDigest: 'not-a-digest', + }), + { accepted: false, code: 'INVALID_DIGEST' }, + ); + assert.deepEqual( + createInvitationTokenV1({ + ...input(), + expiresAt: new Date(Date.parse(issuedAt) + (INVITATION_MAX_SECONDS_V1 + 1) * 1_000).toISOString(), + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-010] consuming an active token is one-time and revisioned', () => { + const created = createInvitationTokenV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const consumed = consumeInvitationTokenV1(created.value, issuedAt); + assert.equal(consumed.accepted, true); + if (!consumed.accepted) return; + assert.equal(consumed.value.status, 'REDEEMED'); + assert.equal(consumed.value.revision, 2); + assert.deepEqual(consumeInvitationTokenV1(consumed.value, issuedAt), { + accepted: false, + code: 'ALREADY_CONSUMED', + }); +}); + +void test('[IAM-010] consuming after expiry or before issue fails closed', () => { + const created = createInvitationTokenV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual( + consumeInvitationTokenV1(created.value, new Date(Date.parse(expiresAt) + 1).toISOString()), + { accepted: false, code: 'EXPIRED' }, + ); + assert.deepEqual( + consumeInvitationTokenV1(created.value, new Date(Date.parse(issuedAt) - 1).toISOString()), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 49613204..c96e6738 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -15,9 +15,10 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './authorization/v1', './audit/v1', './identity/v1', - './entitlements/v1', - './mfa/v1', - './device-authorization/v1', + './entitlements/v1', + './mfa/v1', + './invitation/v1', + './device-authorization/v1', './device-sync/v1', './device-capability/v1', './data-mode/v1', From 92b18edca31e9ae896efe076b70e3723cc857130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:23:57 +0700 Subject: [PATCH 02/99] feat(iam): publish invitation invariants in domain aggregate --- packages/domain/src/v1.ts | 1 + packages/domain/test/public-api-v1.test.mjs | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 383101b9..0c0c4793 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -27,6 +27,7 @@ export * from './evidence-grant/v1.js'; export * from './identity/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; +export * from './invitation/v1.js'; export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index c96e6738..014648b2 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -15,10 +15,10 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './authorization/v1', './audit/v1', './identity/v1', - './entitlements/v1', - './mfa/v1', - './invitation/v1', - './device-authorization/v1', + './entitlements/v1', + './mfa/v1', + './invitation/v1', + './device-authorization/v1', './device-sync/v1', './device-capability/v1', './data-mode/v1', @@ -67,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.IDENTITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.INVITATION_TOKEN_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PKCE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.CSRF_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); From 46b69394ed9e650931ef8fd11bdb23adfb2e360d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:30:51 +0700 Subject: [PATCH 03/99] feat(iam): add invitation issue and redemption service --- .../application/invitation-repository.port.ts | 35 +++ .../iam/application/invitation.service.ts | 277 ++++++++++++++++++ .../features/iam/invitation-service.test.ts | 265 +++++++++++++++++ 3 files changed, 577 insertions(+) create mode 100644 services/api/src/features/iam/application/invitation-repository.port.ts create mode 100644 services/api/src/features/iam/application/invitation.service.ts create mode 100644 services/api/test/features/iam/invitation-service.test.ts diff --git a/services/api/src/features/iam/application/invitation-repository.port.ts b/services/api/src/features/iam/application/invitation-repository.port.ts new file mode 100644 index 00000000..15ff1ff2 --- /dev/null +++ b/services/api/src/features/iam/application/invitation-repository.port.ts @@ -0,0 +1,35 @@ +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_INVITATION_REPOSITORY_PORT = Symbol('IAM_INVITATION_REPOSITORY_PORT'); + +export interface IamInvitationTransactionPortV1 { + findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise; + findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise; + findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise; + findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise; + saveInvitation(context: IamTenantContextV1, invitation: InvitationTokenV1): Promise; + saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1): Promise; +} + +export interface IamInvitationRepositoryPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts new file mode 100644 index 00000000..3f1894a2 --- /dev/null +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -0,0 +1,277 @@ +import { timingSafeEqual } from 'node:crypto'; + +import { + consumeInvitationTokenV1, + createInvitationTokenV1, + type InvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { roleHasPermissionV1, PERMISSIONS_V1 } from '@databreeze/domain/permissions/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from './invitation-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_INVITATION_SERVICE = Symbol('IAM_INVITATION_SERVICE'); + +export type IamInvitationApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_TEXT' + | 'INVALID_EMAIL' + | 'INVALID_TOKEN' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'INVALID_STATE' + | 'RECIPIENT_MISMATCH' + | 'CONFLICT' + | 'DELIVERY_UNAVAILABLE' + | 'UNAVAILABLE'; + +export type IamInvitationApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: IamInvitationApplicationCodeV1 }; + +export interface IamInvitationDigestPortV1 { + digestToken(rawToken: string): string; + digestEmail(normalizedEmail: string): string; +} + +export type IamInvitationIdGeneratorV1 = () => string; +export type IamInvitationTokenGeneratorV1 = () => string; +export type IamInvitationClockV1 = () => Date; + +export interface IamPrincipalEmailLookupPortV1 { + findEmail(principalId: StableIdentifierV1): Promise; +} + +/** Raw bearer material is deliberately confined to this delivery port. */ +export interface IamInvitationDeliveryPortV1 { + deliver(input: { + readonly invitationId: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly recipientEmail: string; + readonly rawToken: string; + readonly expiresAt: string; + }): Promise; +} + +export interface IamIssuedInvitationV1 { + readonly invitationId: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly expiresAt: InvitationTokenV1['expiresAt']; + readonly deliveryStatus: 'DELIVERED'; +} + +function accepted(value: TValue): IamInvitationApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: IamInvitationApplicationCodeV1): IamInvitationApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function rawToken(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length < 32 || input.length > 512) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + return input; +} + +function safeDigestEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +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') + return 'CONFLICT'; + if (message === 'IAM_INVITATION_INVALID') return 'INVALID_TOKEN'; + return 'UNAVAILABLE'; +} + +/** IAM-010: issue and redeem one-time invitations without exposing bearer material. */ +export class IamInvitationService { + public constructor( + private readonly repository: IamInvitationRepositoryPortV1, + private readonly principalEmails: IamPrincipalEmailLookupPortV1, + private readonly idGenerator: IamInvitationIdGeneratorV1, + private readonly tokenGenerator: IamInvitationTokenGeneratorV1, + private readonly digest: IamInvitationDigestPortV1, + private readonly delivery: IamInvitationDeliveryPortV1, + private readonly clock: IamInvitationClockV1 = () => new Date(), + ) {} + + private now(): string | undefined { + try { + const value = this.clock(); + return value instanceof Date && Number.isFinite(value.getTime()) + ? value.toISOString() + : undefined; + } catch { + return undefined; + } + } + + private async authorize( + context: IamTenantContextV1, + target: IamMembershipRecordV1, + transaction: IamInvitationTransactionPortV1, + ): Promise<'ALLOWED' | 'DENIED' | 'UNAVAILABLE'> { + if (!tenantScopeContainsV1(context.tenantScope, target.scope)) return 'DENIED'; + try { + const actor = await transaction.findMembershipForPrincipal(context, context.actorId); + return actor && roleHasPermissionV1(actor.roleId, PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE) + ? 'ALLOWED' + : 'DENIED'; + } catch { + return 'UNAVAILABLE'; + } + } + + public async issue( + context: IamTenantContextV1, + input: { readonly membershipId: unknown; readonly recipientEmail: unknown }, + ): Promise> { + const membershipId = stable(input.membershipId); + if (!membershipId) return rejected('INVALID_IDENTIFIER'); + const normalizedEmail = normalizeEmailAddressV1(input.recipientEmail); + if (!normalizedEmail.accepted) return rejected('INVALID_EMAIL'); + const issuedAt = this.now(); + if (!issuedAt) return rejected('UNAVAILABLE'); + let invitationId: string; + let raw: string; + try { + invitationId = this.idGenerator(); + raw = this.tokenGenerator(); + } catch { + return rejected('UNAVAILABLE'); + } + if (!stable(invitationId) || !rawToken(raw)) return rejected('UNAVAILABLE'); + try { + return 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'); + const authorization = await this.authorize(context, membership, transaction); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + const recipientEmail = await this.principalEmails.findEmail(membership.principalId); + const normalizedRecipient = normalizeEmailAddressV1(recipientEmail); + if (!normalizedRecipient.accepted || normalizedRecipient.value !== normalizedEmail.value) + return rejected('RECIPIENT_MISMATCH'); + if (await transaction.findActiveInvitationForMembership(context, membership.id)) + return rejected('CONFLICT'); + const expiresAt = new Date(Date.parse(issuedAt) + 7 * 24 * 60 * 60 * 1_000).toISOString(); + const token = createInvitationTokenV1({ + id: invitationId, + membershipId: membership.id, + principalId: membership.principalId, + scope: membership.scope, + roleId: membership.roleId, + tokenDigest: this.digest.digestToken(raw), + emailDigest: this.digest.digestEmail(normalizedEmail.value), + issuedAt, + 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, + }); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async accept( + context: IamTenantContextV1, + rawInput: unknown, + ): Promise> { + const raw = rawToken(rawInput); + if (!raw) return rejected('INVALID_TEXT'); + let digest: string; + try { + digest = this.digest.digestToken(raw); + } catch { + return rejected('UNAVAILABLE'); + } + try { + return await this.repository.withTransaction(context, async (transaction) => { + const token = await transaction.findInvitationByDigest(context, digest); + if (!token) return rejected('INVALID_TOKEN'); + if (token.principalId !== context.actorId) return rejected('INVALID_TOKEN'); + const recipientEmail = await this.principalEmails.findEmail(context.actorId); + const normalizedEmail = normalizeEmailAddressV1(recipientEmail); + if ( + !normalizedEmail.accepted || + !safeDigestEqual(this.digest.digestEmail(normalizedEmail.value), token.emailDigest) + ) + return rejected('INVALID_TOKEN'); + if (!tenantScopeContainsV1(context.tenantScope, token.scope)) + return rejected('INVALID_TOKEN'); + const membership = await transaction.findMembershipById(context, token.membershipId); + if ( + !membership || + membership.status !== 'INVITED' || + membership.principalId !== token.principalId || + membership.roleId !== token.roleId || + !tenantScopesEqualV1(membership.scope, token.scope) + ) + return rejected('INVALID_TOKEN'); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); + const consumed = consumeInvitationTokenV1(token, now); + if (!consumed.accepted) return rejected('INVALID_TOKEN'); + const { + startsAt: _startsAt, + expiresAt: _expiresAt, + ...membershipWithoutLifetime + } = membership; + void _startsAt; + void _expiresAt; + const next: IamMembershipRecordV1 = Object.freeze({ + ...membershipWithoutLifetime, + status: 'ACTIVE', + revision: membership.revision + 1, + }); + await transaction.saveInvitation(context, consumed.value); + await transaction.saveMembership(context, next); + return accepted(next); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } +} diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts new file mode 100644 index 00000000..addcfd0b --- /dev/null +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + IamInvitationService, + type IamInvitationDigestPortV1, + type IamInvitationDeliveryPortV1, + type IamInvitationIdGeneratorV1, + type IamInvitationTokenGeneratorV1, + type IamPrincipalEmailLookupPortV1, +} from '../../../src/features/iam/application/invitation.service.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../../../src/features/iam/application/invitation-repository.port.js'; +import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000311', + invitee: '00000000-0000-4000-8000-000000000312', + organization: '00000000-0000-4000-8000-000000000313', + ownerMembership: '00000000-0000-4000-8000-000000000314', + invitedMembership: '00000000-0000-4000-8000-000000000315', + invitation: '00000000-0000-4000-8000-000000000316', +}; +const now = new Date('2026-08-03T00:00:00.000Z'); +const RAW_TOKEN = 'raw-token-abcdefghijklmnopqrstuvwxyz123456'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture timestamp'); + return parsed.value; +} + +function context(actorId: string, key: string) { + const parsed = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: ids.organization }, + actorId, + correlationId: '00000000-0000-4000-8000-000000000317', + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture context'); + return parsed.value; +} + +class Repository implements IamInvitationRepositoryPortV1 { + memberships: IamMembershipRecordV1[] = [ + { + id: stable(ids.ownerMembership), + principalId: stable(ids.owner), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable(ids.invitedMembership), + principalId: stable(ids.invitee), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'viewer', + status: 'INVITED', + startsAt: timestamp(now.toISOString()), + expiresAt: timestamp(new Date(now.getTime() + 86_400_000).toISOString()), + revision: 1, + }, + ]; + invitations: InvitationTokenV1[] = []; + private tail: Promise = Promise.resolve(); + + async withTransaction( + _context: Parameters[0], + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const prior = this.tail; + this.tail = new Promise((resolve) => (release = resolve)); + await prior; + const memberships = this.memberships.map((item) => ({ ...item, scope: { ...item.scope } })); + const invitations = [...this.invitations]; + try { + return await work({ + findMembershipForPrincipal: async (_context, principalId) => + this.memberships.find( + (membership) => + membership.principalId === principalId && membership.status === 'ACTIVE', + ), + findMembershipById: async (_context, id) => + this.memberships.find((membership) => membership.id === id), + findInvitationByDigest: async (_context, digest) => + this.invitations.find((invitation) => invitation.tokenDigest === digest), + findActiveInvitationForMembership: async (_context, membershipId) => + this.invitations.find( + (invitation) => + invitation.membershipId === membershipId && invitation.status === 'ACTIVE', + ), + saveInvitation: async (_context, invitation) => { + const index = this.invitations.findIndex((item) => item.id === invitation.id); + if (index >= 0) { + if (this.invitations[index]?.revision !== invitation.revision - 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + this.invitations[index] = invitation; + } else { + if (this.invitations.some((item) => item.tokenDigest === invitation.tokenDigest)) + throw new Error('IAM_INVITATION_CONFLICT'); + this.invitations.push(invitation); + } + }, + saveMembership: async (_context, membership) => { + 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'); + this.memberships[index] = membership; + }, + }); + } catch (error) { + this.memberships = memberships; + this.invitations = invitations; + throw error; + } finally { + release(); + } + } +} + +class EmailLookup implements IamPrincipalEmailLookupPortV1 { + async findEmail(principalId: string): Promise { + return principalId === ids.invitee ? 'invitee@example.com' : 'owner@example.com'; + } +} + +class Digest implements IamInvitationDigestPortV1 { + digestToken(value: string): string { + return value === RAW_TOKEN ? 'a'.repeat(64) : 'c'.repeat(64); + } + + digestEmail(value: string): string { + return value === 'invitee@example.com' ? 'b'.repeat(64) : 'd'.repeat(64); + } +} + +class Delivery implements IamInvitationDeliveryPortV1 { + readonly sent: Array<{ readonly token: string; readonly email: string }> = []; + + async deliver(input: { + readonly rawToken: string; + readonly recipientEmail: string; + }): Promise { + this.sent.push({ token: input.rawToken, email: input.recipientEmail }); + } +} + +function service(repository: Repository, delivery = new Delivery()) { + const idsQueue: string[] = [ids.invitation, ids.invitation]; + const idGenerator: IamInvitationIdGeneratorV1 = () => { + const next = idsQueue.shift(); + if (!next) throw new Error('invitation id generator exhausted'); + return next; + }; + const tokenGenerator: IamInvitationTokenGeneratorV1 = () => RAW_TOKEN; + return { + service: new IamInvitationService( + repository, + new EmailLookup(), + idGenerator, + tokenGenerator, + new Digest(), + delivery, + () => now, + ), + delivery, + }; +} + +void test('[IAM-010] issuing an invitation delivers a raw token but returns only safe metadata', async () => { + const repository = new Repository(); + const composed = service(repository); + const result = await composed.service.issue(context(ids.owner, 'invitation-issue-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'INVITEE@example.com', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.membershipId, stable(ids.invitedMembership)); + assert.equal('rawToken' in result.value, false); + assert.equal('tokenDigest' in result.value, false); + assert.deepEqual(composed.delivery.sent, [{ token: RAW_TOKEN, email: 'invitee@example.com' }]); +}); + +void test('[IAM-010] email mismatch and non-owner issuance are denied without persistence', async () => { + const repository = new Repository(); + const composed = service(repository); + assert.deepEqual( + await composed.service.issue(context(ids.owner, 'invitation-issue-002'), { + membershipId: ids.invitedMembership, + recipientEmail: 'other@example.com', + }), + { accepted: false, code: 'RECIPIENT_MISMATCH' }, + ); + assert.deepEqual( + await composed.service.issue(context(ids.invitee, 'invitation-issue-003'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + assert.equal(repository.invitations.length, 0); +}); + +void test('[IAM-010] acceptance binds token, principal, email, role, and scope then consumes once', async () => { + const repository = new Repository(); + const composed = service(repository); + const issued = await composed.service.issue(context(ids.owner, 'invitation-accept-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }); + assert.equal(issued.accepted, true); + const accepted = await composed.service.accept( + context(ids.invitee, 'invitation-accept-002'), + RAW_TOKEN, + ); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + assert.equal(accepted.value.status, 'ACTIVE'); + assert.equal(repository.invitations[0]?.status, 'REDEEMED'); + assert.deepEqual( + await composed.service.accept(context(ids.invitee, 'invitation-accept-003'), RAW_TOKEN), + { accepted: false, code: 'INVALID_TOKEN' }, + ); +}); + +void test('[IAM-010] concurrent acceptance has one winner and no duplicate activation', async () => { + const repository = new Repository(); + const composed = service(repository); + await composed.service.issue(context(ids.owner, 'invitation-race-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }); + const results = await Promise.all([ + composed.service.accept(context(ids.invitee, 'invitation-race-002'), RAW_TOKEN), + composed.service.accept(context(ids.invitee, 'invitation-race-003'), RAW_TOKEN), + ]); + assert.equal(results.filter((result) => result.accepted).length, 1); + assert.equal(results.filter((result) => !result.accepted).length, 1); + assert.equal( + repository.memberships.find((item) => item.id === stable(ids.invitedMembership))?.revision, + 2, + ); +}); From 9f14db243ff149d0a0874b3f3373304c26e1a7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:33:17 +0700 Subject: [PATCH 04/99] feat(iam): add scoped invitation repository adapter --- ...emory-iam-invitation-repository.adapter.ts | 181 ++++++++++++++++++ .../in-memory-invitation-repository.test.ts | 161 ++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts create mode 100644 services/api/test/features/iam/in-memory-invitation-repository.test.ts 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 new file mode 100644 index 00000000..bbc0fc49 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts @@ -0,0 +1,181 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; + +import type { IamMembershipRecordV1 } from '../application/iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../application/invitation-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; + +function visible(context: TenantScopeV1, target: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, target) || tenantScopeContainsV1(target, context); +} + +function cloneMembership(record: IamMembershipRecordV1): IamMembershipRecordV1 { + return Object.freeze({ ...record, scope: Object.freeze({ ...record.scope }) }); +} + +function cloneInvitation(record: InvitationTokenV1): InvitationTokenV1 { + return Object.freeze({ ...record, scope: Object.freeze({ ...record.scope }) }); +} + +function sameInvitationIdentity(left: InvitationTokenV1, right: InvitationTokenV1): boolean { + return ( + left.membershipId === right.membershipId && + left.principalId === right.principalId && + tenantScopesEqualV1(left.scope, right.scope) && + left.roleId === right.roleId && + left.tokenDigest === right.tokenDigest && + left.emailDigest === right.emailDigest && + left.issuedAt === right.issuedAt && + left.expiresAt === right.expiresAt + ); +} + +/** Test/local adapter that mirrors the scoped and compare-and-set rules of PostgreSQL. */ +export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepositoryPortV1 { + private memberships: IamMembershipRecordV1[]; + private invitations: InvitationTokenV1[] = []; + private transactionTail: Promise = Promise.resolve(); + + public constructor(memberships: readonly IamMembershipRecordV1[] = []) { + this.memberships = memberships.map(cloneMembership); + } + + public seedMemberships(memberships: readonly IamMembershipRecordV1[]): void { + this.memberships = memberships.map(cloneMembership); + } + + public async withTransaction( + _context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const prior = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await prior; + const membershipsBefore = this.memberships.map(cloneMembership); + const invitationsBefore = this.invitations.map(cloneInvitation); + try { + return await work({ + findMembershipForPrincipal: this.findMembershipForPrincipal.bind(this), + findMembershipById: this.findMembershipById.bind(this), + findInvitationByDigest: this.findInvitationByDigest.bind(this), + findActiveInvitationForMembership: this.findActiveInvitationForMembership.bind(this), + saveInvitation: this.saveInvitation.bind(this), + saveMembership: this.saveMembership.bind(this), + }); + } catch (error) { + this.memberships = membershipsBefore; + this.invitations = invitationsBefore; + throw error; + } finally { + release(); + } + } + + private async findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + const visibleMemberships = this.memberships.filter((membership) => + visible(context.tenantScope, membership.scope), + ); + return selectAuthoritativeMembership(visibleMemberships, context, principalId); + } + + private async findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const membership = this.memberships.find((item) => item.id === membershipId); + return membership && visible(context.tenantScope, membership.scope) + ? cloneMembership(membership) + : undefined; + } + + private async findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise { + const invitation = this.invitations.find( + (item) => + item.tokenDigest === tokenDigest && tenantScopeContainsV1(context.tenantScope, item.scope), + ); + return invitation ? cloneInvitation(invitation) : undefined; + } + + private async findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const invitation = this.invitations.find( + (item) => + item.membershipId === membershipId && + item.status === 'ACTIVE' && + tenantScopeContainsV1(context.tenantScope, item.scope), + ); + return invitation ? cloneInvitation(invitation) : undefined; + } + + private async saveInvitation( + context: IamTenantContextV1, + invitation: InvitationTokenV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, invitation.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const existing = this.invitations.find((item) => item.id === invitation.id); + if (!existing) { + if (this.invitations.some((item) => item.tokenDigest === invitation.tokenDigest)) + throw new Error('IAM_INVITATION_CONFLICT'); + if ( + this.invitations.some( + (item) => item.membershipId === invitation.membershipId && item.status === 'ACTIVE', + ) + ) + throw new Error('IAM_INVITATION_CONFLICT'); + this.invitations.push(cloneInvitation(invitation)); + return; + } + if (!sameInvitationIdentity(existing, invitation)) + throw new Error('IAM_INVITATION_SCOPE_IMMUTABLE'); + if (invitation.revision !== existing.revision + 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + if (existing.status !== 'ACTIVE' || invitation.status === 'ACTIVE') + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + this.invitations = this.invitations.map((item) => + item.id === invitation.id ? cloneInvitation(invitation) : item, + ); + } + + private async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const index = this.memberships.findIndex((item) => item.id === membership.id); + if (index < 0) throw new Error('IAM_REVISION_CONFLICT'); + const existing = this.memberships[index]; + if (!existing) throw new Error('IAM_REVISION_CONFLICT'); + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) || + existing.roleId !== membership.roleId + ) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + this.memberships = this.memberships.map((item, itemIndex) => + itemIndex === index ? cloneMembership(membership) : item, + ); + } +} diff --git a/services/api/test/features/iam/in-memory-invitation-repository.test.ts b/services/api/test/features/iam/in-memory-invitation-repository.test.ts new file mode 100644 index 00000000..a4006e35 --- /dev/null +++ b/services/api/test/features/iam/in-memory-invitation-repository.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryIamInvitationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.js'; +import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../../../src/features/iam/application/invitation-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000321', + invitee: '00000000-0000-4000-8000-000000000322', + organization: '00000000-0000-4000-8000-000000000323', + membership: '00000000-0000-4000-8000-000000000324', + invitation: '00000000-0000-4000-8000-000000000325', + correlation: '00000000-0000-4000-8000-000000000326', +}; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture identifier'); + return result.value; +} + +function timestamp(value: string) { + const result = parseStrictUtcTimestampV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture timestamp'); + return result.value; +} + +function context( + actorId = ids.owner, + scope: unknown = { scopeType: 'organization', organizationId: ids.organization }, +) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId, + correlationId: ids.correlation, + idempotencyKey: `invitation-repository-${actorId}`, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture context'); + return result.value; +} + +function membership(status: IamMembershipRecordV1['status'] = 'INVITED'): IamMembershipRecordV1 { + return { + id: stable(ids.membership), + principalId: stable(ids.invitee), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'viewer', + status, + ...(status === 'INVITED' + ? { + startsAt: timestamp('2026-08-03T00:00:00.000Z'), + expiresAt: timestamp('2026-08-04T00:00:00.000Z'), + } + : {}), + revision: 1, + }; +} + +function invitation() { + const result = createInvitationTokenV1({ + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.invitee, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:00:00.000Z', + expiresAt: '2026-08-10T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture invitation'); + return result.value; +} + +void test('[IAM-010] in-memory invitation repository scopes digest and membership lookups', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + assert.equal( + (await transaction.findInvitationByDigest(context(), 'a'.repeat(64)))?.id, + stable(ids.invitation), + ); + assert.equal( + await transaction.findInvitationByDigest( + context(ids.owner, { + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: '00000000-0000-4000-8000-000000000399', + }), + 'a'.repeat(64), + ), + undefined, + ); + }, + ); +}); + +void test('[IAM-010] invitation repository enforces immutable identity and compare-and-set revisions', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + await assert.rejects( + transaction.saveInvitation(context(), { ...invitation(), roleId: 'admin' }), + /IAM_INVITATION_SCOPE_IMMUTABLE/, + ); + await assert.rejects( + transaction.saveMembership(context(), { ...membership('ACTIVE'), revision: 3 }), + /IAM_REVISION_CONFLICT/, + ); + }, + ); +}); + +void test('[IAM-010] invitation repository transaction rolls back token and membership together', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await assert.rejects( + repository.withTransaction(context(), async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + const next = { ...membership('ACTIVE'), revision: 2 }; + await transaction.saveMembership(context(), next); + throw new Error('simulated delivery acknowledgement failure'); + }), + /simulated delivery acknowledgement failure/, + ); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + assert.equal(await transaction.findInvitationByDigest(context(), 'a'.repeat(64)), undefined); + assert.equal( + (await transaction.findMembershipById(context(), stable(ids.membership)))?.status, + 'INVITED', + ); + }, + ); +}); From 2a65756ea4d55f3d71578af22a87faaa8e9cbad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:42:56 +0700 Subject: [PATCH 05/99] feat(iam): persist invitation token records --- .../migration.sql | 31 ++ services/api/prisma/schema/iam.prisma | 26 ++ ...risma-iam-invitation-repository.adapter.ts | 386 ++++++++++++++++++ .../prisma-iam-invitation-repository.test.ts | 222 ++++++++++ services/api/test/prisma-foundation.test.mjs | 16 + 5 files changed, 681 insertions(+) create mode 100644 services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql create mode 100644 services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-iam-invitation-repository.test.ts diff --git a/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql b/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql new file mode 100644 index 00000000..38642d0b --- /dev/null +++ b/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql @@ -0,0 +1,31 @@ +-- IAM-010: invitation bearer values are short-lived and hashed at rest. +CREATE TABLE "iam"."invitation_tokens" ( + "id" UUID NOT NULL, + "membership_id" UUID NOT NULL, + "principal_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "role_id" VARCHAR(32) NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "email_digest" CHAR(64) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "consumed_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitation_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "invitation_tokens_token_digest_key" +ON "iam"."invitation_tokens"("token_digest"); + +CREATE INDEX "invitation_tokens_membership_status_idx" +ON "iam"."invitation_tokens"("membership_id", "status"); + +CREATE INDEX "invitation_tokens_scope_idx" +ON "iam"."invitation_tokens"("organization_id", "scope_type", "workspace_id", "project_id"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 279aff05..901f770d 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -91,6 +91,32 @@ model MembershipIdentity { @@schema("iam") } +/// IAM-010: raw invitation bearer values are never persisted; only keyed digests are stored. +model InvitationTokenRecord { + id String @id @db.Uuid + membershipId String @map("membership_id") @db.Uuid + principalId String @map("principal_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + roleId String @map("role_id") @db.VarChar(32) + tokenDigest String @unique(map: "invitation_tokens_token_digest_key") @map("token_digest") @db.Char(64) + emailDigest String @map("email_digest") @db.Char(64) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(16) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(6) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([membershipId, status], map: "invitation_tokens_membership_status_idx") + @@index([organizationId, scopeType, workspaceId, projectId], map: "invitation_tokens_scope_idx") + @@map("invitation_tokens") + @@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/prisma-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts new file mode 100644 index 00000000..b87a0a58 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts @@ -0,0 +1,386 @@ +import { + createInvitationTokenV1, + type InvitationTokenStatusV1, + type InvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { validateMembershipV1 } from '@databreeze/domain/identity/v1'; + +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../application/invitation-repository.port.js'; +import type { IamMembershipRecordV1 } from '../application/iam-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; + +export interface IamInvitationMembershipDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt: Date | null; + readonly expiresAt: Date | null; + readonly revision: number; +} + +export interface IamInvitationDatabaseRowV1 { + readonly id: string; + readonly membershipId: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: InvitationTokenStatusV1; + readonly consumedAt: Date | null; + readonly revision: number; +} + +interface IamInvitationMembershipDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { + readonly data: IamInvitationMembershipDatabaseRowV1; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface IamInvitationTokenDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + create(input: { readonly data: IamInvitationDatabaseRowV1 }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface IamInvitationTransactionDatabaseClientV1 { + readonly membershipIdentity: IamInvitationMembershipDelegateV1; + readonly invitationTokenRecord: IamInvitationTokenDelegateV1; +} + +export interface IamInvitationDatabaseClientV1 extends IamInvitationTransactionDatabaseClientV1 { + $transaction( + work: (transaction: IamInvitationTransactionDatabaseClientV1) => Promise, + ): Promise; +} + +function parseScope(input: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 | undefined { + const organizationId = parseStableIdentifierV1(input.organizationId); + if (!organizationId.accepted) return undefined; + if (input.scopeType === 'ORGANIZATION') { + return input.workspaceId === null && input.projectId === null + ? { scopeType: 'organization', organizationId: organizationId.value } + : undefined; + } + const workspaceId = parseStableIdentifierV1(input.workspaceId); + if (!workspaceId.accepted) return undefined; + if (input.scopeType === 'WORKSPACE') { + return input.projectId === null + ? { + scopeType: 'workspace', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + } + : undefined; + } + const projectId = parseStableIdentifierV1(input.projectId); + if (!projectId.accepted || input.scopeType !== 'PROJECT') return undefined; + return { + scopeType: 'project', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + projectId: projectId.value, + }; +} + +function membershipFromRow(row: IamInvitationMembershipDatabaseRowV1): IamMembershipRecordV1 { + const scope = parseScope(row); + const validated = validateMembershipV1({ + id: row.id, + principalType: row.principalType, + principalId: row.principalId, + scope, + roleId: row.roleId, + status: row.status, + ...(row.startsAt === null ? {} : { startsAt: row.startsAt.toISOString() }), + ...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt.toISOString() }), + revision: row.revision, + }); + if (!validated.accepted) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return validated.value; +} + +function invitationFromRow(row: IamInvitationDatabaseRowV1): InvitationTokenV1 { + const scope = parseScope(row); + const issuedAt = row.issuedAt.toISOString(); + const expiresAt = row.expiresAt.toISOString(); + const base = createInvitationTokenV1({ + id: row.id, + membershipId: row.membershipId, + principalId: row.principalId, + scope, + roleId: row.roleId, + tokenDigest: row.tokenDigest, + emailDigest: row.emailDigest, + issuedAt, + expiresAt, + revision: row.revision, + }); + if (!base.accepted) throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'REDEEMED' && row.status !== 'REVOKED') + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + const consumedAtCandidate = row.consumedAt === null ? undefined : row.consumedAt.toISOString(); + const consumedAtParsed = + consumedAtCandidate === undefined ? undefined : parseStrictUtcTimestampV1(consumedAtCandidate); + const consumedAt = consumedAtParsed?.accepted ? consumedAtParsed.value : undefined; + if ((row.status === 'REDEEMED') !== (consumedAt !== undefined)) + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + if (consumedAtCandidate !== undefined && !consumedAtParsed?.accepted) + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + return Object.freeze({ + ...base.value, + status: row.status, + ...(consumedAt === undefined ? {} : { consumedAt }), + }); +} + +function membershipRow(membership: IamMembershipRecordV1): IamInvitationMembershipDatabaseRowV1 { + return { + id: membership.id, + principalType: 'USER', + principalId: membership.principalId, + scopeType: membership.scope.scopeType.toUpperCase(), + organizationId: membership.scope.organizationId, + workspaceId: + membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, + projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, + roleId: membership.roleId, + status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, + revision: membership.revision, + }; +} + +function invitationRow(invitation: InvitationTokenV1): IamInvitationDatabaseRowV1 { + return { + id: invitation.id, + membershipId: invitation.membershipId, + principalId: invitation.principalId, + scopeType: invitation.scope.scopeType.toUpperCase(), + organizationId: invitation.scope.organizationId, + workspaceId: + invitation.scope.scopeType === 'organization' ? null : invitation.scope.workspaceId, + projectId: invitation.scope.scopeType === 'project' ? invitation.scope.projectId : null, + roleId: invitation.roleId, + tokenDigest: invitation.tokenDigest, + emailDigest: invitation.emailDigest, + issuedAt: new Date(invitation.issuedAt), + expiresAt: new Date(invitation.expiresAt), + status: invitation.status, + consumedAt: invitation.consumedAt ? new Date(invitation.consumedAt) : null, + revision: invitation.revision, + }; +} + +function visibleInScope(context: TenantScopeV1, target: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, target) || tenantScopeContainsV1(target, context); +} + +function uniqueConstraint(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + +class PrismaIamInvitationTransactionAdapter implements IamInvitationTransactionPortV1 { + public constructor(private readonly client: IamInvitationTransactionDatabaseClientV1) {} + + public async findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + const rows = await this.client.membershipIdentity.findMany({ + where: { organizationId: context.tenantScope.organizationId, principalId, status: 'ACTIVE' }, + }); + const memberships = rows + .map((row) => { + try { + return membershipFromRow(row); + } catch { + return undefined; + } + }) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) + .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); + return selectAuthoritativeMembership(memberships, context, principalId); + } + + public async findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const row = await this.client.membershipIdentity.findUnique({ where: { id: membershipId } }); + if (!row) return undefined; + const membership = membershipFromRow(row); + return visibleInScope(context.tenantScope, membership.scope) ? membership : undefined; + } + + public async findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise { + const row = await this.client.invitationTokenRecord.findUnique({ where: { tokenDigest } }); + if (!row) return undefined; + const invitation = invitationFromRow(row); + return tenantScopeContainsV1(context.tenantScope, invitation.scope) ? invitation : undefined; + } + + public async findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const row = await this.client.invitationTokenRecord.findFirst({ + where: { membershipId, status: 'ACTIVE' }, + }); + if (!row) return undefined; + const invitation = invitationFromRow(row); + return tenantScopeContainsV1(context.tenantScope, invitation.scope) ? invitation : undefined; + } + + public async saveInvitation( + context: IamTenantContextV1, + invitation: InvitationTokenV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, invitation.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const row = invitationRow(invitation); + const existingRow = await this.client.invitationTokenRecord.findUnique({ + where: { id: invitation.id }, + }); + if (!existingRow) { + const active = await this.client.invitationTokenRecord.findFirst({ + where: { membershipId: invitation.membershipId, status: 'ACTIVE' }, + }); + if (active) throw new Error('IAM_INVITATION_CONFLICT'); + try { + await this.client.invitationTokenRecord.create({ data: row }); + } catch (error) { + if (uniqueConstraint(error)) throw new Error('IAM_INVITATION_CONFLICT'); + throw error; + } + return; + } + const existing = invitationFromRow(existingRow); + if ( + existing.membershipId !== invitation.membershipId || + existing.principalId !== invitation.principalId || + !tenantScopesEqualV1(existing.scope, invitation.scope) || + existing.roleId !== invitation.roleId || + existing.tokenDigest !== invitation.tokenDigest || + existing.emailDigest !== invitation.emailDigest || + existing.issuedAt !== invitation.issuedAt || + existing.expiresAt !== invitation.expiresAt + ) + throw new Error('IAM_INVITATION_SCOPE_IMMUTABLE'); + if (existing.status !== 'ACTIVE' || invitation.status === 'ACTIVE') + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + if (invitation.revision !== existing.revision + 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + const updated = await this.client.invitationTokenRecord.updateMany({ + where: { id: invitation.id, revision: existing.revision, status: 'ACTIVE' }, + data: { + status: invitation.status, + consumedAt: invitation.consumedAt ? new Date(invitation.consumedAt) : null, + revision: invitation.revision, + }, + }); + if (updated.count !== 1) throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + } + + public async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + const existingRow = await this.client.membershipIdentity.findUnique({ + where: { id: membership.id }, + }); + if (!existingRow) throw new Error('IAM_REVISION_CONFLICT'); + const existing = membershipFromRow(existingRow); + if (!visibleInScope(context.tenantScope, existing.scope)) + throw new Error('IAM_REVISION_CONFLICT'); + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) || + existing.roleId !== membership.roleId + ) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + const updated = await this.client.membershipIdentity.updateMany({ + where: { id: membership.id, revision: existing.revision }, + data: { + status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, + revision: membership.revision, + }, + }); + if (updated.count !== 1) throw new Error('IAM_REVISION_CONFLICT'); + } +} + +export class PrismaIamInvitationRepositoryAdapter implements IamInvitationRepositoryPortV1 { + public constructor(private readonly client: IamInvitationDatabaseClientV1) {} + + public withTransaction( + _context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIamInvitationTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts new file mode 100644 index 00000000..e2e23c49 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInvitationTokenV1, type InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaIamInvitationRepositoryAdapter, + type IamInvitationDatabaseClientV1, + type IamInvitationDatabaseRowV1, + type IamInvitationMembershipDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; +import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000331', + invitee: '00000000-0000-4000-8000-000000000332', + organization: '00000000-0000-4000-8000-000000000333', + membership: '00000000-0000-4000-8000-000000000334', + invitation: '00000000-0000-4000-8000-000000000335', + correlation: '00000000-0000-4000-8000-000000000336', +}; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid Prisma invitation fixture identifier'); + return parsed.value; +} + +function context() { + const parsed = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: ids.organization }, + actorId: ids.owner, + correlationId: ids.correlation, + idempotencyKey: 'prisma-invitation-001', + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid Prisma invitation fixture context'); + return parsed.value; +} + +function membershipRow(status = 'INVITED'): IamInvitationMembershipDatabaseRowV1 { + return { + id: ids.membership, + principalType: 'USER', + principalId: ids.invitee, + scopeType: 'ORGANIZATION', + organizationId: ids.organization, + workspaceId: null, + projectId: null, + roleId: 'viewer', + status, + startsAt: status === 'INVITED' ? new Date('2026-08-03T00:00:00.000Z') : null, + expiresAt: status === 'INVITED' ? new Date('2026-08-04T00:00:00.000Z') : null, + revision: status === 'INVITED' ? 1 : 2, + }; +} + +function token(): InvitationTokenV1 { + const created = createInvitationTokenV1({ + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.invitee, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:00:00.000Z', + expiresAt: '2026-08-10T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid Prisma invitation token fixture'); + return created.value; +} + +function client(options: { readonly updateCount?: number } = {}) { + const memberships: IamInvitationMembershipDatabaseRowV1[] = [membershipRow()]; + const invitations: IamInvitationDatabaseRowV1[] = []; + const calls: Array<{ readonly operation: string; readonly input: unknown }> = []; + const database: IamInvitationDatabaseClientV1 = { + membershipIdentity: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + calls.push({ operation: 'membership.findUnique', input: where }); + return memberships.find((row) => row.id === where['id']) ?? null; + }, + findMany: async ({ where }: { readonly where: Readonly> }) => { + calls.push({ operation: 'membership.findMany', input: where }); + return memberships.filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value), + ); + }, + create: async ({ data }: { readonly data: IamInvitationMembershipDatabaseRowV1 }) => { + memberships.push(data); + return data; + }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const index = memberships.findIndex( + (row) => row.id === where['id'] && row.revision === where['revision'], + ); + if (index < 0) return { count: 0 }; + memberships[index] = { + ...memberships[index], + ...data, + } as IamInvitationMembershipDatabaseRowV1; + return { count: options.updateCount ?? 1 }; + }, + }, + invitationTokenRecord: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + calls.push({ operation: 'invitation.findUnique', input: where }); + return ( + invitations.find( + (row) => row.tokenDigest === where['tokenDigest'] || row.id === where['id'], + ) ?? null + ); + }, + findFirst: async ({ where }: { readonly where: Readonly> }) => { + calls.push({ operation: 'invitation.findFirst', input: where }); + return ( + invitations.find( + (row) => row.membershipId === where['membershipId'] && row.status === where['status'], + ) ?? null + ); + }, + create: async ({ data }: { readonly data: IamInvitationDatabaseRowV1 }) => { + invitations.push(data); + return data; + }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + const index = invitations.findIndex( + (row) => + row.id === where['id'] && + row.revision === where['revision'] && + row.status === where['status'], + ); + if (index < 0) return { count: 0 }; + invitations[index] = { ...invitations[index], ...data } as IamInvitationDatabaseRowV1; + return { count: options.updateCount ?? 1 }; + }, + }, + $transaction: async (work: (transaction: typeof database) => Promise) => + work(database), + }; + return { database, memberships, invitations, calls }; +} + +void test('[IAM-010] Prisma invitation adapter stores and resolves only exact scoped digests', async () => { + const fixture = client(); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + const membership = await transaction.findMembershipById(context(), stable(ids.membership)); + assert.equal(membership?.status, 'INVITED'); + const created = token(); + await transaction.saveInvitation(context(), created); + assert.equal( + (await transaction.findInvitationByDigest(context(), created.tokenDigest))?.id, + stable(ids.invitation), + ); + assert.equal( + (await transaction.findActiveInvitationForMembership(context(), stable(ids.membership)))?.id, + stable(ids.invitation), + ); + }); + assert.equal(fixture.invitations[0]?.tokenDigest, 'a'.repeat(64)); + assert.equal(fixture.invitations[0]?.emailDigest, 'b'.repeat(64)); + assert.equal(fixture.invitations[0]?.status, 'ACTIVE'); +}); + +void test('[IAM-010] Prisma invitation adapter rejects stale token updates and hides sibling scopes', async () => { + const fixture = client(); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + await transaction.saveInvitation(context(), token()); + await assert.rejects( + transaction.saveInvitation(context(), { ...token(), status: 'REDEEMED', revision: 3 }), + /IAM_INVITATION_REVISION_CONFLICT/, + ); + const siblingContext = createIamTenantContextV1({ + tenantScope: { + scopeType: 'organization', + organizationId: '00000000-0000-4000-8000-000000000399', + }, + actorId: ids.owner, + correlationId: ids.correlation, + idempotencyKey: 'prisma-invitation-sibling', + authorizationEpoch: 1, + }); + assert.equal(siblingContext.accepted, true); + if (!siblingContext.accepted) return; + assert.equal( + await transaction.findInvitationByDigest(siblingContext.value, 'a'.repeat(64)), + undefined, + ); + }); +}); + +void test('[IAM-010] Prisma invitation adapter maps a conditional update race to a conflict', async () => { + const fixture = client({ updateCount: 0 }); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + await transaction.saveInvitation(context(), token()); + await assert.rejects( + transaction.saveInvitation(context(), { ...token(), status: 'REDEEMED', revision: 2 }), + /IAM_INVITATION_REVISION_CONFLICT/, + ); + }); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 49a22a3a..c9f4fa74 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -82,6 +82,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "sa"\."spreadsheet_audit_results"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -125,6 +126,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803010000_iam_session_scope_binding', '20260803020000_bua_project_usage_scope', '20260803030000_iam_membership_scope_uniqueness', + '20260803040000_iam_invitation_tokens', 'migration_lock.toml', ]); const migration = await readFile( @@ -536,4 +538,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo ); assert.match(membershipUniquenessMigration, /COALESCE\("workspace_id"::text, ''\)/u); assert.match(membershipUniquenessMigration, /COALESCE\("project_id"::text, ''\)/u); + const invitationMigration = await readFile( + path.join(migrationsDirectory, '20260803040000_iam_invitation_tokens', 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iam"."invitation_tokens"', + 'CREATE UNIQUE INDEX "invitation_tokens_token_digest_key"', + 'CREATE INDEX "invitation_tokens_membership_status_idx"', + ]) { + assert.match( + invitationMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\\]\\]/g, '\\$&')), + ); + } }); From 5ce9fe37b0f68a54cee789d63ec5ff1811540a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:43:20 +0700 Subject: [PATCH 06/99] docs(iam): record invitation token evidence --- .../iam-010-invitation-token-2026-08-03.md | 31 +++++++++++++++++++ docs/plans/requirement-traceability.json | 15 ++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 docs/operations/iam-010-invitation-token-2026-08-03.md diff --git a/docs/operations/iam-010-invitation-token-2026-08-03.md b/docs/operations/iam-010-invitation-token-2026-08-03.md new file mode 100644 index 00000000..67d4db89 --- /dev/null +++ b/docs/operations/iam-010-invitation-token-2026-08-03.md @@ -0,0 +1,31 @@ +# IAM-010 durable invitation token slice — 2026-08-03 + +## Scope + +This evidence record covers the durable persistence boundary for the IAM-010 invitation-token +slice. It is a partial implementation record, not a release approval or a claim that Plan 020 is +complete. + +## Delivered + +- `2a65756` adds the `iam.invitation_tokens` Prisma model and centrally ordered migration. +- Only the token digest and recipient-email digest are persisted; the raw bearer value is accepted + only by the delivery port and is never returned by the application result or stored in a row. +- `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 Prisma foundation test proves the schema diff and migration inventory include the new table. + +## Verification + +- `corepack pnpm --filter @databreeze/api exec prisma validate --config prisma.config.ts` +- `corepack pnpm --filter @databreeze/api test` — 352 tests passed. +- `corepack pnpm --filter @databreeze/domain test` — 134 tests passed. +- `git diff --check` passed before commit. + +## 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. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index bb04f2d5..ea3c772c 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -8985,7 +8985,9 @@ "priority": "P0", "primaryPlan": "020-identity-audit-entitlements.md", "primaryTask": "Task 1: IAM identity and permissions", - "supportingTasks": [], + "supportingTasks": [ + "IAM-010 durable invitation token persistence slice" + ], "codePaths": [ "packages/domain/src/identity/v1.ts", "packages/domain/src/permissions/v1.ts", @@ -8993,12 +8995,16 @@ "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts", + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", "services/api/test/features/iam/", + "services/api/test/features/iam/prisma-iam-invitation-repository.test.ts", + "services/api/test/prisma-foundation.test.mjs", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], @@ -9283,11 +9289,12 @@ ], "releaseEvidence": [ "requirement-linked-tests", + "docs/operations/iam-010-invitation-token-2026-08-03.md", "security-and-tenant-gate", "release-manager-approval" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" From bf311ec6a6d6b85c51260498d7e14719138744b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:45:56 +0700 Subject: [PATCH 07/99] feat(iam): add keyed invitation crypto adapters --- .../adapter/iam-invitation-crypto.adapter.ts | 46 +++++++++++++++++++ .../iam/iam-invitation-crypto.adapter.test.ts | 44 ++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts create mode 100644 services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts 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 new file mode 100644 index 00000000..1b05f471 --- /dev/null +++ b/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts @@ -0,0 +1,46 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; + +import type { IamInvitationDigestPortV1 } from '../application/invitation.service.js'; + +export type IamInvitationDigestKeyV1 = string | Uint8Array; + +function validKey(key: IamInvitationDigestKeyV1): boolean { + return ( + (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + ); +} + +function boundedInput(value: string): string { + if (value.length === 0 || value.length > 4096 || /\p{Cc}/u.test(value)) + throw new Error('IAM_INVITATION_INPUT_INVALID'); + return value.normalize('NFC'); +} + +/** HMAC keeps invitation digests keyed while domain-separating bearer and recipient material. */ +export class HmacSha256IamInvitationDigestAdapter implements IamInvitationDigestPortV1 { + public constructor(private readonly key: IamInvitationDigestKeyV1) { + if (!validKey(key)) throw new Error('IAM_INVITATION_KEY_INVALID'); + } + + private digest(domain: 'token' | 'email', value: string): string { + return createHmac('sha256', this.key) + .update(`databreeze:iam:invitation:${domain}:v1\u0000${boundedInput(value)}`, 'utf8') + .digest('hex'); + } + + public digestToken(rawToken: string): string { + return this.digest('token', rawToken); + } + + public digestEmail(normalizedEmail: string): string { + return this.digest('email', normalizedEmail); + } +} + +export function randomIamInvitationIdV1(): string { + return randomUUID(); +} + +export function randomIamInvitationTokenV1(): string { + return randomBytes(32).toString('base64url'); +} 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 new file mode 100644 index 00000000..2dc91c3d --- /dev/null +++ b/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + HmacSha256IamInvitationDigestAdapter, + randomIamInvitationIdV1, + randomIamInvitationTokenV1, +} 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 token = digest.digestToken('raw-token-abcdefghijklmnopqrstuvwxyz123456'); + const email = digest.digestEmail('invitee@example.com'); + assert.match(token, /^[a-f0-9]{64}$/u); + assert.match(email, /^[a-f0-9]{64}$/u); + assert.equal(token, digest.digestToken('raw-token-abcdefghijklmnopqrstuvwxyz123456')); + assert.notEqual(token, email); + assert.notEqual( + token, + new HmacSha256IamInvitationDigestAdapter('other-key-v1').digestToken( + 'raw-token-abcdefghijklmnopqrstuvwxyz123456', + ), + ); +}); + +void test('[IAM-010] invitation crypto adapters reject unusable key material', () => { + assert.throws(() => new HmacSha256IamInvitationDigestAdapter(''), /IAM_INVITATION_KEY_INVALID/); + assert.throws( + () => new HmacSha256IamInvitationDigestAdapter(new Uint8Array()), + /IAM_INVITATION_KEY_INVALID/, + ); +}); + +void test('[IAM-010] generated invitation identifiers and tokens are fresh and non-guessable', () => { + const invitationId = randomIamInvitationIdV1(); + const parsed = parseStableIdentifierV1(invitationId); + assert.equal(parsed.accepted, true); + const token = randomIamInvitationTokenV1(); + assert.ok(token.length >= 43); + assert.doesNotMatch(token, /[\u0000-\u001f\u007f]/u); + assert.notEqual(token, randomIamInvitationTokenV1()); +}); From ab4d22c91e9ed7630eb93846619b594f49db24b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:53:21 +0700 Subject: [PATCH 08/99] feat(iam): expose invitation HTTP composition --- packages/i18n/src/catalogs-v1.ts | 18 + packages/i18n/test/catalogs-v1.test.mjs | 6 + services/api/openapi/v1.json | 325 ++++++++++++++++++ .../features/iam/api/invitation.controller.ts | 96 ++++++ .../src/features/iam/api/invitation.dto.ts | 40 +++ .../application/invitation-problem.error.ts | 14 + services/api/src/features/iam/iam.module.ts | 80 +++++ .../platform/http/problem-details.filter.ts | 21 ++ .../iam/invitation-composition.test.ts | 60 ++++ .../iam/invitation-controller.test.ts | 81 +++++ services/api/test/openapi.test.ts | 15 + 11 files changed, 756 insertions(+) create mode 100644 services/api/src/features/iam/api/invitation.controller.ts create mode 100644 services/api/src/features/iam/api/invitation.dto.ts create mode 100644 services/api/src/features/iam/application/invitation-problem.error.ts create mode 100644 services/api/test/features/iam/invitation-composition.test.ts create mode 100644 services/api/test/features/iam/invitation-controller.test.ts diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 550bb866..0b232ccb 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -131,6 +131,14 @@ const vietnameseCatalogV1 = { 'api.error.device_request_rejected': entry('Thiết bị đã từ chối yêu cầu.'), 'api.error.device_revision_conflict': entry('Thiết bị đã thay đổi. Hãy tải lại và thử lại.'), 'api.error.device_scope_denied': entry('Bạn không có quyền truy cập thiết bị này.'), + 'api.error.invitation_request_rejected': entry('Lời mời không hợp lệ hoặc đã bị từ chối.'), + 'api.error.invitation_scope_denied': entry('Bạn không có quyền quản lý lời mời này.'), + 'api.error.invitation_not_found': entry( + 'Không tìm thấy lời mời hoặc bạn không có quyền truy cập.', + ), + 'api.error.invitation_conflict': entry('Lời mời đã thay đổi hoặc đã được sử dụng.'), + 'api.error.invitation_delivery_unavailable': entry('Không thể gửi lời mời lúc này.'), + 'api.error.invitation_unavailable': entry('Dịch vụ lời mời hiện không khả dụng.'), 'retry.now': entry('Thử lại ngay'), 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), @@ -276,6 +284,16 @@ const englishCatalogV1: MessageCatalogV1 = { 'api.error.device_request_rejected': entry('The device rejected the request.'), 'api.error.device_revision_conflict': entry('The device changed. Reload and try again.'), 'api.error.device_scope_denied': entry('You do not have access to this device.'), + 'api.error.invitation_request_rejected': entry('The invitation is invalid or was rejected.'), + 'api.error.invitation_scope_denied': entry( + 'You do not have permission to manage this invitation.', + ), + 'api.error.invitation_not_found': entry('The invitation was not found or is not accessible.'), + 'api.error.invitation_conflict': entry('The invitation changed or has already been used.'), + 'api.error.invitation_delivery_unavailable': entry( + 'The invitation could not be delivered right now.', + ), + 'api.error.invitation_unavailable': entry('The invitation service is temporarily unavailable.'), 'retry.now': entry('Try again now'), 'retry.later': entry('Try again later. Your entered data has been preserved.'), 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index 10a601c9..7ae3fdd4 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -102,6 +102,12 @@ const REQUIRED_KEYS = Object.freeze([ 'api.error.device_request_rejected', 'api.error.device_revision_conflict', 'api.error.device_scope_denied', + 'api.error.invitation_request_rejected', + 'api.error.invitation_scope_denied', + 'api.error.invitation_not_found', + 'api.error.invitation_conflict', + 'api.error.invitation_delivery_unavailable', + 'api.error.invitation_unavailable', 'retry.now', 'retry.later', 'retry.afterSeconds.one', diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 98362cd8..5fdb8199 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2771,6 +2771,298 @@ "tags": ["identity"] } }, + "/v1/invitations": { + "post": { + "operationId": "IamInvitationController.issue", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/IssueInvitationDto" } } + } + }, + "responses": { + "200": { + "description": "Invitation metadata without bearer material.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Deliver a single-use invitation token to an existing principal", + "tags": ["identity"] + } + }, + "/v1/invitations/accept": { + "post": { + "operationId": "IamInvitationController.accept", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AcceptInvitationDto" } } + } + }, + "responses": { + "200": { + "description": "Activated membership metadata.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Redeem a single-use invitation token for the authenticated principal", + "tags": ["identity"] + } + }, "/v1/me/bootstrap": { "get": { "operationId": "IamBootstrapController.bootstrap", @@ -9073,6 +9365,39 @@ }, "required": ["expectedRevision"] }, + "IssueInvitationDto": { + "type": "object", + "properties": { + "membershipId": { "type": "string", "format": "uuid" }, + "recipientEmail": { "type": "string", "format": "email", "maxLength": 254 } + }, + "required": ["membershipId", "recipientEmail"] + }, + "InvitationRejectedResponseDto": { + "type": "object", + "properties": { + "accepted": { "type": "boolean", "enum": [false], "example": false }, + "code": { + "type": "string", + "enum": [ + "INVITATION_REQUEST_REJECTED", + "INVITATION_SCOPE_DENIED", + "INVITATION_NOT_FOUND", + "INVITATION_CONFLICT", + "INVITATION_DELIVERY_UNAVAILABLE", + "INVITATION_UNAVAILABLE" + ] + } + }, + "required": ["accepted", "code"] + }, + "AcceptInvitationDto": { + "type": "object", + "properties": { + "token": { "type": "string", "minLength": 32, "maxLength": 512, "writeOnly": true } + }, + "required": ["token"] + }, "BootstrapUserDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/invitation.controller.ts b/services/api/src/features/iam/api/invitation.controller.ts new file mode 100644 index 00000000..789971e7 --- /dev/null +++ b/services/api/src/features/iam/api/invitation.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post, Req } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + IAM_INVITATION_SERVICE, + type IamInvitationApplicationResultV1, + type IamInvitationService, +} from '../application/invitation.service.js'; +import { InvitationProblemError } from '../application/invitation-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + AcceptInvitationDto, + InvitationRejectedResponseDto, + IssueInvitationDto, +} from './invitation.dto.js'; + +function invitationError(result: IamInvitationApplicationResultV1): TValue { + if (result.accepted) return result.value; + switch (result.code) { + case 'SCOPE_DENIED': + throw new InvitationProblemError('INVITATION_SCOPE_DENIED'); + case 'NOT_FOUND': + throw new InvitationProblemError('INVITATION_NOT_FOUND'); + case 'CONFLICT': + throw new InvitationProblemError('INVITATION_CONFLICT'); + case 'DELIVERY_UNAVAILABLE': + throw new InvitationProblemError('INVITATION_DELIVERY_UNAVAILABLE'); + case 'UNAVAILABLE': + throw new InvitationProblemError('INVITATION_UNAVAILABLE'); + default: + throw new InvitationProblemError('INVITATION_REQUEST_REJECTED'); + } +} + +/** IAM-010: invitation bearer material is accepted only in a write body and never returned. */ +@ApiTags('identity') +@ApiBearerAuth() +@Controller('v1/invitations') +export class IamInvitationController { + public constructor( + @Optional() + @Inject(IAM_INVITATION_SERVICE) + private readonly invitations: IamInvitationService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private requireService(): IamInvitationService { + if (this.invitations === undefined) throw new InvitationProblemError('INVITATION_UNAVAILABLE'); + return this.invitations; + } + + @Post() + @HttpCode(200) + @ApiOperation({ summary: 'Deliver a single-use invitation token to an existing principal' }) + @ApiBody({ type: IssueInvitationDto }) + @ApiOkResponse({ description: 'Invitation metadata without bearer material.' }) + @ApiBadRequestResponse({ type: InvitationRejectedResponseDto }) + @ApiForbiddenResponse({ type: InvitationRejectedResponseDto }) + @ApiNotFoundResponse({ type: InvitationRejectedResponseDto }) + @ApiConflictResponse({ type: InvitationRejectedResponseDto }) + @ApiServiceUnavailableResponse({ type: InvitationRejectedResponseDto }) + async issue(@Req() request: unknown, @Body() input: IssueInvitationDto): Promise { + const context = await this.requestContext.resolve(request); + return invitationError(await this.requireService().issue(context, input)); + } + + @Post('accept') + @HttpCode(200) + @ApiOperation({ summary: 'Redeem a single-use invitation token for the authenticated principal' }) + @ApiBody({ type: AcceptInvitationDto }) + @ApiOkResponse({ description: 'Activated membership metadata.' }) + @ApiBadRequestResponse({ type: InvitationRejectedResponseDto }) + @ApiForbiddenResponse({ type: InvitationRejectedResponseDto }) + @ApiNotFoundResponse({ type: InvitationRejectedResponseDto }) + @ApiConflictResponse({ type: InvitationRejectedResponseDto }) + @ApiServiceUnavailableResponse({ type: InvitationRejectedResponseDto }) + async accept(@Req() request: unknown, @Body() input: AcceptInvitationDto): Promise { + const context = await this.requestContext.resolve(request); + return invitationError(await this.requireService().accept(context, input.token)); + } +} diff --git a/services/api/src/features/iam/api/invitation.dto.ts b/services/api/src/features/iam/api/invitation.dto.ts new file mode 100644 index 00000000..3d3ccaad --- /dev/null +++ b/services/api/src/features/iam/api/invitation.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class IssueInvitationDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + membershipId!: string; + + @ApiProperty({ format: 'email', maxLength: 254 }) + @IsEmail() + @IsString() + @MinLength(3) + @MaxLength(254) + recipientEmail!: string; +} + +export class AcceptInvitationDto { + @ApiProperty({ minLength: 32, maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(32) + @MaxLength(512) + token!: string; +} + +export class InvitationRejectedResponseDto { + @ApiProperty({ enum: [false], example: false }) + accepted!: false; + + @ApiProperty({ + enum: [ + 'INVITATION_REQUEST_REJECTED', + 'INVITATION_SCOPE_DENIED', + 'INVITATION_NOT_FOUND', + 'INVITATION_CONFLICT', + 'INVITATION_DELIVERY_UNAVAILABLE', + 'INVITATION_UNAVAILABLE', + ], + }) + code!: string; +} diff --git a/services/api/src/features/iam/application/invitation-problem.error.ts b/services/api/src/features/iam/application/invitation-problem.error.ts new file mode 100644 index 00000000..50502441 --- /dev/null +++ b/services/api/src/features/iam/application/invitation-problem.error.ts @@ -0,0 +1,14 @@ +export type IamInvitationProblemCodeV1 = + | 'INVITATION_REQUEST_REJECTED' + | 'INVITATION_SCOPE_DENIED' + | 'INVITATION_NOT_FOUND' + | 'INVITATION_CONFLICT' + | 'INVITATION_DELIVERY_UNAVAILABLE' + | 'INVITATION_UNAVAILABLE'; + +export class InvitationProblemError extends Error { + public constructor(readonly code: IamInvitationProblemCodeV1) { + super(code); + this.name = 'InvitationProblemError'; + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 9a12cecd..61d864f3 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -45,6 +45,20 @@ import { } from './application/hierarchy-repository.port.js'; import { IAM_HIERARCHY_SERVICE, IamHierarchyService } from './application/hierarchy.service.js'; import { IAM_MEMBERSHIP_SERVICE, IamMembershipService } from './application/membership.service.js'; +import { + IAM_INVITATION_SERVICE, + IamInvitationService, + type IamInvitationClockV1, + type IamInvitationDeliveryPortV1, + type IamInvitationDigestPortV1, + type IamInvitationIdGeneratorV1, + type IamPrincipalEmailLookupPortV1, + type IamInvitationTokenGeneratorV1, +} from './application/invitation.service.js'; +import { + IAM_INVITATION_REPOSITORY_PORT, + type IamInvitationRepositoryPortV1, +} from './application/invitation-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -67,12 +81,23 @@ import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1, } from './adapter/prisma-iam-repository.adapter.js'; +import { + PrismaIamInvitationRepositoryAdapter, + type IamInvitationDatabaseClientV1, +} from './adapter/prisma-iam-invitation-repository.adapter.js'; +import { + HmacSha256IamInvitationDigestAdapter, + randomIamInvitationIdV1, + randomIamInvitationTokenV1, + type IamInvitationDigestKeyV1, +} from './adapter/iam-invitation-crypto.adapter.js'; import { InMemoryIamHierarchyRepositoryAdapter } from './adapter/in-memory-iam-hierarchy-repository.adapter.js'; import { PrismaIamHierarchyRepositoryAdapter, type IamHierarchyDatabaseClientV1, } from './adapter/prisma-iam-hierarchy-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; +import { IamInvitationController } from './api/invitation.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { PrismaDeviceIdentityRepositoryAdapter, @@ -118,6 +143,16 @@ export interface IamModuleOptions { readonly hierarchyDatabase?: IamHierarchyDatabaseClientV1; readonly hierarchyService?: IamHierarchyService; readonly membershipService?: IamMembershipService; + readonly invitationRepository?: IamInvitationRepositoryPortV1; + readonly invitationDatabase?: IamInvitationDatabaseClientV1; + readonly invitationService?: IamInvitationService; + readonly invitationPrincipalEmails?: IamPrincipalEmailLookupPortV1; + readonly invitationDelivery?: IamInvitationDeliveryPortV1; + readonly invitationDigest?: IamInvitationDigestPortV1; + readonly invitationDigestKey?: IamInvitationDigestKeyV1; + readonly invitationIdGenerator?: IamInvitationIdGeneratorV1; + readonly invitationTokenGenerator?: IamInvitationTokenGeneratorV1; + readonly invitationClock?: IamInvitationClockV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -204,6 +239,32 @@ export class IamModule { const membershipService = options.membershipService ?? (iamRepository === undefined ? undefined : new IamMembershipService(iamRepository)); + const invitationRepository = + options.invitationRepository ?? + (options.invitationDatabase === undefined + ? undefined + : new PrismaIamInvitationRepositoryAdapter(options.invitationDatabase)); + const invitationDigest = + options.invitationDigest ?? + (options.invitationDigestKey === undefined + ? undefined + : new HmacSha256IamInvitationDigestAdapter(options.invitationDigestKey)); + const invitationService = + options.invitationService ?? + (invitationRepository && + options.invitationPrincipalEmails && + options.invitationDelivery && + invitationDigest + ? new IamInvitationService( + invitationRepository, + options.invitationPrincipalEmails, + options.invitationIdGenerator ?? randomIamInvitationIdV1, + options.invitationTokenGenerator ?? randomIamInvitationTokenV1, + invitationDigest, + options.invitationDelivery, + options.invitationClock, + ) + : undefined); const authentication = options.authentication ?? (credentials && sessions @@ -234,6 +295,8 @@ export class IamModule { if (mfaService) exports.unshift(MFA_SERVICE); if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); if (membershipService) exports.unshift(IAM_MEMBERSHIP_SERVICE); + if (invitationRepository) exports.unshift(IAM_INVITATION_REPOSITORY_PORT); + if (invitationService) exports.unshift(IAM_INVITATION_SERVICE); return { module: IamModule, controllers: [ @@ -242,6 +305,7 @@ export class IamModule { MfaController, IamHierarchyController, IamMembershipController, + IamInvitationController, IamBootstrapController, ], providers: [ @@ -321,6 +385,22 @@ export class IamModule { }, ] : []), + ...(invitationRepository + ? [ + { + provide: IAM_INVITATION_REPOSITORY_PORT, + useValue: invitationRepository, + }, + ] + : []), + ...(invitationService + ? [ + { + provide: IAM_INVITATION_SERVICE, + useValue: invitationService, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 9112e266..a9018ea9 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -12,6 +12,7 @@ import { SessionProblemError } from '../../features/iam/application/session-prob import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; +import { InvitationProblemError } from '../../features/iam/application/invitation-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; import { ArtifactExportProblemError } from '../../features/iae/application/artifact-export-problem.error.js'; import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; @@ -106,6 +107,26 @@ function describe(error: unknown, correlationId: string): ProblemInput { status, }; } + if (error instanceof InvitationProblemError) { + const status = + error.code === 'INVITATION_UNAVAILABLE' || error.code === 'INVITATION_DELIVERY_UNAVAILABLE' + ? HttpStatus.SERVICE_UNAVAILABLE + : error.code === 'INVITATION_SCOPE_DENIED' + ? HttpStatus.FORBIDDEN + : error.code === 'INVITATION_NOT_FOUND' + ? HttpStatus.NOT_FOUND + : error.code === 'INVITATION_CONFLICT' + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST; + return { + code: error.code, + correlationId, + messageKey: `api.error.${error.code.toLowerCase()}`, + retryable: + error.code === 'INVITATION_UNAVAILABLE' || error.code === 'INVITATION_DELIVERY_UNAVAILABLE', + status, + }; + } if (error instanceof AuditProblemError) { const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { diff --git a/services/api/test/features/iam/invitation-composition.test.ts b/services/api/test/features/iam/invitation-composition.test.ts new file mode 100644 index 00000000..540b56c5 --- /dev/null +++ b/services/api/test/features/iam/invitation-composition.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IAM_INVITATION_REPOSITORY_PORT } from '../../../src/features/iam/application/invitation-repository.port.js'; +import { + IAM_INVITATION_SERVICE, + IamInvitationService, +} from '../../../src/features/iam/application/invitation.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; +import { PrismaIamInvitationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +void test('[IAM-010] explicitly supplied invitation service is exported by IAM composition', () => { + const service = {} as IamInvitationService; + const registered = IamModule.register({ invitationService: service }); + const configured = provider(registered, IAM_INVITATION_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok( + registered.controllers?.some((controller) => controller.name === 'IamInvitationController'), + ); +}); + +void test('[IAM-010] durable invitation composition requires all secret and delivery ports', () => { + const registered = IamModule.register({ invitationDatabase: {} as never }); + assert.equal(provider(registered, IAM_INVITATION_SERVICE), undefined); + const repository = provider(registered, IAM_INVITATION_REPOSITORY_PORT); + assert.ok(repository && 'useValue' in repository); + if (!repository || !('useValue' in repository)) return; + assert.ok(repository.useValue instanceof PrismaIamInvitationRepositoryAdapter); +}); + +void test('[IAM-010] durable invitation composition selects Prisma persistence when configured', () => { + const registered = IamModule.register({ + invitationDatabase: {} as never, + invitationPrincipalEmails: { findEmail: async () => 'invitee@example.com' }, + invitationDelivery: { deliver: async () => undefined }, + invitationDigest: { + digestToken: () => 'a'.repeat(64), + digestEmail: () => 'b'.repeat(64), + }, + }); + const repository = provider(registered, IAM_INVITATION_REPOSITORY_PORT); + const service = provider(registered, IAM_INVITATION_SERVICE); + assert.ok(repository && 'useValue' in repository); + assert.ok(service && 'useValue' in service); + if (!repository || !('useValue' in repository) || !service || !('useValue' in service)) return; + assert.ok(repository.useValue instanceof PrismaIamInvitationRepositoryAdapter); + assert.ok(service.useValue instanceof IamInvitationService); +}); diff --git a/services/api/test/features/iam/invitation-controller.test.ts b/services/api/test/features/iam/invitation-controller.test.ts new file mode 100644 index 00000000..5032fde5 --- /dev/null +++ b/services/api/test/features/iam/invitation-controller.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IamInvitationController } from '../../../src/features/iam/api/invitation.controller.js'; +import { InvitationProblemError } from '../../../src/features/iam/application/invitation-problem.error.js'; +import type { IamInvitationService } from '../../../src/features/iam/application/invitation.service.js'; + +void test('[IAM-010] invitation controller forwards bounded issue and accept commands without returning bearer material', async () => { + const calls: Array = []; + const service = { + issue: async (...input: unknown[]) => { + calls.push(input); + return { + accepted: true as const, + value: { + invitationId: 'invitation-id', + membershipId: 'membership-id', + expiresAt: '2026-08-10T00:00:00.000Z', + deliveryStatus: 'DELIVERED' as const, + }, + }; + }, + accept: async (...input: unknown[]) => { + calls.push(input); + return { + accepted: true as const, + value: { id: 'membership-id', status: 'ACTIVE' }, + }; + }, + } as unknown as IamInvitationService; + const context = { actorId: 'actor', tenantScope: { scopeType: 'organization' } } as never; + const controller = new IamInvitationController(service, { resolve: async () => context }); + const issued = await controller.issue( + {}, + { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }, + ); + const accepted = await controller.accept( + {}, + { token: 'raw-token-abcdefghijklmnopqrstuvwxyz123456' }, + ); + assert.deepEqual(issued, { + invitationId: 'invitation-id', + membershipId: 'membership-id', + expiresAt: '2026-08-10T00:00:00.000Z', + deliveryStatus: 'DELIVERED', + }); + assert.deepEqual(accepted, { id: 'membership-id', status: 'ACTIVE' }); + assert.equal(calls.length, 2); + assert.equal( + (calls[0]?.[1] as { readonly recipientEmail?: string }).recipientEmail, + 'invitee@example.com', + ); + assert.equal(calls[1]?.[1], 'raw-token-abcdefghijklmnopqrstuvwxyz123456'); +}); + +void test('[IAM-010] invitation controller maps rejected application outcomes to safe problem codes', async () => { + const service = { + issue: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), + accept: async () => ({ accepted: false as const, code: 'INVALID_TOKEN' as const }), + } as unknown as IamInvitationService; + const controller = new IamInvitationController(service, { resolve: async () => ({}) as never }); + await assert.rejects( + controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_SCOPE_DENIED', + ); + await assert.rejects( + controller.accept({}, { token: 'raw-token-abcdefghijklmnopqrstuvwxyz123456' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_REQUEST_REJECTED', + ); +}); + +void test('[IAM-010] invitation controller fails closed when service composition is incomplete', async () => { + const controller = new IamInvitationController(undefined, { resolve: async () => ({}) as never }); + await assert.rejects( + controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_UNAVAILABLE', + ); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 556aa5eb..1a292fca 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -84,6 +84,19 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '#/components/schemas/MembershipRejectedResponseDto', ); } + const invitationResponses = ( + firstDocument.paths['/v1/invitations']?.post as OperationLike | undefined + )?.responses; + for (const status of ['400', '403', '404', '409', '503']) { + assert.equal( + ( + invitationResponses?.[status]?.content?.['application/json'] as + | { readonly schema?: { readonly $ref?: string } } + | undefined + )?.schema?.$ref, + '#/components/schemas/InvitationRejectedResponseDto', + ); + } const paths = Object.keys(firstDocument.paths).sort(); assert.deepEqual(paths, [ @@ -159,6 +172,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/revoke', '/v1/entitlements/snapshots/{snapshotId}', '/v1/entitlements/usage', + '/v1/invitations', + '/v1/invitations/accept', '/v1/me/bootstrap', '/v1/memberships', '/v1/memberships/{membershipId}/accept', From 003f7c94d0863355565e6b6e20d0f056ac0861ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 23:56:30 +0700 Subject: [PATCH 09/99] feat(iam): add durable principal email lookup --- .../prisma-principal-email-lookup.adapter.ts | 35 +++++++++++ .../iam/application/invitation.service.ts | 1 + services/api/src/features/iam/iam.module.ts | 24 +++++++- .../iam/invitation-composition.test.ts | 13 ++++ ...sma-principal-email-lookup.adapter.test.ts | 59 +++++++++++++++++++ 5 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts create mode 100644 services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts new file mode 100644 index 00000000..68c8d3f5 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts @@ -0,0 +1,35 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamPrincipalEmailLookupPortV1 } from '../application/invitation.service.js'; + +export interface IamPrincipalEmailDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; +} + +export interface IamPrincipalEmailDatabaseClientV1 { + readonly userIdentity: { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + }; +} + +/** Reads only the active, normalized email needed by the invitation use case. */ +export class PrismaIamPrincipalEmailLookupAdapter implements IamPrincipalEmailLookupPortV1 { + public constructor(private readonly client: IamPrincipalEmailDatabaseClientV1) {} + + public async findEmail(principalId: StableIdentifierV1): Promise { + const row = await this.client.userIdentity.findUnique({ where: { id: principalId } }); + if (!row || row.status !== 'ACTIVE') return undefined; + const persistedId = parseStableIdentifierV1(row.id); + if (!persistedId.accepted || persistedId.value !== principalId) return undefined; + const email = normalizeEmailAddressV1(row.email); + return email.accepted ? email.value : undefined; + } +} diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts index 3f1894a2..d2cfd819 100644 --- a/services/api/src/features/iam/application/invitation.service.ts +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -22,6 +22,7 @@ import type { import type { IamTenantContextV1 } from './tenant-context.js'; export const IAM_INVITATION_SERVICE = Symbol('IAM_INVITATION_SERVICE'); +export const IAM_PRINCIPAL_EMAIL_LOOKUP_PORT = Symbol('IAM_PRINCIPAL_EMAIL_LOOKUP_PORT'); export type IamInvitationApplicationCodeV1 = | 'INVALID_IDENTIFIER' diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 61d864f3..f89bb693 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -47,6 +47,7 @@ import { IAM_HIERARCHY_SERVICE, IamHierarchyService } from './application/hierar import { IAM_MEMBERSHIP_SERVICE, IamMembershipService } from './application/membership.service.js'; import { IAM_INVITATION_SERVICE, + IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, IamInvitationService, type IamInvitationClockV1, type IamInvitationDeliveryPortV1, @@ -91,6 +92,10 @@ import { randomIamInvitationTokenV1, type IamInvitationDigestKeyV1, } from './adapter/iam-invitation-crypto.adapter.js'; +import { + PrismaIamPrincipalEmailLookupAdapter, + type IamPrincipalEmailDatabaseClientV1, +} from './adapter/prisma-principal-email-lookup.adapter.js'; import { InMemoryIamHierarchyRepositoryAdapter } from './adapter/in-memory-iam-hierarchy-repository.adapter.js'; import { PrismaIamHierarchyRepositoryAdapter, @@ -147,6 +152,7 @@ export interface IamModuleOptions { readonly invitationDatabase?: IamInvitationDatabaseClientV1; readonly invitationService?: IamInvitationService; readonly invitationPrincipalEmails?: IamPrincipalEmailLookupPortV1; + readonly invitationPrincipalEmailDatabase?: IamPrincipalEmailDatabaseClientV1; readonly invitationDelivery?: IamInvitationDeliveryPortV1; readonly invitationDigest?: IamInvitationDigestPortV1; readonly invitationDigestKey?: IamInvitationDigestKeyV1; @@ -249,15 +255,20 @@ export class IamModule { (options.invitationDigestKey === undefined ? undefined : new HmacSha256IamInvitationDigestAdapter(options.invitationDigestKey)); + const invitationPrincipalEmails = + options.invitationPrincipalEmails ?? + (options.invitationPrincipalEmailDatabase === undefined + ? undefined + : new PrismaIamPrincipalEmailLookupAdapter(options.invitationPrincipalEmailDatabase)); const invitationService = options.invitationService ?? (invitationRepository && - options.invitationPrincipalEmails && + invitationPrincipalEmails && options.invitationDelivery && invitationDigest ? new IamInvitationService( invitationRepository, - options.invitationPrincipalEmails, + invitationPrincipalEmails, options.invitationIdGenerator ?? randomIamInvitationIdV1, options.invitationTokenGenerator ?? randomIamInvitationTokenV1, invitationDigest, @@ -297,6 +308,7 @@ export class IamModule { if (membershipService) exports.unshift(IAM_MEMBERSHIP_SERVICE); if (invitationRepository) exports.unshift(IAM_INVITATION_REPOSITORY_PORT); if (invitationService) exports.unshift(IAM_INVITATION_SERVICE); + if (invitationPrincipalEmails) exports.unshift(IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); return { module: IamModule, controllers: [ @@ -401,6 +413,14 @@ export class IamModule { }, ] : []), + ...(invitationPrincipalEmails + ? [ + { + provide: IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, + useValue: invitationPrincipalEmails, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/iam/invitation-composition.test.ts b/services/api/test/features/iam/invitation-composition.test.ts index 540b56c5..10816ee7 100644 --- a/services/api/test/features/iam/invitation-composition.test.ts +++ b/services/api/test/features/iam/invitation-composition.test.ts @@ -5,9 +5,11 @@ import { IAM_INVITATION_REPOSITORY_PORT } from '../../../src/features/iam/applic import { IAM_INVITATION_SERVICE, IamInvitationService, + IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, } from '../../../src/features/iam/application/invitation.service.js'; import { IamModule } from '../../../src/features/iam/iam.module.js'; import { PrismaIamInvitationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; +import { PrismaIamPrincipalEmailLookupAdapter } from '../../../src/features/iam/adapter/prisma-principal-email-lookup.adapter.js'; function provider(module: ReturnType, token: symbol) { return module.providers?.find( @@ -58,3 +60,14 @@ void test('[IAM-010] durable invitation composition selects Prisma persistence w assert.ok(repository.useValue instanceof PrismaIamInvitationRepositoryAdapter); assert.ok(service.useValue instanceof IamInvitationService); }); + +void test('[IAM-010] durable invitation composition can source principal email from IAM', () => { + const registered = IamModule.register({ + invitationPrincipalEmailDatabase: {} as never, + }); + assert.equal(provider(registered, IAM_INVITATION_SERVICE), undefined); + const lookup = provider(registered, IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); + assert.ok(lookup && 'useValue' in lookup); + if (!lookup || !('useValue' in lookup)) return; + assert.ok(lookup.useValue instanceof PrismaIamPrincipalEmailLookupAdapter); +}); diff --git a/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts new file mode 100644 index 00000000..31acb91c --- /dev/null +++ b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaIamPrincipalEmailLookupAdapter, + type IamPrincipalEmailDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-principal-email-lookup.adapter.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const principalId = '00000000-0000-4000-8000-000000000341'; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid principal-email fixture identifier'); + return result.value; +} + +function client(row: IamPrincipalEmailDatabaseRowV1 | null) { + const calls: unknown[] = []; + return { + calls, + userIdentity: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + calls.push(where); + return row; + }, + }, + }; +} + +void test('[IAM-010] Prisma principal email lookup returns only a normalized active identity', async () => { + const database = client({ id: principalId, email: 'Invitee@Example.com', status: 'ACTIVE' }); + const adapter = new PrismaIamPrincipalEmailLookupAdapter(database); + assert.equal(await adapter.findEmail(stable(principalId)), 'invitee@example.com'); + assert.deepEqual(database.calls, [{ id: principalId }]); +}); + +void test('[IAM-010] Prisma principal email lookup fails closed for inactive or malformed rows', async () => { + const inactive = new PrismaIamPrincipalEmailLookupAdapter( + client({ id: principalId, email: 'invitee@example.com', status: 'SUSPENDED' }), + ); + assert.equal(await inactive.findEmail(stable(principalId)), undefined); + const malformed = new PrismaIamPrincipalEmailLookupAdapter( + client({ id: principalId, email: 'not-an-email', status: 'ACTIVE' }), + ); + assert.equal(await malformed.findEmail(stable(principalId)), undefined); +}); + +void test('[IAM-010] Prisma principal email lookup does not accept a row for another principal', async () => { + const adapter = new PrismaIamPrincipalEmailLookupAdapter( + client({ + id: '00000000-0000-4000-8000-000000000342', + email: 'invitee@example.com', + status: 'ACTIVE', + }), + ); + assert.equal(await adapter.findEmail(stable(principalId)), undefined); +}); From b7701a7cdd6fc9271cdef0ccb69b9298d99aec50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:02:53 +0700 Subject: [PATCH 10/99] feat(iam): add atomic account registration service --- ...-memory-registration-repository.adapter.ts | 52 +++++++ .../registration-repository.port.ts | 45 ++++++ .../iam/application/registration.service.ts | 108 ++++++++++++++ .../features/iam/registration.service.test.ts | 138 ++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts create mode 100644 services/api/src/features/iam/application/registration-repository.port.ts create mode 100644 services/api/src/features/iam/application/registration.service.ts create mode 100644 services/api/test/features/iam/registration.service.test.ts diff --git a/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts new file mode 100644 index 00000000..4e648230 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts @@ -0,0 +1,52 @@ +import type { + RegistrationPersistenceInputV1, + RegistrationRepositoryPortV1, + RegistrationTransactionPortV1, +} from '../application/registration-repository.port.js'; +import { RegistrationConflictError } from '../application/registration-repository.port.js'; + +function clone(value: TValue): TValue { + return structuredClone(value); +} + +/** Deterministic transactional registration store for tests and private-alpha composition. */ +export class InMemoryRegistrationRepositoryAdapter implements RegistrationRepositoryPortV1 { + private records = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.records); + const transaction: RegistrationTransactionPortV1 = { + findByEmail: async (email) => this.records.has(email), + save: async (input) => { + if (this.records.has(input.email)) throw new RegistrationConflictError(); + this.records.set(input.email, clone(input)); + }, + }; + try { + return await work(transaction); + } catch (error) { + this.records = before; + throw error; + } finally { + release(); + } + } + + public has(email: string): boolean { + return this.records.has(email); + } + + public get(email: string): RegistrationPersistenceInputV1 | undefined { + const value = this.records.get(email); + return value ? clone(value) : undefined; + } +} diff --git a/services/api/src/features/iam/application/registration-repository.port.ts b/services/api/src/features/iam/application/registration-repository.port.ts new file mode 100644 index 00000000..fe5eeddb --- /dev/null +++ b/services/api/src/features/iam/application/registration-repository.port.ts @@ -0,0 +1,45 @@ +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 interface RegistrationPersistenceInputV1 { + readonly email: string; + readonly credentialId: string; + readonly credential: PasswordCredentialV1; + readonly bootstrap: PersonalOrganizationBootstrapV1; +} + +export interface RegistrationTransactionPortV1 { + /** Exact normalized lookup; callers must not use this to disclose account existence. */ + findByEmail(email: string): Promise; + save(input: RegistrationPersistenceInputV1): Promise; +} + +export interface RegistrationRepositoryPortV1 { + withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise; +} + +/** Adapter-level signal for a concurrent unique-email race. */ +export class RegistrationConflictError extends Error { + public constructor() { + super('IAM_REGISTRATION_CONFLICT'); + this.name = 'RegistrationConflictError'; + } +} + +export type RegistrationFailureCodeV1 = + | 'INVALID_INPUT' + | 'REGISTRATION_REJECTED' + | 'REGISTRATION_UNAVAILABLE'; + +export interface RegistrationValueV1 { + readonly bootstrap: PersonalOrganizationBootstrapV1; + readonly email: string; +} + +export type RegistrationResultV1 = + | { readonly accepted: true; readonly value: RegistrationValueV1 } + | { readonly accepted: false; readonly code: RegistrationFailureCodeV1 }; diff --git a/services/api/src/features/iam/application/registration.service.ts b/services/api/src/features/iam/application/registration.service.ts new file mode 100644 index 00000000..1154faa5 --- /dev/null +++ b/services/api/src/features/iam/application/registration.service.ts @@ -0,0 +1,108 @@ +import { + bootstrapPersonalOrganizationV1, + isBoundedTextV1, + normalizeEmailAddressV1, + type LocaleV1, +} from '@databreeze/domain/identity/v1'; + +import type { PasswordCredentialService } from './password-credential.service.js'; +import { + RegistrationConflictError, + type RegistrationRepositoryPortV1, + type RegistrationResultV1, +} from './registration-repository.port.js'; + +export const IAM_REGISTRATION_SERVICE = Symbol('IAM_REGISTRATION_SERVICE'); + +export interface RegistrationInputV1 { + readonly email: unknown; + readonly displayName: unknown; + readonly password: unknown; + readonly locale?: unknown; +} + +export interface RegistrationClockV1 { + now(): Date; +} + +export interface RegistrationIdGeneratorV1 { + next(): string; +} + +export interface RegistrationServicePortsV1 { + readonly repository: RegistrationRepositoryPortV1; + readonly passwordCredentials: PasswordCredentialService; + readonly ids: RegistrationIdGeneratorV1; + readonly clock?: RegistrationClockV1; +} + +function now(clock: RegistrationClockV1 | undefined): Date { + return clock?.now() ?? new Date(); +} + +function locale(input: unknown): LocaleV1 | undefined { + return input === undefined ? 'vi-VN' : input === 'vi-VN' || input === 'en' ? input : undefined; +} + +/** Creates a user, password credential, and personal owner hierarchy atomically. */ +export class RegistrationService { + public constructor(private readonly ports: RegistrationServicePortsV1) {} + + public async register(input: RegistrationInputV1): Promise { + const email = normalizeEmailAddressV1(input.email); + const selectedLocale = locale(input.locale); + if (!email.accepted || !isBoundedTextV1(input.displayName, 200) || !selectedLocale) + return Object.freeze({ accepted: false, code: 'INVALID_INPUT' as const }); + + // Password hashing intentionally occurs before the uniqueness check so an existing account + // cannot be distinguished by a cheap fast path. The raw password never enters a repository. + const credential = await this.ports.passwordCredentials.create(input.password); + if (!credential.accepted) { + return Object.freeze({ + accepted: false, + code: + credential.code === 'INVALID_PASSWORD' + ? ('INVALID_INPUT' as const) + : ('REGISTRATION_UNAVAILABLE' as const), + }); + } + + const createdAt = now(this.ports.clock).toISOString(); + const bootstrap = bootstrapPersonalOrganizationV1({ + user: { + id: this.ports.ids.next(), + displayName: input.displayName, + locale: selectedLocale, + createdAt, + }, + organizationId: this.ports.ids.next(), + workspaceId: this.ports.ids.next(), + projectId: this.ports.ids.next(), + membershipId: this.ports.ids.next(), + createdAt, + }); + if (!bootstrap.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_INPUT' as const }); + + 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 }); + await transaction.save({ + email: email.value, + credentialId: this.ports.ids.next(), + credential: credential.value, + bootstrap: bootstrap.value, + }); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ bootstrap: bootstrap.value, email: email.value }), + }); + }); + } catch (error) { + if (error instanceof RegistrationConflictError) + return Object.freeze({ accepted: false, code: 'REGISTRATION_REJECTED' as const }); + return Object.freeze({ accepted: false, code: 'REGISTRATION_UNAVAILABLE' as const }); + } + } +} diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts new file mode 100644 index 00000000..cb178f57 --- /dev/null +++ b/services/api/test/features/iam/registration.service.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-registration-repository.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; +import { RegistrationService } from '../../../src/features/iam/application/registration.service.js'; + +function ids() { + let next = 1; + return { + next: () => `00000000-0000-4000-8000-${String(next++).padStart(12, '0')}`, + }; +} + +function passwordCredentials() { + return new PasswordCredentialService({ + hash: async () => ({ + schemaVersion: 1 as const, + algorithm: 'argon2id' as const, + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }), + verify: async () => true, + }); +} + +void test('[IAM-001, IAM-009, IAM-016] registration atomically creates a Vietnamese personal owner hierarchy', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); + const result = await service.register({ + email: 'User@Example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.email, 'user@example.com'); + 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); + assert.equal(repository.has('user@example.com'), true); + assert.equal( + repository.get('user@example.com')?.credential.encodedHash.includes('password'), + false, + ); +}); + +void test('[IAM-001] registration rejects duplicate email without disclosing account state', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.equal( + ( + await service.register({ + email: 'same@example.com', + displayName: 'One', + password: 'valid password here', + }) + ).accepted, + true, + ); + assert.deepEqual( + await service.register({ + email: 'SAME@example.com', + displayName: 'Two', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_REJECTED' }, + ); +}); + +void test('[IAM-001] registration validates input before persistence and maps hash failure safely', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.deepEqual( + await service.register({ + email: 'not-an-email', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'INVALID_INPUT' }, + ); + const unavailable = new RegistrationService({ + repository, + passwordCredentials: new PasswordCredentialService({ + hash: async () => { + throw new Error('hash'); + }, + verify: async () => false, + }), + ids: ids(), + }); + assert.deepEqual( + await unavailable.register({ + email: 'new@example.com', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_UNAVAILABLE' }, + ); +}); + +void test('[IAM-001] registration rolls back when persistence fails after staging', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository: { + withTransaction: async (work) => + work({ + findByEmail: async () => false, + save: async () => { + throw new Error('database unavailable'); + }, + }), + }, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.deepEqual( + await service.register({ + email: 'new@example.com', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_UNAVAILABLE' }, + ); + assert.equal(repository.has('new@example.com'), false); +}); From a6a616809065ec833ee1450020f4eb4b7b14262d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:05:51 +0700 Subject: [PATCH 11/99] feat(iam): persist account registration atomically --- ...a-identity-bootstrap-repository.adapter.ts | 4 +- .../prisma-registration-repository.adapter.ts | 99 ++++++++ .../prisma-registration-repository.test.ts | 223 ++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-registration-repository.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 9bf9437a..ec2308c4 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -265,7 +265,9 @@ function bootstrapFromRows( }); } -class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { +export class PrismaIdentityBootstrapTransactionAdapter + implements IdentityBootstrapTransactionPortV1 +{ public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} public async findByUserId( diff --git a/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts new file mode 100644 index 00000000..5f72e831 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts @@ -0,0 +1,99 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; + +import { + PrismaIdentityBootstrapTransactionAdapter, + type IdentityBootstrapDatabaseClientV1, + type UserIdentityDatabaseRowV1, +} from './prisma-identity-bootstrap-repository.adapter.js'; +import { + RegistrationConflictError, + type RegistrationPersistenceInputV1, + type RegistrationRepositoryPortV1, + type RegistrationTransactionPortV1, +} from '../application/registration-repository.port.js'; + +interface RegistrationUserDelegateV1 { + findUnique(input: { + readonly where: Readonly<{ readonly id?: string; readonly email?: string }>; + }): Promise; + create(input: { + readonly data: Readonly>; + }): Promise; +} + +interface RegistrationCredentialDelegateV1 { + create(input: { readonly data: Readonly> }): Promise; +} + +export interface RegistrationDatabaseClientV1 + extends Omit { + readonly userIdentity: RegistrationUserDelegateV1; + readonly passwordCredential: RegistrationCredentialDelegateV1; + $transaction( + work: (transaction: RegistrationDatabaseClientV1) => Promise, + ): Promise; +} + +function uniqueConflict(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + ('code' in error ? error.code === 'P2002' : error instanceof RegistrationConflictError) + ); +} + +class PrismaRegistrationTransactionAdapter implements RegistrationTransactionPortV1 { + public constructor(private readonly client: RegistrationDatabaseClientV1) {} + + public async findByEmail(emailInput: string): Promise { + const email = normalizeEmailAddressV1(emailInput); + if (!email.accepted) return false; + const row = await this.client.userIdentity.findUnique({ where: { email: email.value } }); + return row?.email === email.value; + } + + public async save(input: RegistrationPersistenceInputV1): Promise { + const email = normalizeEmailAddressV1(input.email); + if (!email.accepted || email.value !== input.email) + throw new Error('IAM_REGISTRATION_INPUT_INVALID'); + try { + await this.client.userIdentity.create({ + data: { + id: input.bootstrap.user.id, + email: input.email, + displayName: input.bootstrap.user.displayName, + locale: input.bootstrap.user.locale, + status: input.bootstrap.user.status, + securityEpoch: input.bootstrap.user.securityEpoch, + createdAt: new Date(input.bootstrap.user.createdAt), + }, + }); + await this.client.passwordCredential.create({ + data: { + id: input.credentialId, + userId: input.bootstrap.user.id, + algorithm: input.credential.algorithm, + encodedHash: input.credential.encodedHash, + createdAt: new Date(input.bootstrap.user.createdAt), + }, + }); + await new PrismaIdentityBootstrapTransactionAdapter(this.client).save(input.bootstrap); + } catch (error) { + if (uniqueConflict(error)) throw new RegistrationConflictError(); + throw error; + } + } +} + +/** PostgreSQL adapter for the atomic account and personal-tenant registration unit. */ +export class PrismaRegistrationRepositoryAdapter implements RegistrationRepositoryPortV1 { + public constructor(private readonly client: RegistrationDatabaseClientV1) {} + + public withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaRegistrationTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/test/features/iam/prisma-registration-repository.test.ts b/services/api/test/features/iam/prisma-registration-repository.test.ts new file mode 100644 index 00000000..71a91d6b --- /dev/null +++ b/services/api/test/features/iam/prisma-registration-repository.test.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { PasswordCredentialV1 } from '../../../src/features/iam/domain/password-credential.js'; +import { + PrismaRegistrationRepositoryAdapter, + type RegistrationDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-registration-repository.adapter.js'; +import { + RegistrationConflictError, + type RegistrationPersistenceInputV1, +} from '../../../src/features/iam/application/registration-repository.port.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const projectId = '00000000-0000-4000-8000-000000000004'; +const membershipId = '00000000-0000-4000-8000-000000000005'; +const credentialId = '00000000-0000-4000-8000-000000000006'; +const createdAt = new Date('2026-08-03T00:00:00.000Z'); + +const input: RegistrationPersistenceInputV1 = { + email: 'user@example.com', + credentialId, + credential: { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }, + bootstrap: { + user: { + schemaVersion: 1, + id: userId as never, + status: 'ACTIVE', + displayName: 'Nguyen An', + locale: 'vi-VN', + securityEpoch: 1, + createdAt: createdAt.toISOString() as never, + }, + organization: { + schemaVersion: 1, + id: organizationId as never, + name: "Nguyen An's DataBreeze", + personal: true, + status: 'ACTIVE', + createdAt: createdAt.toISOString() as never, + }, + workspace: { + schemaVersion: 1, + id: workspaceId as never, + organizationId: organizationId as never, + name: 'Personal workspace', + status: 'ACTIVE', + authorizationEpoch: 1, + createdAt: createdAt.toISOString() as never, + }, + project: { + schemaVersion: 1, + id: projectId as never, + organizationId: organizationId as never, + workspaceId: workspaceId as never, + kind: 'INTERNAL', + name: 'Personal project', + status: 'ACTIVE', + createdAt: createdAt.toISOString() as never, + }, + membership: { + schemaVersion: 1, + id: membershipId as never, + principalType: 'USER', + principalId: userId as never, + scope: { scopeType: 'organization', organizationId: organizationId as never }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + }, +}; + +interface State { + users: Map>; + credentials: Map>; + organizations: Map>; + workspaces: Map>; + projects: Map>; + memberships: Map>; +} + +function cloneState(state: State): State { + return { + users: new Map([...state.users].map(([id, row]) => [id, { ...row }])), + credentials: new Map([...state.credentials].map(([id, row]) => [id, { ...row }])), + organizations: new Map([...state.organizations].map(([id, row]) => [id, { ...row }])), + workspaces: new Map([...state.workspaces].map(([id, row]) => [id, { ...row }])), + projects: new Map([...state.projects].map(([id, row]) => [id, { ...row }])), + memberships: new Map([...state.memberships].map(([id, row]) => [id, { ...row }])), + }; +} + +function createDatabase() { + const state: State = { + users: new Map(), + credentials: new Map(), + organizations: new Map(), + workspaces: new Map(), + projects: new Map(), + memberships: new Map(), + }; + let client!: RegistrationDatabaseClientV1; + const transactionCalls = { value: 0 }; + const makeIdentityDelegate = (records: Map>) => ({ + findUnique: async ({ where }: { readonly where: Record }) => { + const id = where['id']; + if (id) return records.get(id) ?? null; + const email = where['email']; + if (email) return [...records.values()].find((row) => row['email'] === email) ?? null; + return null; + }, + create: async ({ data }: { readonly data: Record }) => { + if (records.has(String(data['id']))) + throw Object.assign(new Error('P2002'), { code: 'P2002' }); + records.set(String(data['id']), data); + return data; + }, + findMany: async ({ where }: { readonly where: Record }) => + [...records.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ), + }); + client = { + userIdentity: makeIdentityDelegate(state.users), + passwordCredential: makeIdentityDelegate(state.credentials), + organizationIdentity: makeIdentityDelegate(state.organizations), + workspaceIdentity: makeIdentityDelegate(state.workspaces), + projectIdentity: makeIdentityDelegate(state.projects), + membershipIdentity: makeIdentityDelegate(state.memberships), + $transaction: async ( + work: (transaction: RegistrationDatabaseClientV1) => Promise, + ) => { + transactionCalls.value += 1; + const before = cloneState(state); + try { + return await work(client); + } catch (error) { + for (const key of Object.keys(state) as (keyof State)[]) { + state[key].clear(); + for (const [id, row] of before[key]) state[key].set(id, row); + } + throw error; + } + }, + } as unknown as RegistrationDatabaseClientV1; + return { client, state, transactionCalls }; +} + +void test('[IAM-001, IAM-009] Prisma registration persists the user, credential, and hierarchy in one transaction', async () => { + const database = createDatabase(); + const adapter = new PrismaRegistrationRepositoryAdapter(database.client); + await adapter.withTransaction((transaction) => transaction.save(input)); + assert.equal(database.transactionCalls.value, 1); + assert.equal(database.state.users.size, 1); + assert.equal(database.state.credentials.size, 1); + assert.equal(database.state.organizations.size, 1); + assert.equal(database.state.workspaces.size, 1); + assert.equal(database.state.projects.size, 1); + assert.equal(database.state.memberships.size, 1); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail(input.email)), + true, + ); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail('other@example.com')), + false, + ); +}); + +void test('[IAM-001] Prisma registration maps an existing normalized email and concurrent unique race to a conflict', async () => { + const database = createDatabase(); + const adapter = new PrismaRegistrationRepositoryAdapter(database.client); + await adapter.withTransaction((transaction) => transaction.save(input)); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail('user@example.com')), + true, + ); + await assert.rejects( + adapter.withTransaction((transaction) => transaction.save(input)), + RegistrationConflictError, + ); +}); + +void test('[IAM-001] Prisma registration rolls back user and credential when hierarchy persistence fails', async () => { + const database = createDatabase(); + let failing!: RegistrationDatabaseClientV1; + failing = { + ...database.client, + projectIdentity: { + ...database.client.projectIdentity, + create: async () => { + throw new Error('project write failed'); + }, + }, + $transaction: async (work) => { + const before = cloneState(database.state); + try { + return await work(failing); + } catch (error) { + for (const key of Object.keys(database.state) as (keyof State)[]) { + database.state[key].clear(); + for (const [id, row] of before[key]) database.state[key].set(id, row); + } + throw error; + } + }, + } as RegistrationDatabaseClientV1; + const adapter = new PrismaRegistrationRepositoryAdapter(failing); + await assert.rejects( + adapter.withTransaction((transaction) => transaction.save(input)), + /project write failed/, + ); + assert.equal(database.state.users.size, 0); + assert.equal(database.state.credentials.size, 0); + assert.equal(database.state.organizations.size, 0); +}); From 4fd6236a17b4fca8249cf030029d375cfc71ef97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:15:40 +0700 Subject: [PATCH 12/99] fix(repo): normalize invitation formatting --- packages/domain/src/invitation/v1.ts | 5 +---- packages/domain/test/invitation-v1.test.mjs | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts index 9c258191..3b1f40c6 100644 --- a/packages/domain/src/invitation/v1.ts +++ b/packages/domain/src/invitation/v1.ts @@ -1,7 +1,4 @@ -import { - INVITATION_MAX_SECONDS_V1, - type InitialRoleIdForIdentityV1, -} from '../identity/v1.js'; +import { INVITATION_MAX_SECONDS_V1, type InitialRoleIdForIdentityV1 } from '../identity/v1.js'; import { parseStableIdentifierV1, parseStrictUtcTimestampV1, diff --git a/packages/domain/test/invitation-v1.test.mjs b/packages/domain/test/invitation-v1.test.mjs index 430e96c1..a9e56825 100644 --- a/packages/domain/test/invitation-v1.test.mjs +++ b/packages/domain/test/invitation-v1.test.mjs @@ -15,9 +15,7 @@ const ids = { }; const scope = { scopeType: 'organization', organizationId: ids.organization }; const issuedAt = '2026-08-03T00:00:00.000Z'; -const expiresAt = new Date( - Date.parse(issuedAt) + INVITATION_MAX_SECONDS_V1 * 1_000, -).toISOString(); +const expiresAt = new Date(Date.parse(issuedAt) + INVITATION_MAX_SECONDS_V1 * 1_000).toISOString(); function input(overrides = {}) { return { @@ -56,7 +54,9 @@ void test('[IAM-010] invitation token cannot exceed seven days or carry invalid assert.deepEqual( createInvitationTokenV1({ ...input(), - expiresAt: new Date(Date.parse(issuedAt) + (INVITATION_MAX_SECONDS_V1 + 1) * 1_000).toISOString(), + expiresAt: new Date( + Date.parse(issuedAt) + (INVITATION_MAX_SECONDS_V1 + 1) * 1_000, + ).toISOString(), }), { accepted: false, code: 'INVALID_LIFETIME' }, ); From a8ec43e34e505b0ce8c4f0c323653f32f9be7de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:15:45 +0700 Subject: [PATCH 13/99] feat(iam): expose account registration endpoint --- .../operations/iam-registration-2026-08-03.md | 26 ++++ docs/plans/requirement-traceability.json | 10 +- packages/i18n/src/catalogs-v1.ts | 6 + packages/i18n/test/catalogs-v1.test.mjs | 2 + services/api/openapi/v1.json | 116 ++++++++++++++++++ .../iam/api/registration.controller.ts | 58 +++++++++ .../src/features/iam/api/registration.dto.ts | 46 +++++++ .../application/registration-problem.error.ts | 10 ++ services/api/src/features/iam/iam.module.ts | 56 ++++++++- .../platform/http/problem-details.filter.ts | 13 ++ .../iam/registration-composition.test.ts | 58 +++++++++ .../iam/registration-controller.test.ts | 78 ++++++++++++ .../features/iam/registration-http.test.ts | 75 +++++++++++ services/api/test/openapi.test.ts | 2 + 14 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 docs/operations/iam-registration-2026-08-03.md create mode 100644 services/api/src/features/iam/api/registration.controller.ts create mode 100644 services/api/src/features/iam/api/registration.dto.ts create mode 100644 services/api/src/features/iam/application/registration-problem.error.ts create mode 100644 services/api/test/features/iam/registration-composition.test.ts create mode 100644 services/api/test/features/iam/registration-controller.test.ts create mode 100644 services/api/test/features/iam/registration-http.test.ts diff --git a/docs/operations/iam-registration-2026-08-03.md b/docs/operations/iam-registration-2026-08-03.md new file mode 100644 index 00000000..68c8028c --- /dev/null +++ b/docs/operations/iam-registration-2026-08-03.md @@ -0,0 +1,26 @@ +# IAM registration slice — 2026-08-03 + +This evidence records the partial account-registration boundary delivered on `feat/iam-registration`. +It does not claim that IAM-001 or the IAM plan is complete. + +## Scope + +- Normalize and validate the email, display name, locale, and password at the application boundary. +- 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. +- Select the Prisma registration adapter only when durable registration storage and the password boundary are configured; otherwise the endpoint fails closed. + +## Evidence + +- Service and in-memory transaction tests: `services/api/test/features/iam/registration.service.test.ts`. +- Durable adapter and rollback tests: `services/api/test/features/iam/prisma-registration-repository.test.ts`. +- Composition and controller tests: `services/api/test/features/iam/registration-composition.test.ts` and `services/api/test/features/iam/registration-controller.test.ts`. +- HTTP and problem-details tests: `services/api/test/features/iam/registration-http.test.ts`. +- OpenAPI route: `services/api/openapi/v1.json` (`POST /v1/auth/register`). +- Bilingual error catalog coverage: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. + +## 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. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index ea3c772c..41cef87e 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -8986,7 +8986,8 @@ "primaryPlan": "020-identity-audit-entitlements.md", "primaryTask": "Task 1: IAM identity and permissions", "supportingTasks": [ - "IAM-010 durable invitation token persistence slice" + "IAM-010 durable invitation token persistence slice", + "IAM registration and personal-tenant persistence slice" ], "codePaths": [ "packages/domain/src/identity/v1.ts", @@ -8996,6 +8997,7 @@ "packages/domain/src/csrf/v1.ts", "services/api/src/features/iam/", "services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts", + "services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts", "services/api/prisma/schema/iam.prisma", "services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql" ], @@ -9004,12 +9006,16 @@ "packages/domain/test/permissions-v1.test.mjs", "services/api/test/features/iam/", "services/api/test/features/iam/prisma-iam-invitation-repository.test.ts", + "services/api/test/features/iam/prisma-registration-repository.test.ts", + "services/api/test/features/iam/registration.service.test.ts", + "services/api/test/features/iam/registration-http.test.ts", "services/api/test/prisma-foundation.test.mjs", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md", + "docs/operations/iam-registration-2026-08-03.md" ], "status": "partial", "coverage": "partial", diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 0b232ccb..9c787f97 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -143,6 +143,8 @@ const vietnameseCatalogV1 = { 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), 'retry.afterSeconds.other': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), + 'api.error.registration_request_rejected': entry('Yêu cầu đăng ký không được chấp nhận.'), + 'api.error.registration_unavailable': entry('Dịch vụ đăng ký hiện không khả dụng.'), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), @@ -298,6 +300,10 @@ const englishCatalogV1: MessageCatalogV1 = { 'retry.later': entry('Try again later. Your entered data has been preserved.'), 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), 'retry.afterSeconds.other': entry('Try again in {seconds} seconds.', { seconds: 'number' }), + 'api.error.registration_request_rejected': entry('The registration request was rejected.'), + 'api.error.registration_unavailable': entry( + 'The registration service is temporarily unavailable.', + ), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index 7ae3fdd4..b5a6064c 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -112,6 +112,8 @@ const REQUIRED_KEYS = Object.freeze([ 'retry.later', 'retry.afterSeconds.one', 'retry.afterSeconds.other', + 'api.error.registration_request_rejected', + 'api.error.registration_unavailable', 'module.folderAutopilot', 'module.spreadsheetAuditor', 'module.quoteIntelligence', diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 5fdb8199..978fa599 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3063,6 +3063,93 @@ "tags": ["identity"] } }, + "/v1/auth/register": { + "post": { + "description": "Registration does not return bearer material; sign in separately after creation.", + "operationId": "RegistrationController.register", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/RegistrationDto" } } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegistrationResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The registration request was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Registration persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Create an account and personal organization hierarchy", + "tags": ["auth"] + } + }, "/v1/me/bootstrap": { "get": { "operationId": "IamBootstrapController.bootstrap", @@ -9398,6 +9485,35 @@ }, "required": ["token"] }, + "RegistrationDto": { + "type": "object", + "properties": { + "email": { "type": "string", "example": "ngu***@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" } + }, + "required": ["email", "displayName", "password"] + }, + "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" + ] + }, "BootstrapUserDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts new file mode 100644 index 00000000..2f6e39cf --- /dev/null +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBody, + ApiCreatedResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + IAM_REGISTRATION_SERVICE, + type RegistrationService, +} from '../application/registration.service.js'; +import { RegistrationProblemError } from '../application/registration-problem.error.js'; +import { RegistrationDto, RegistrationResponseDto } from './registration.dto.js'; + +/** IAM-001/IAM-009: account registration creates a safe personal hierarchy without a session. */ +@ApiTags('auth') +@Controller('v1/auth') +export class RegistrationController { + public constructor( + @Optional() + @Inject(IAM_REGISTRATION_SERVICE) + private readonly registration: RegistrationService | undefined, + ) {} + + @Post('register') + @HttpCode(201) + @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 }) + @ApiBadRequestResponse({ description: 'The registration request was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Registration persistence is unavailable.' }) + async register(@Body() input: RegistrationDto): Promise { + if (this.registration === undefined) + throw new RegistrationProblemError('REGISTRATION_UNAVAILABLE'); + const result = await this.registration.register(input); + if (!result.accepted) { + throw new RegistrationProblemError( + result.code === 'REGISTRATION_UNAVAILABLE' + ? 'REGISTRATION_UNAVAILABLE' + : '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, + }; + } +} diff --git a/services/api/src/features/iam/api/registration.dto.ts b/services/api/src/features/iam/api/registration.dto.ts new file mode 100644 index 00000000..1063c7ba --- /dev/null +++ b/services/api/src/features/iam/api/registration.dto.ts @@ -0,0 +1,46 @@ +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 }) + @IsEmail() + @MaxLength(254) + email!: string; + + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + displayName!: string; + + @ApiProperty({ minLength: 12, maxLength: 128, writeOnly: true }) + @IsString() + @MinLength(12) + @MaxLength(128) + password!: string; + + @ApiPropertyOptional({ enum: ['vi-VN', 'en'], default: 'vi-VN' }) + @IsOptional() + @IsIn(['vi-VN', 'en']) + locale?: 'vi-VN' | 'en'; +} + +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'; +} diff --git a/services/api/src/features/iam/application/registration-problem.error.ts b/services/api/src/features/iam/application/registration-problem.error.ts new file mode 100644 index 00000000..697ece10 --- /dev/null +++ b/services/api/src/features/iam/application/registration-problem.error.ts @@ -0,0 +1,10 @@ +export type RegistrationProblemCodeV1 = + | 'REGISTRATION_REQUEST_REJECTED' + | 'REGISTRATION_UNAVAILABLE'; + +export class RegistrationProblemError extends Error { + public constructor(readonly code: RegistrationProblemCodeV1) { + super(code); + this.name = 'RegistrationProblemError'; + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index f89bb693..c2d4a32a 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; @@ -96,6 +96,20 @@ import { PrismaIamPrincipalEmailLookupAdapter, type IamPrincipalEmailDatabaseClientV1, } from './adapter/prisma-principal-email-lookup.adapter.js'; +import { + PrismaRegistrationRepositoryAdapter, + type RegistrationDatabaseClientV1, +} from './adapter/prisma-registration-repository.adapter.js'; +import { + IAM_REGISTRATION_REPOSITORY_PORT, + type RegistrationRepositoryPortV1, +} from './application/registration-repository.port.js'; +import { + IAM_REGISTRATION_SERVICE, + RegistrationService, + type RegistrationClockV1, + type RegistrationIdGeneratorV1, +} from './application/registration.service.js'; import { InMemoryIamHierarchyRepositoryAdapter } from './adapter/in-memory-iam-hierarchy-repository.adapter.js'; import { PrismaIamHierarchyRepositoryAdapter, @@ -103,6 +117,7 @@ import { } from './adapter/prisma-iam-hierarchy-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { IamInvitationController } from './api/invitation.controller.js'; +import { RegistrationController } from './api/registration.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { PrismaDeviceIdentityRepositoryAdapter, @@ -159,6 +174,11 @@ export interface IamModuleOptions { readonly invitationIdGenerator?: IamInvitationIdGeneratorV1; readonly invitationTokenGenerator?: IamInvitationTokenGeneratorV1; readonly invitationClock?: IamInvitationClockV1; + readonly registrationRepository?: RegistrationRepositoryPortV1; + readonly registrationDatabase?: RegistrationDatabaseClientV1; + readonly registrationService?: RegistrationService; + readonly registrationIdGenerator?: RegistrationIdGeneratorV1; + readonly registrationClock?: RegistrationClockV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -276,6 +296,21 @@ export class IamModule { options.invitationClock, ) : undefined); + const registrationRepository = + options.registrationRepository ?? + (options.registrationDatabase === undefined + ? undefined + : new PrismaRegistrationRepositoryAdapter(options.registrationDatabase)); + const registrationService = + options.registrationService ?? + (registrationRepository && options.passwordCredentials + ? new RegistrationService({ + repository: registrationRepository, + passwordCredentials: options.passwordCredentials, + ids: options.registrationIdGenerator ?? { next: () => randomUUID() }, + ...(options.registrationClock ? { clock: options.registrationClock } : {}), + }) + : undefined); const authentication = options.authentication ?? (credentials && sessions @@ -309,6 +344,8 @@ export class IamModule { if (invitationRepository) exports.unshift(IAM_INVITATION_REPOSITORY_PORT); if (invitationService) exports.unshift(IAM_INVITATION_SERVICE); if (invitationPrincipalEmails) exports.unshift(IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); + if (registrationRepository) exports.unshift(IAM_REGISTRATION_REPOSITORY_PORT); + if (registrationService) exports.unshift(IAM_REGISTRATION_SERVICE); return { module: IamModule, controllers: [ @@ -318,6 +355,7 @@ export class IamModule { IamHierarchyController, IamMembershipController, IamInvitationController, + RegistrationController, IamBootstrapController, ], providers: [ @@ -421,6 +459,22 @@ export class IamModule { }, ] : []), + ...(registrationRepository + ? [ + { + provide: IAM_REGISTRATION_REPOSITORY_PORT, + useValue: registrationRepository, + }, + ] + : []), + ...(registrationService + ? [ + { + provide: IAM_REGISTRATION_SERVICE, + useValue: registrationService, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index a9018ea9..48b1300f 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -13,6 +13,7 @@ import { MfaProblemError } from '../../features/iam/application/mfa-problem.erro import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; import { InvitationProblemError } from '../../features/iam/application/invitation-problem.error.js'; +import { RegistrationProblemError } from '../../features/iam/application/registration-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; import { ArtifactExportProblemError } from '../../features/iae/application/artifact-export-problem.error.js'; import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; @@ -127,6 +128,18 @@ function describe(error: unknown, correlationId: string): ProblemInput { status, }; } + if (error instanceof RegistrationProblemError) { + const unavailable = error.code === 'REGISTRATION_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.registration_unavailable' + : 'api.error.registration_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof AuditProblemError) { const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts new file mode 100644 index 00000000..1330117c --- /dev/null +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PrismaRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-registration-repository.adapter.js'; +import { IAM_REGISTRATION_REPOSITORY_PORT } from '../../../src/features/iam/application/registration-repository.port.js'; +import { + IAM_REGISTRATION_SERVICE, + RegistrationService, +} 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'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +const passwordCredentials = new PasswordCredentialService({ + hash: async () => ({ + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }), + verify: async () => true, +}); + +void test('[IAM-001] registration composition exports an explicitly supplied service and controller', () => { + const service = {} as RegistrationService; + const registered = IamModule.register({ registrationService: service }); + const configured = provider(registered, IAM_REGISTRATION_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok( + registered.controllers?.some((controller) => controller.name === 'RegistrationController'), + ); +}); + +void test('[IAM-001] durable registration requires password credentials before composing the service', () => { + const withoutPassword = IamModule.register({ registrationDatabase: {} as never }); + assert.equal(provider(withoutPassword, IAM_REGISTRATION_SERVICE), undefined); + const configured = IamModule.register({ + registrationDatabase: {} as never, + passwordCredentials, + }); + const repository = provider(configured, IAM_REGISTRATION_REPOSITORY_PORT); + const service = provider(configured, IAM_REGISTRATION_SERVICE); + assert.ok(repository && 'useValue' in repository); + assert.ok(service && 'useValue' in service); + if (!repository || !('useValue' in repository) || !service || !('useValue' in service)) return; + assert.ok(repository.useValue instanceof PrismaRegistrationRepositoryAdapter); + assert.ok(service.useValue instanceof RegistrationService); +}); diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts new file mode 100644 index 00000000..2e4ea537 --- /dev/null +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +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'; + +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 () => { + const controller = new RegistrationController({ + register: async () => ({ accepted: true as const, value }), + } as unknown as RegistrationService); + const response = await controller.register({ + email: 'user@example.com', + 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.equal('email' in response, false); + assert.equal('accessToken' in response, false); +}); + +void test('[IAM-001] registration controller maps rejected and unavailable outcomes to stable problems', async () => { + const rejected = new RegistrationController({ + register: async () => ({ accepted: false as const, code: 'REGISTRATION_REJECTED' as const }), + } as unknown as RegistrationService); + await assert.rejects( + rejected.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_REQUEST_REJECTED', + ); + const unavailable = new RegistrationController({ + register: async () => ({ accepted: false as const, code: 'REGISTRATION_UNAVAILABLE' as const }), + } as unknown as RegistrationService); + await assert.rejects( + unavailable.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_UNAVAILABLE', + ); +}); + +void test('[IAM-001] registration controller fails closed when registration is not composed', async () => { + const controller = new RegistrationController(undefined); + await assert.rejects( + controller.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/iam/registration-http.test.ts b/services/api/test/features/iam/registration-http.test.ts new file mode 100644 index 00000000..292dbd19 --- /dev/null +++ b/services/api/test/features/iam/registration-http.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.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'; + +function credentials() { + return new PasswordCredentialService({ + hash: async () => ({ + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }), + verify: async () => true, + }); +} + +void test('[IAM-001, IAM-009, IAM-016] registration HTTP creates a personal hierarchy and rejects a duplicate generically', async () => { + const { app } = await createApiApplication({ + registrationRepository: new InMemoryRegistrationRepositoryAdapter(), + passwordCredentials: credentials(), + }); + try { + const first = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'User@example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }, + }); + assert.equal(first.statusCode, 201); + const body = first.json>(); + assert.match(String(body['userId']), /^[0-9a-f-]{36}$/u); + assert.equal(body['locale'], 'vi-VN'); + assert.equal('accessToken' in body, false); + assert.equal('email' in body, false); + + const duplicate = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'user@example.com', + displayName: 'Different', + 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'); + } finally { + await app.close(); + } +}); + +void test('[IAM-001] registration HTTP fails closed when durable registration is not configured', async () => { + const { app } = await createApiApplication(); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'user@example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }, + }); + assert.equal(response.statusCode, 503); + assert.equal(response.json<{ code: string }>().code, 'REGISTRATION_UNAVAILABLE'); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 1a292fca..596e4e73 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -131,6 +131,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', '/v1/auth/refresh', + '/v1/auth/register', '/v1/auth/sign-in', '/v1/auth/sign-out', '/v1/data-mode-policies', @@ -293,6 +294,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, 'GET /v1/system/compatibility', 'POST /v1/system/compatibility/check', 'POST /v1/auth/sign-in', + 'POST /v1/auth/register', 'POST /v1/auth/refresh', ]); for (const [path, pathItem] of Object.entries(firstDocument.paths) as Array< From 26dd0eec63b8e1712151798ebc77226e53f05796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:18:22 +0700 Subject: [PATCH 14/99] feat(iam): add bounded recovery challenge invariants --- packages/domain/package.json | 4 + packages/domain/src/recovery/v1.ts | 146 ++++++++++++++++++ .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 1 + packages/domain/test/recovery-v1.test.mjs | 86 +++++++++++ 5 files changed, 240 insertions(+) create mode 100644 packages/domain/src/recovery/v1.ts create mode 100644 packages/domain/test/recovery-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 5a3c21fb..49eb5c06 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -40,6 +40,10 @@ "types": "./src/invitation/v1.ts", "import": "./dist/invitation/v1.js" }, + "./recovery/v1": { + "types": "./src/recovery/v1.ts", + "import": "./dist/recovery/v1.js" + }, "./device-authorization/v1": { "types": "./src/device-authorization/v1.ts", "import": "./dist/device-authorization/v1.js" diff --git a/packages/domain/src/recovery/v1.ts b/packages/domain/src/recovery/v1.ts new file mode 100644 index 00000000..99d8f188 --- /dev/null +++ b/packages/domain/src/recovery/v1.ts @@ -0,0 +1,146 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '../tenant-scope/v1.js'; + +/** IAM-015: short-lived recovery bearers are hashed and single-use at rest. */ +export const RECOVERY_CHALLENGE_SCHEMA_VERSION_V1 = 1 as const; +export const RECOVERY_CHALLENGE_MAX_SECONDS_V1 = 60 * 60; + +export type RecoveryChallengeStatusV1 = 'ACTIVE' | 'CONSUMED' | 'REVOKED'; + +export interface RecoveryChallengeV1 { + readonly schemaVersion: typeof RECOVERY_CHALLENGE_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly userId: StableIdentifierV1; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: string; + readonly expiresAt: string; + readonly status: RecoveryChallengeStatusV1; + readonly consumedAt?: string; + readonly revokedAt?: string; + readonly revision: number; +} + +export type RecoveryChallengeErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_DIGEST' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_STATE' + | 'ALREADY_TERMINAL' + | 'EXPIRED'; + +export type RecoveryChallengeResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: RecoveryChallengeErrorCodeV1 }; + +function accepted(value: TValue): RecoveryChallengeResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: RecoveryChallengeErrorCodeV1): RecoveryChallengeResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): string | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function revision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +export function createRecoveryChallengeV1(input: { + readonly id: unknown; + readonly userId: unknown; + readonly tokenDigest: unknown; + readonly emailDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly revision?: unknown; +}): RecoveryChallengeResultV1 { + const id = stable(input.id); + const userId = stable(input.userId); + const tokenDigest = digest(input.tokenDigest); + const emailDigest = digest(input.emailDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const currentRevision = input.revision === undefined ? 1 : revision(input.revision); + if (!id || !userId) return rejected('INVALID_IDENTIFIER'); + if (!tokenDigest || !emailDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + if (!currentRevision) return rejected('INVALID_STATE'); + const duration = Date.parse(expiresAt) - Date.parse(issuedAt); + if ( + !Number.isFinite(duration) || + duration <= 0 || + duration > RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000 + ) + return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: RECOVERY_CHALLENGE_SCHEMA_VERSION_V1, + id, + userId, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, + status: 'ACTIVE' as const, + revision: currentRevision, + }), + ); +} + +export function consumeRecoveryChallengeV1( + challenge: RecoveryChallengeV1, + at: unknown, +): RecoveryChallengeResultV1 { + const current = timestamp(at); + if (!current) return rejected('INVALID_TIMESTAMP'); + if (challenge.status !== 'ACTIVE') return rejected('ALREADY_TERMINAL'); + const now = Date.parse(current); + const issued = Date.parse(challenge.issuedAt); + const expires = Date.parse(challenge.expiresAt); + if (!Number.isFinite(now) || now < issued) return rejected('INVALID_TIMESTAMP'); + if (now >= expires) return rejected('EXPIRED'); + return accepted( + Object.freeze({ + ...challenge, + status: 'CONSUMED' as const, + consumedAt: current, + revision: challenge.revision + 1, + }), + ); +} + +export function revokeRecoveryChallengeV1( + challenge: RecoveryChallengeV1, + at: unknown, +): RecoveryChallengeResultV1 { + const current = timestamp(at); + if (!current) return rejected('INVALID_TIMESTAMP'); + if (challenge.status !== 'ACTIVE') return rejected('ALREADY_TERMINAL'); + return accepted( + Object.freeze({ + ...challenge, + status: 'REVOKED' as const, + revokedAt: current, + revision: challenge.revision + 1, + }), + ); +} diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 06821858..dfa8168c 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -30,6 +30,7 @@ const [ mapping, ruleSet, evidenceGrant, + recovery, ] = await Promise.all([ import('@databreeze/domain/v1'), import('@databreeze/domain/permissions/v1'), @@ -60,6 +61,7 @@ const [ import('@databreeze/domain/mapping/v1'), import('@databreeze/domain/rule-set/v1'), import('@databreeze/domain/evidence-grant/v1'), + import('@databreeze/domain/recovery/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -94,4 +96,5 @@ assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); +assert.equal(recovery.RECOVERY_CHALLENGE_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 014648b2..81414390 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -18,6 +18,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './entitlements/v1', './mfa/v1', './invitation/v1', + './recovery/v1', './device-authorization/v1', './device-sync/v1', './device-capability/v1', diff --git a/packages/domain/test/recovery-v1.test.mjs b/packages/domain/test/recovery-v1.test.mjs new file mode 100644 index 00000000..4ecf05fa --- /dev/null +++ b/packages/domain/test/recovery-v1.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + RECOVERY_CHALLENGE_MAX_SECONDS_V1, + consumeRecoveryChallengeV1, + createRecoveryChallengeV1, + revokeRecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; + +const ids = { + challenge: '00000000-0000-4000-8000-000000000001', + user: '00000000-0000-4000-8000-000000000002', +}; +const issuedAt = '2026-08-03T00:00:00.000Z'; +const expiresAt = new Date( + Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, +).toISOString(); + +function input(overrides = {}) { + return { + id: ids.challenge, + userId: ids.user, + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt, + expiresAt, + ...overrides, + }; +} + +void test('[IAM-015] recovery challenge stores only bounded digests and expires within one hour', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.status, 'ACTIVE'); + assert.equal('rawToken' in created.value, false); + assert.equal(created.value.revision, 1); + assert.deepEqual(createRecoveryChallengeV1(input({ tokenDigest: 'raw-token' })), { + accepted: false, + code: 'INVALID_DIGEST', + }); + assert.deepEqual( + createRecoveryChallengeV1({ + ...input(), + expiresAt: new Date( + Date.parse(issuedAt) + (RECOVERY_CHALLENGE_MAX_SECONDS_V1 + 1) * 1_000, + ).toISOString(), + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-015] recovery challenge consumption is single-use, time-bounded, and revisioned', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const consumed = consumeRecoveryChallengeV1(created.value, '2026-08-03T00:30:00.000Z'); + assert.equal(consumed.accepted, true); + if (!consumed.accepted) return; + assert.equal(consumed.value.status, 'CONSUMED'); + assert.equal(consumed.value.revision, 2); + assert.deepEqual(consumeRecoveryChallengeV1(consumed.value, '2026-08-03T00:31:00.000Z'), { + accepted: false, + code: 'ALREADY_TERMINAL', + }); + assert.deepEqual(consumeRecoveryChallengeV1(created.value, expiresAt), { + accepted: false, + code: 'EXPIRED', + }); +}); + +void test('[IAM-015] recovery challenge revocation is terminal and does not return bearer data', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const revoked = revokeRecoveryChallengeV1(created.value, '2026-08-03T00:01:00.000Z'); + assert.equal(revoked.accepted, true); + if (!revoked.accepted) return; + assert.equal(revoked.value.status, 'REVOKED'); + assert.equal(revoked.value.revokedAt, '2026-08-03T00:01:00.000Z'); + assert.deepEqual(revokeRecoveryChallengeV1(revoked.value, '2026-08-03T00:02:00.000Z'), { + accepted: false, + code: 'ALREADY_TERMINAL', + }); +}); From 371ccdffc845fec09edf7a3bd429aa4daf35df0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:22:15 +0700 Subject: [PATCH 15/99] feat(iam): add recovery service and transactional state effects --- .../in-memory-recovery-repository.adapter.ts | 115 ++++++++++ .../application/recovery-repository.port.ts | 56 +++++ .../iam/application/recovery.service.ts | 207 ++++++++++++++++++ .../features/iam/recovery.service.test.ts | 101 +++++++++ 4 files changed, 479 insertions(+) create mode 100644 services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts create mode 100644 services/api/src/features/iam/application/recovery-repository.port.ts create mode 100644 services/api/src/features/iam/application/recovery.service.ts create mode 100644 services/api/test/features/iam/recovery.service.test.ts 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 new file mode 100644 index 00000000..7a30a11c --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts @@ -0,0 +1,115 @@ +import type { RecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { + RecoveryCompletionInputV1, + RecoveryRepositoryPortV1, + RecoveryTransactionPortV1, +} from '../application/recovery-repository.port.js'; + +interface RecoveryAccountV1 { + readonly email: string; + readonly userId: string; + credentialId?: string; + credentialHash?: string; + securityEpoch: number; + mfaReenrollmentRequired: boolean; + activeSessionFamilies: Set; +} + +function cloneChallenge(value: RecoveryChallengeV1): RecoveryChallengeV1 { + return Object.freeze({ ...value }); +} + +/** In-memory recovery adapter that models credential, epoch, MFA, and session-family effects. */ +export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPortV1 { + private accounts = new Map(); + private challenges = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public seed(input: { + readonly email: string; + readonly userId: string; + readonly securityEpoch?: number; + readonly activeSessionFamilies?: readonly string[]; + }): void { + this.accounts.set(input.email, { + email: input.email, + userId: input.userId, + securityEpoch: input.securityEpoch ?? 1, + mfaReenrollmentRequired: false, + activeSessionFamilies: new Set(input.activeSessionFamilies ?? []), + }); + } + + public account(userId: string): Readonly | undefined { + const account = [...this.accounts.values()].find((candidate) => candidate.userId === userId); + return account + ? Object.freeze({ ...account, activeSessionFamilies: new Set(account.activeSessionFamilies) }) + : undefined; + } + + public challenge(tokenDigest: string): RecoveryChallengeV1 | undefined { + const challenge = this.challenges.get(tokenDigest); + return challenge ? cloneChallenge(challenge) : undefined; + } + + public async withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const beforeAccounts = new Map( + [...this.accounts].map(([key, value]) => [ + key, + { ...value, activeSessionFamilies: new Set(value.activeSessionFamilies) }, + ]), + ); + const beforeChallenges = new Map(this.challenges); + const transaction: RecoveryTransactionPortV1 = { + findUserIdByEmail: async (email) => + this.accounts.get(email)?.userId as StableIdentifierV1 | undefined, + findChallengeByTokenDigest: async (tokenDigest) => { + const challenge = this.challenges.get(tokenDigest); + return challenge ? cloneChallenge(challenge) : undefined; + }, + findActiveChallengeForUser: async (userId) => + [...this.challenges.values()].find( + (challenge) => challenge.userId === userId && challenge.status === 'ACTIVE', + ), + saveChallenge: async (challenge) => { + const existing = this.challenges.get(challenge.tokenDigest); + if (existing && existing.revision + 1 !== challenge.revision) + throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + this.challenges.set(challenge.tokenDigest, cloneChallenge(challenge)); + }, + completeRecovery: async (input: RecoveryCompletionInputV1) => { + const account = [...this.accounts.values()].find( + (candidate) => candidate.userId === input.challenge.userId, + ); + if (!account) throw new Error('IAM_RECOVERY_USER_NOT_FOUND'); + this.accounts.set(account.email, { + ...account, + credentialId: input.credentialId, + credentialHash: input.credential.encodedHash, + securityEpoch: account.securityEpoch + 1, + mfaReenrollmentRequired: true, + activeSessionFamilies: new Set(), + }); + this.challenges.set(input.challenge.tokenDigest, cloneChallenge(input.challenge)); + }, + }; + try { + return await work(transaction); + } catch (error) { + this.accounts = beforeAccounts; + this.challenges = beforeChallenges; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iam/application/recovery-repository.port.ts b/services/api/src/features/iam/application/recovery-repository.port.ts new file mode 100644 index 00000000..3f9fe92a --- /dev/null +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -0,0 +1,56 @@ +import type { RecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { PasswordCredentialV1 } from '../domain/password-credential.js'; + +export const IAM_RECOVERY_REPOSITORY_PORT = Symbol('IAM_RECOVERY_REPOSITORY_PORT'); + +export interface RecoveryCompletionInputV1 { + readonly challenge: RecoveryChallengeV1; + readonly credentialId: StableIdentifierV1; + readonly credential: PasswordCredentialV1; +} + +export interface RecoveryTransactionPortV1 { + findUserIdByEmail(email: string): Promise; + findChallengeByTokenDigest(tokenDigest: string): Promise; + findActiveChallengeForUser(userId: StableIdentifierV1): Promise; + saveChallenge(challenge: RecoveryChallengeV1): Promise; + completeRecovery(input: RecoveryCompletionInputV1): Promise; +} + +export interface RecoveryRepositoryPortV1 { + withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise; +} + +export interface RecoveryDigestPortV1 { + digestToken(rawToken: string): string; + digestEmail(normalizedEmail: string): string; +} + +export interface RecoveryDeliveryPortV1 { + deliver(input: { + readonly challengeId: StableIdentifierV1; + readonly recipientEmail: string; + readonly rawToken: string; + readonly expiresAt: string; + }): Promise; +} + +export type RecoveryFailureCodeV1 = 'INVALID_INPUT' | 'INVALID_TOKEN' | 'RECOVERY_UNAVAILABLE'; + +export type RecoveryRequestResultV1 = + | { readonly accepted: true; readonly value: { readonly requested: true } } + | { readonly accepted: false; readonly code: RecoveryFailureCodeV1 }; + +export type RecoveryCompletionResultV1 = + | { + readonly accepted: true; + readonly value: { + readonly userId: StableIdentifierV1; + readonly mfaReenrollmentRequired: true; + }; + } + | { readonly accepted: false; readonly code: RecoveryFailureCodeV1 }; diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts new file mode 100644 index 00000000..b4172e0a --- /dev/null +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -0,0 +1,207 @@ +import { + consumeRecoveryChallengeV1, + createRecoveryChallengeV1, + RECOVERY_CHALLENGE_MAX_SECONDS_V1, + type RecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { PasswordCredentialService } from './password-credential.service.js'; +import type { + RecoveryCompletionResultV1, + RecoveryDeliveryPortV1, + RecoveryDigestPortV1, + RecoveryFailureCodeV1, + RecoveryRepositoryPortV1, + RecoveryRequestResultV1, +} from './recovery-repository.port.js'; + +export const IAM_RECOVERY_SERVICE = Symbol('IAM_RECOVERY_SERVICE'); + +export interface RecoveryIdGeneratorV1 { + next(): string; +} + +export interface RecoveryTokenGeneratorV1 { + next(): string; +} + +export interface RecoveryClockV1 { + now(): Date; +} + +export interface RecoveryServicePortsV1 { + readonly repository: RecoveryRepositoryPortV1; + readonly passwordCredentials: PasswordCredentialService; + readonly digest: RecoveryDigestPortV1; + readonly delivery: RecoveryDeliveryPortV1; + readonly ids: RecoveryIdGeneratorV1; + readonly tokens: RecoveryTokenGeneratorV1; + readonly clock?: RecoveryClockV1; +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function rawToken(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length < 32 || input.length > 512) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + return input; +} + +function timestamp(clock: RecoveryClockV1 | undefined): string | undefined { + try { + const value = clock?.now() ?? new Date(); + return value instanceof Date && Number.isFinite(value.getTime()) + ? value.toISOString() + : undefined; + } catch { + return undefined; + } +} + +function unavailable(): { readonly accepted: false; readonly code: 'RECOVERY_UNAVAILABLE' } { + return Object.freeze({ accepted: false, code: 'RECOVERY_UNAVAILABLE' as const }); +} + +function inputRejected(code: RecoveryFailureCodeV1): { + readonly accepted: false; + readonly code: RecoveryFailureCodeV1; +} { + return Object.freeze({ accepted: false, code }); +} + +/** Public recovery flow: generic requests, hashed single-use tokens, and atomic credential reset. */ +export class RecoveryService { + public constructor(private readonly ports: RecoveryServicePortsV1) {} + + public async request(emailInput: unknown): Promise { + const normalized = normalizeEmailAddressV1(emailInput); + if (!normalized.accepted) return inputRejected('INVALID_INPUT'); + const issuedAt = timestamp(this.ports.clock); + if (!issuedAt) return unavailable(); + let challengeId: string; + let raw: string; + try { + challengeId = this.ports.ids.next(); + raw = this.ports.tokens.next(); + } catch { + return unavailable(); + } + if (!stable(challengeId) || !rawToken(raw)) return unavailable(); + let tokenDigest: string; + let emailDigest: string; + try { + tokenDigest = this.ports.digest.digestToken(raw); + emailDigest = this.ports.digest.digestEmail(normalized.value); + } catch { + return unavailable(); + } + const challenge = createRecoveryChallengeV1({ + id: challengeId, + userId: '00000000-0000-4000-8000-000000000001', + tokenDigest, + emailDigest, + issuedAt, + expiresAt: new Date( + Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, + ).toISOString(), + }); + // The placeholder user ID is replaced inside the transaction after the exact email lookup. + if (!challenge.accepted) return unavailable(); + try { + return 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 } }); + const active = await transaction.findActiveChallengeForUser(userId); + if (active) { + const revoked = { + ...active, + status: 'REVOKED' as const, + revokedAt: issuedAt, + revision: active.revision + 1, + } satisfies RecoveryChallengeV1; + await transaction.saveChallenge(Object.freeze(revoked)); + } + const bound = createRecoveryChallengeV1({ + id: challenge.value.id, + userId, + tokenDigest: challenge.value.tokenDigest, + emailDigest: challenge.value.emailDigest, + issuedAt: challenge.value.issuedAt, + expiresAt: challenge.value.expiresAt, + }); + if (!bound.accepted) return unavailable(); + try { + await this.ports.delivery.deliver({ + challengeId: bound.value.id, + recipientEmail: normalized.value, + rawToken: raw, + expiresAt: bound.value.expiresAt, + }); + } catch { + return unavailable(); + } + await transaction.saveChallenge(bound.value); + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + }); + } catch { + return unavailable(); + } + } + + public async complete( + rawTokenInput: unknown, + newPassword: unknown, + ): Promise { + const raw = rawToken(rawTokenInput); + if (!raw) return inputRejected('INVALID_TOKEN'); + const credential = await this.ports.passwordCredentials.create(newPassword); + if (!credential.accepted) { + return inputRejected( + credential.code === 'INVALID_PASSWORD' ? 'INVALID_INPUT' : 'RECOVERY_UNAVAILABLE', + ); + } + let digest: string; + let credentialId: StableIdentifierV1; + try { + digest = this.ports.digest.digestToken(raw); + const parsedId = stable(this.ports.ids.next()); + if (!parsedId) return unavailable(); + credentialId = parsedId; + } catch { + return unavailable(); + } + const now = timestamp(this.ports.clock); + if (!now) return unavailable(); + try { + return await this.ports.repository.withTransaction(async (transaction) => { + const challenge = await transaction.findChallengeByTokenDigest(digest); + if (!challenge) return inputRejected('INVALID_TOKEN'); + const consumed = consumeRecoveryChallengeV1(challenge, now); + if (!consumed.accepted) return inputRejected('INVALID_TOKEN'); + await transaction.completeRecovery({ + challenge: consumed.value, + credentialId, + credential: credential.value, + }); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ + userId: consumed.value.userId, + mfaReenrollmentRequired: true as const, + }), + }); + }); + } catch { + return unavailable(); + } + } +} diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts new file mode 100644 index 00000000..772dfa56 --- /dev/null +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-repository.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'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; + +function credentials() { + return new PasswordCredentialService({ + hash: async (password) => ({ + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, + }), + verify: async () => true, + }); +} + +function service( + repository: InMemoryRecoveryRepositoryAdapter, + delivery: RecoveryDeliveryPortV1 = { deliver: async () => undefined }, +) { + let id = 2; + return new RecoveryService({ + repository, + passwordCredentials: credentials(), + digest: { + digestToken: () => 'a'.repeat(64), + digestEmail: () => 'b'.repeat(64), + }, + delivery, + ids: { next: () => `00000000-0000-4000-8000-${String(id++).padStart(12, '0')}` }, + tokens: { next: () => token }, + clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); +} + +void test('[IAM-015] recovery request is generic for unknown email and stores a delivered hashed challenge for a known account', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const delivered: string[] = []; + const recovery = service(repository, { + deliver: async (input: Parameters[0]) => { + delivered.push(input.rawToken); + }, + }); + assert.deepEqual(await recovery.request('unknown@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(await recovery.request('USER@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(delivered, [token]); + assert.equal(repository.challenge('a'.repeat(64))?.userId, userId); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); + +void test('[IAM-015] completion atomically consumes the challenge, rotates the credential, advances the epoch, revokes sessions, and requires MFA re-enrollment', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ + email: 'user@example.com', + userId, + activeSessionFamilies: ['family-1', 'family-2'], + }); + const recovery = service(repository); + assert.equal((await recovery.request('user@example.com')).accepted, true); + const result = await recovery.complete(token, 'new correct horse battery staple'); + assert.deepEqual(result, { + accepted: true, + value: { userId, mfaReenrollmentRequired: true }, + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'CONSUMED'); + assert.equal(repository.account(userId)?.securityEpoch, 2); + assert.equal(repository.account(userId)?.mfaReenrollmentRequired, true); + assert.equal(repository.account(userId)?.activeSessionFamilies.size, 0); + assert.deepEqual(await recovery.complete(token, 'another correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); +}); + +void test('[IAM-015] recovery delivery failures do not persist a usable challenge', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const recovery = service(repository, { + deliver: async () => { + throw new Error('provider down'); + }, + }); + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: false, + code: 'RECOVERY_UNAVAILABLE', + }); + assert.equal(repository.challenge('a'.repeat(64)), undefined); +}); From 857aa05740b3dba1e378dac0513250f4c21df742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:27:19 +0700 Subject: [PATCH 16/99] feat(iam): persist recovery security state atomically --- .../migration.sql | 30 ++ services/api/prisma/schema/iam.prisma | 22 ++ .../prisma-recovery-repository.adapter.ts | 289 ++++++++++++++++++ .../iam/prisma-recovery-repository.test.ts | 237 ++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql create mode 100644 services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-recovery-repository.test.ts diff --git a/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql b/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql new file mode 100644 index 00000000..f58f874b --- /dev/null +++ b/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql @@ -0,0 +1,30 @@ +-- IAM-015: preserve the security epoch and force MFA re-enrollment after recovery. +ALTER TABLE "iam"."users" + ADD COLUMN "mfa_reenrollment_required" BOOLEAN NOT NULL DEFAULT false; + +-- IAM-015: raw recovery bearers never persist; only keyed digests are stored. +CREATE TABLE "iam"."recovery_challenges" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "email_digest" CHAR(64) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "consumed_at" TIMESTAMPTZ(6), + "revoked_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "recovery_challenges_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "recovery_challenges_token_digest_key" +ON "iam"."recovery_challenges"("token_digest"); + +CREATE INDEX "recovery_challenges_user_status_idx" +ON "iam"."recovery_challenges"("user_id", "status"); + +CREATE INDEX "recovery_challenges_expiry_status_idx" +ON "iam"."recovery_challenges"("expires_at", "status"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 901f770d..3140d3d0 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -7,6 +7,7 @@ model UserIdentity { locale String @default("vi-VN") @db.VarChar(16) status String @default("ACTIVE") @db.VarChar(24) securityEpoch Int @default(1) @map("security_epoch") + mfaReenrollmentRequired Boolean @default(false) @map("mfa_reenrollment_required") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) @@ -117,6 +118,27 @@ model InvitationTokenRecord { @@schema("iam") } +/// IAM-015: recovery bearers are short-lived, email-bound, and hashed at rest. +model RecoveryChallengeRecord { + id String @id @db.Uuid + userId String @map("user_id") @db.Uuid + tokenDigest String @unique(map: "recovery_challenges_token_digest_key") @map("token_digest") @db.Char(64) + emailDigest String @map("email_digest") @db.Char(64) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(16) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(6) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([userId, status], map: "recovery_challenges_user_status_idx") + @@index([expiresAt, status], map: "recovery_challenges_expiry_status_idx") + @@map("recovery_challenges") + @@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/prisma-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts new file mode 100644 index 00000000..1041facf --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts @@ -0,0 +1,289 @@ +import { + createRecoveryChallengeV1, + type RecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + RecoveryCompletionInputV1, + RecoveryRepositoryPortV1, + RecoveryTransactionPortV1, +} from '../application/recovery-repository.port.js'; + +export interface RecoveryUserDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; + readonly securityEpoch: number; + readonly mfaReenrollmentRequired: boolean; +} + +export interface RecoveryChallengeDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: string; + readonly consumedAt?: Date | null; + readonly revokedAt?: Date | null; + readonly revision: number; +} + +export interface RecoverySessionDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly familyId: string; +} + +interface UniqueDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; +} + +interface UserDelegateV1 extends UniqueDelegateV1 { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +interface ChallengeDelegateV1 extends UniqueDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { + readonly data: Readonly>; + }): Promise; + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; +} + +interface PasswordCredentialDelegateV1 { + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; +} + +interface SessionDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; +} + +interface UpdateManyDelegateV1 { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +export interface RecoveryDatabaseClientV1 { + readonly userIdentity: UserDelegateV1; + readonly recoveryChallenge: ChallengeDelegateV1; + readonly passwordCredential: PasswordCredentialDelegateV1; + readonly sessionRecord: SessionDelegateV1; + readonly refreshTokenRecord: UpdateManyDelegateV1; + readonly accessTokenRecord: UpdateManyDelegateV1; + readonly mfaFactor: UpdateManyDelegateV1; + $transaction( + work: (transaction: RecoveryDatabaseClientV1) => Promise, + ): Promise; +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: Date | null | undefined): string | undefined { + if (!input) return undefined; + const value = input.toISOString(); + return Number.isFinite(Date.parse(value)) ? value : undefined; +} + +function challengeFromRow(row: RecoveryChallengeDatabaseRowV1): RecoveryChallengeV1 { + const created = createRecoveryChallengeV1({ + id: row.id, + userId: row.userId, + tokenDigest: row.tokenDigest, + emailDigest: row.emailDigest, + issuedAt: timestamp(row.issuedAt), + expiresAt: timestamp(row.expiresAt), + revision: row.revision, + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'CONSUMED' && row.status !== 'REVOKED') + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + const consumedAt = timestamp(row.consumedAt); + const revokedAt = timestamp(row.revokedAt); + if ((row.consumedAt && !consumedAt) || (row.revokedAt && !revokedAt)) + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'CONSUMED' && !consumedAt) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'REVOKED' && !revokedAt) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'ACTIVE' && (consumedAt || revokedAt)) + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + ...(consumedAt ? { consumedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function challengeData(challenge: RecoveryChallengeV1): Record { + return { + id: challenge.id, + userId: challenge.userId, + tokenDigest: challenge.tokenDigest, + emailDigest: challenge.emailDigest, + issuedAt: new Date(challenge.issuedAt), + expiresAt: new Date(challenge.expiresAt), + status: challenge.status, + consumedAt: challenge.consumedAt ? new Date(challenge.consumedAt) : null, + revokedAt: challenge.revokedAt ? new Date(challenge.revokedAt) : null, + revision: challenge.revision, + }; +} + +function sameImmutableFields(left: RecoveryChallengeV1, right: RecoveryChallengeV1): boolean { + return ( + left.id === right.id && + left.userId === right.userId && + left.tokenDigest === right.tokenDigest && + left.emailDigest === right.emailDigest && + left.issuedAt === right.issuedAt && + left.expiresAt === right.expiresAt + ); +} + +function isConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaRecoveryTransactionAdapter implements RecoveryTransactionPortV1 { + public constructor(private readonly client: RecoveryDatabaseClientV1) {} + + public async findUserIdByEmail(emailInput: string) { + const email = normalizeEmailAddressV1(emailInput); + if (!email.accepted) return undefined; + const user = await this.client.userIdentity.findUnique({ where: { email: email.value } }); + const userId = stable(user?.id); + return user && user.status === 'ACTIVE' && user.email === email.value ? userId : undefined; + } + + public async findChallengeByTokenDigest(tokenDigest: string) { + const row = await this.client.recoveryChallenge.findUnique({ where: { tokenDigest } }); + return row ? challengeFromRow(row) : undefined; + } + + public async findActiveChallengeForUser(userId: StableIdentifierV1) { + const rows = await this.client.recoveryChallenge.findMany({ + where: { userId, status: 'ACTIVE' }, + }); + const row = [...rows].sort((left, right) => left.id.localeCompare(right.id))[0]; + return row ? challengeFromRow(row) : undefined; + } + + public async saveChallenge(challenge: RecoveryChallengeV1): Promise { + if (!stable(challenge.id) || !stable(challenge.userId)) + throw new Error('IAM_RECOVERY_INVALID_IDENTIFIER'); + const existingRow = await this.client.recoveryChallenge.findUnique({ + where: { id: challenge.id }, + }); + try { + if (!existingRow) { + if (challenge.revision !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + await this.client.recoveryChallenge.create({ data: challengeData(challenge) }); + return; + } + const existing = challengeFromRow(existingRow); + if (!sameImmutableFields(existing, challenge)) throw new Error('IAM_RECOVERY_IMMUTABLE'); + if ( + existing.revision === challenge.revision && + JSON.stringify(existing) === JSON.stringify(challenge) + ) + return; + if (challenge.revision !== existing.revision + 1) + throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + await this.client.recoveryChallenge.update({ + where: { id: challenge.id }, + data: challengeData(challenge), + }); + } catch (error) { + if (isConflict(error)) throw new Error('IAM_RECOVERY_CONFLICT'); + throw error; + } + } + + public async completeRecovery(input: RecoveryCompletionInputV1): Promise { + if (input.challenge.status !== 'CONSUMED' || !input.challenge.consumedAt) + throw new Error('IAM_RECOVERY_STATE_INVALID'); + const user = await this.client.userIdentity.findUnique({ + where: { id: input.challenge.userId }, + }); + if (!user || user.status === 'DEACTIVATED' || 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 }, + data: { securityEpoch: user.securityEpoch + 1, mfaReenrollmentRequired: true }, + }); + if (updatedUser.count !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + await this.client.passwordCredential.update({ + where: { userId: user.id }, + data: { + id: input.credentialId, + algorithm: input.credential.algorithm, + encodedHash: input.credential.encodedHash, + rotatedAt: new Date(input.challenge.consumedAt), + }, + }); + const revokedAt = new Date(input.challenge.consumedAt); + const sessions = await this.client.sessionRecord.findMany({ + where: { userId: user.id, status: 'ACTIVE' }, + }); + for (const session of sessions) { + await this.client.refreshTokenRecord.updateMany({ + where: { familyId: session.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await this.client.accessTokenRecord.updateMany({ + where: { sessionId: session.id, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt }, + }); + await this.client.sessionRecord.update({ + where: { id: session.id }, + data: { status: 'REVOKED', revokedAt }, + }); + } + await this.client.mfaFactor.updateMany({ + where: { userId: user.id, status: { in: ['ACTIVE', 'PENDING'] } }, + data: { status: 'REVOKED', revokedAt, revision: { increment: 1 } }, + }); + await this.saveChallenge(input.challenge); + } +} + +/** PostgreSQL adapter for the recovery challenge and security-state transaction. */ +export class PrismaRecoveryRepositoryAdapter implements RecoveryRepositoryPortV1 { + public constructor(private readonly client: RecoveryDatabaseClientV1) {} + + public withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaRecoveryTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/test/features/iam/prisma-recovery-repository.test.ts b/services/api/test/features/iam/prisma-recovery-repository.test.ts new file mode 100644 index 00000000..130b8110 --- /dev/null +++ b/services/api/test/features/iam/prisma-recovery-repository.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaRecoveryRepositoryAdapter, + type RecoveryDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-recovery-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const challengeId = '00000000-0000-4000-8000-000000000002'; +const issuedAt = new Date('2026-08-03T00:00:00.000Z'); + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier invalid'); + return parsed.value; +} + +function challenge(status: 'ACTIVE' | 'CONSUMED' = 'ACTIVE') { + const created = createRecoveryChallengeV1({ + id: challengeId, + userId, + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: issuedAt.toISOString(), + expiresAt: '2026-08-03T00:30:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('fixture invalid'); + if (status === 'ACTIVE') return created.value; + const consumed = { + ...created.value, + status: 'CONSUMED' as const, + consumedAt: '2026-08-03T00:10:00.000Z', + revision: 2, + }; + return consumed; +} + +function database() { + const users = new Map>([ + [ + userId, + { + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 1, + mfaReenrollmentRequired: false, + }, + ], + ]); + const challenges = new Map>(); + const credentials = new Map>([[userId, { userId }]]); + const sessionId = '00000000-0000-4000-8000-000000000003'; + const sessions = new Map>([ + [ + sessionId, + { + id: sessionId, + userId, + familyId: 'family-1', + status: 'ACTIVE', + }, + ], + ]); + const calls = { transactions: 0, refresh: 0, access: 0, sessions: 0, mfa: 0 }; + let client!: RecoveryDatabaseClientV1; + const unique = (records: Map>) => ({ + findUnique: async ({ where }: { readonly where: Record }) => { + if (where['id']) return records.get(where['id']) ?? null; + if (where['email']) + return [...records.values()].find((row) => row['email'] === where['email']) ?? null; + if (where['tokenDigest']) + return ( + [...records.values()].find((row) => row['tokenDigest'] === where['tokenDigest']) ?? null + ); + return null; + }, + }); + client = { + userIdentity: { + ...unique(users), + updateMany: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + const row = users.get(String(where['id'])); + if (!row || row['securityEpoch'] !== where['securityEpoch']) return { count: 0 }; + users.set(String(where['id']), { ...row, ...data }); + return { count: 1 }; + }, + }, + recoveryChallenge: { + ...unique(challenges), + findMany: async ({ where }: { where: Record }) => + [...challenges.values()].filter( + (row) => row['userId'] === where['userId'] && row['status'] === where['status'], + ), + create: async ({ data }: { data: Record }) => { + challenges.set(String(data['id']), data); + return data; + }, + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + const row = challenges.get(String(where['id'])); + if (!row) throw new Error('missing'); + challenges.set(String(where['id']), { ...row, ...data }); + return { ...row, ...data }; + }, + }, + passwordCredential: { + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + const row = credentials.get(String(where['userId'])); + if (!row) throw new Error('credential missing'); + credentials.set(String(where['userId']), { ...row, ...data }); + return { ...row, ...data }; + }, + }, + sessionRecord: { + findMany: async () => [...sessions.values()], + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + calls.sessions += 1; + const row = sessions.get(String(where['id'])); + if (!row) throw new Error('session missing'); + sessions.set(String(where['id']), { ...row, ...data }); + return { ...row, ...data }; + }, + }, + refreshTokenRecord: { + updateMany: async () => { + calls.refresh += 1; + return { count: 1 }; + }, + }, + accessTokenRecord: { + updateMany: async () => { + calls.access += 1; + return { count: 1 }; + }, + }, + mfaFactor: { + updateMany: async () => { + calls.mfa += 1; + return { count: 1 }; + }, + }, + $transaction: async (work: (transaction: RecoveryDatabaseClientV1) => Promise) => { + calls.transactions += 1; + return work(client); + }, + } as unknown as RecoveryDatabaseClientV1; + return { client, users, challenges, credentials, sessions, calls }; +} + +void test('[IAM-015] Prisma recovery adapter persists and reads exact challenge versions', async () => { + const state = database(); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + assert.equal(state.calls.transactions, 1); + assert.equal( + await adapter.withTransaction((transaction) => + transaction.findUserIdByEmail('USER@example.com'), + ), + stable(userId), + ); + assert.equal( + ( + await adapter.withTransaction((transaction) => + transaction.findChallengeByTokenDigest('a'.repeat(64)), + ) + )?.status, + 'ACTIVE', + ); + assert.equal( + ( + await adapter.withTransaction((transaction) => + transaction.findActiveChallengeForUser(stable(userId)), + ) + )?.id, + challengeId, + ); +}); + +void test('[IAM-015] Prisma recovery completion rotates credential, epoch, MFA state, sessions, and challenge atomically', async () => { + const state = database(); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + await 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==', + }, + }), + ); + assert.equal(state.users.get(userId)?.['securityEpoch'], 2); + assert.equal(state.users.get(userId)?.['mfaReenrollmentRequired'], true); + assert.equal( + state.credentials.get(userId)?.['encodedHash'], + '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + ); + assert.equal(state.sessions.get('00000000-0000-4000-8000-000000000003')?.['status'], 'REVOKED'); + assert.equal(state.calls.refresh, 1); + assert.equal(state.calls.access, 1); + assert.equal(state.calls.mfa, 1); + assert.equal(state.challenges.get(challengeId)?.['status'], 'CONSUMED'); +}); From 66abc06f8426c80e7be74c92adc0cbeebb930780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:38:33 +0700 Subject: [PATCH 17/99] feat(iam): expose account recovery endpoints --- docs/operations/iam-recovery-2026-08-03.md | 25 +++ docs/plans/requirement-traceability.json | 24 ++- packages/i18n/src/catalogs-v1.ts | 6 + packages/i18n/test/catalogs-v1.test.mjs | 3 + services/api/openapi/v1.json | 200 ++++++++++++++++++ .../adapter/iam-recovery-crypto.adapter.ts | 50 +++++ .../features/iam/api/recovery.controller.ts | 81 +++++++ .../api/src/features/iam/api/recovery.dto.ts | 38 ++++ .../iam/application/recovery-problem.error.ts | 11 + .../iam/application/recovery.service.ts | 51 ++--- services/api/src/features/iam/iam.module.ts | 78 +++++++ .../platform/http/problem-details.filter.ts | 15 ++ .../features/iam/recovery-composition.test.ts | 58 +++++ .../features/iam/recovery-controller.test.ts | 46 ++++ .../test/features/iam/recovery-crypto.test.ts | 16 ++ .../test/features/iam/recovery-http.test.ts | 91 ++++++++ .../features/iam/recovery.service.test.ts | 18 ++ services/api/test/openapi.test.ts | 4 + services/api/test/prisma-foundation.test.mjs | 2 + 19 files changed, 778 insertions(+), 39 deletions(-) create mode 100644 docs/operations/iam-recovery-2026-08-03.md create mode 100644 services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts create mode 100644 services/api/src/features/iam/api/recovery.controller.ts create mode 100644 services/api/src/features/iam/api/recovery.dto.ts create mode 100644 services/api/src/features/iam/application/recovery-problem.error.ts create mode 100644 services/api/test/features/iam/recovery-composition.test.ts create mode 100644 services/api/test/features/iam/recovery-controller.test.ts create mode 100644 services/api/test/features/iam/recovery-crypto.test.ts create mode 100644 services/api/test/features/iam/recovery-http.test.ts diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md new file mode 100644 index 00000000..2c427b9c --- /dev/null +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -0,0 +1,25 @@ +# IAM recovery slice — 2026-08-03 + +This evidence records the partial account-recovery boundary delivered on `feat/iam-recovery`. +It does not claim that IAM-015 or the IAM plan is complete. + +## Scope + +- Validate a bounded recovery request and return the same accepted response for known and unknown email addresses. +- Generate a short-lived, single-use bearer, deliver the raw value only through the delivery port, and persist only keyed HMAC digests. +- Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required. +- Keep the public completion response free of bearer material; no session is automatically created. +- Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. + +## Evidence + +- Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`. +- In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. +- Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. +- Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`. +- Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`). +- Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. + +## Verification + +The scoped API TypeScript build, recovery tests, i18n tests, OpenAPI generation/check, and Prisma validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until authenticated MFA re-enrollment enforcement, audit events, rate limits, abuse monitoring, restoration drills, and the complete IAM release gates are delivered. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index 41cef87e..c73b7f03 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -9443,23 +9443,33 @@ "packages/domain/src/authorization/v1.ts", "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", + "packages/domain/src/recovery/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql", + "services/api/src/features/iam/application/recovery.service.ts", + "services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts", + "services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts", + "services/api/src/features/iam/api/recovery.controller.ts" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", - "services/api/test/features/iam/", + "packages/domain/test/recovery-v1.test.mjs", + "services/api/test/features/iam/recovery.service.test.ts", + "services/api/test/features/iam/prisma-recovery-repository.test.ts", + "services/api/test/features/iam/recovery-crypto.test.ts", + "services/api/test/features/iam/recovery-composition.test.ts", + "services/api/test/features/iam/recovery-controller.test.ts", + "services/api/test/features/iam/recovery-http.test.ts", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-recovery-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 9c787f97..5676d9be 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -145,6 +145,9 @@ const vietnameseCatalogV1 = { 'retry.afterSeconds.other': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), 'api.error.registration_request_rejected': entry('Yêu cầu đăng ký không được chấp nhận.'), 'api.error.registration_unavailable': entry('Dịch vụ đăng ký hiện không khả dụng.'), + 'api.error.recovery_request_rejected': entry('Yêu cầu khôi phục không hợp lệ.'), + 'api.error.recovery_token_invalid': entry('Liên kết khôi phục không hợp lệ hoặc đã hết hạn.'), + 'api.error.recovery_unavailable': entry('Dịch vụ khôi phục hiện không khả dụng.'), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), @@ -304,6 +307,9 @@ const englishCatalogV1: MessageCatalogV1 = { 'api.error.registration_unavailable': entry( 'The registration service is temporarily unavailable.', ), + 'api.error.recovery_request_rejected': entry('The recovery request was rejected.'), + 'api.error.recovery_token_invalid': entry('The recovery link is invalid or has expired.'), + 'api.error.recovery_unavailable': entry('The recovery service is temporarily unavailable.'), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index b5a6064c..35eae31e 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -114,6 +114,9 @@ const REQUIRED_KEYS = Object.freeze([ 'retry.afterSeconds.other', 'api.error.registration_request_rejected', 'api.error.registration_unavailable', + 'api.error.recovery_request_rejected', + 'api.error.recovery_token_invalid', + 'api.error.recovery_unavailable', 'module.folderAutopilot', 'module.spreadsheetAuditor', 'module.quoteIntelligence', diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 978fa599..fc312a0b 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3150,6 +3150,180 @@ "tags": ["auth"] } }, + "/v1/auth/recovery": { + "post": { + "description": "The accepted response is intentionally identical for known and unknown emails.", + "operationId": "RecoveryController.request", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryRequestDto" } } + } + }, + "responses": { + "202": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecoveryRequestResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The recovery request was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Recovery delivery is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Request an account recovery link", + "tags": ["auth"] + } + }, + "/v1/auth/recovery/complete": { + "post": { + "description": "Consumes a single-use link, revokes sessions, and requires MFA re-enrollment.", + "operationId": "RecoveryController.complete", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCompleteDto" } } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecoveryCompleteResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The recovery token or password was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Recovery persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Complete account recovery", + "tags": ["auth"] + } + }, "/v1/me/bootstrap": { "get": { "operationId": "IamBootstrapController.bootstrap", @@ -9514,6 +9688,32 @@ "locale" ] }, + "RecoveryRequestDto": { + "type": "object", + "properties": { "email": { "type": "string", "format": "email", "maxLength": 254 } }, + "required": ["email"] + }, + "RecoveryRequestResponseDto": { + "type": "object", + "properties": { "requested": { "type": "boolean", "enum": [true], "example": true } }, + "required": ["requested"] + }, + "RecoveryCompleteDto": { + "type": "object", + "properties": { + "token": { "type": "string", "minLength": 32, "maxLength": 512, "writeOnly": true }, + "newPassword": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true } + }, + "required": ["token", "newPassword"] + }, + "RecoveryCompleteResponseDto": { + "type": "object", + "properties": { + "userId": { "type": "string", "format": "uuid" }, + "mfaReenrollmentRequired": { "type": "boolean", "enum": [true], "example": true } + }, + "required": ["userId", "mfaReenrollmentRequired"] + }, "BootstrapUserDto": { "type": "object", "properties": { 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 new file mode 100644 index 00000000..6d9a3227 --- /dev/null +++ b/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts @@ -0,0 +1,50 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; + +import type { RecoveryDigestPortV1 } from '../application/recovery-repository.port.js'; +import type { + RecoveryIdGeneratorV1, + RecoveryTokenGeneratorV1, +} from '../application/recovery.service.js'; + +export type IamRecoveryDigestKeyV1 = string | Uint8Array; + +function validKey(key: IamRecoveryDigestKeyV1): boolean { + return ( + (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + ); +} + +function boundedInput(value: string): string { + if (value.length === 0 || value.length > 4096 || /\p{Cc}/u.test(value)) + throw new Error('IAM_RECOVERY_INPUT_INVALID'); + return value.normalize('NFC'); +} + +/** HMAC keeps recovery digests keyed and separate from invitation/session bearer digests. */ +export class HmacSha256IamRecoveryDigestAdapter implements RecoveryDigestPortV1 { + public constructor(private readonly key: IamRecoveryDigestKeyV1) { + if (!validKey(key)) throw new Error('IAM_RECOVERY_KEY_INVALID'); + } + + private digest(domain: 'token' | 'email', value: string): string { + return createHmac('sha256', this.key) + .update(`databreeze:iam:recovery:${domain}:v1\u0000${boundedInput(value)}`, 'utf8') + .digest('hex'); + } + + public digestToken(rawToken: string): string { + return this.digest('token', rawToken); + } + + public digestEmail(normalizedEmail: string): string { + return this.digest('email', normalizedEmail); + } +} + +export const randomIamRecoveryIdV1: RecoveryIdGeneratorV1 = Object.freeze({ + next: () => randomUUID(), +}); + +export const randomIamRecoveryTokenV1: RecoveryTokenGeneratorV1 = Object.freeze({ + next: () => randomBytes(32).toString('base64url'), +}); diff --git a/services/api/src/features/iam/api/recovery.controller.ts b/services/api/src/features/iam/api/recovery.controller.ts new file mode 100644 index 00000000..eb217732 --- /dev/null +++ b/services/api/src/features/iam/api/recovery.controller.ts @@ -0,0 +1,81 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post } from '@nestjs/common'; +import { + ApiAcceptedResponse, + ApiBadRequestResponse, + ApiBody, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { IAM_RECOVERY_SERVICE, RecoveryService } from '../application/recovery.service.js'; +import { RecoveryProblemError } from '../application/recovery-problem.error.js'; +import { + RecoveryCompleteDto, + RecoveryCompleteResponseDto, + RecoveryRequestDto, + RecoveryRequestResponseDto, +} from './recovery.dto.js'; + +/** IAM-015: public account recovery never discloses whether an email has an account. */ +@ApiTags('auth') +@Controller('v1/auth') +export class RecoveryController { + public constructor( + @Optional() + @Inject(IAM_RECOVERY_SERVICE) + private readonly recovery: RecoveryService | undefined, + ) {} + + @Post('recovery') + @HttpCode(202) + @ApiOperation({ + summary: 'Request an account recovery link', + description: 'The accepted response is intentionally identical for known and unknown emails.', + }) + @ApiBody({ type: RecoveryRequestDto }) + @ApiAcceptedResponse({ type: RecoveryRequestResponseDto }) + @ApiBadRequestResponse({ description: 'The recovery request was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Recovery delivery is unavailable.' }) + async request(@Body() input: RecoveryRequestDto): Promise { + if (this.recovery === undefined) throw new RecoveryProblemError('RECOVERY_UNAVAILABLE'); + const result = await this.recovery.request(input.email); + if (!result.accepted) { + throw new RecoveryProblemError( + result.code === 'RECOVERY_UNAVAILABLE' + ? 'RECOVERY_UNAVAILABLE' + : 'RECOVERY_REQUEST_REJECTED', + ); + } + return { requested: true }; + } + + @Post('recovery/complete') + @HttpCode(200) + @ApiOperation({ + summary: 'Complete account recovery', + description: 'Consumes a single-use link, revokes sessions, and requires MFA re-enrollment.', + }) + @ApiBody({ type: RecoveryCompleteDto }) + @ApiOkResponse({ type: RecoveryCompleteResponseDto }) + @ApiBadRequestResponse({ description: 'The recovery token or password was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Recovery persistence is unavailable.' }) + async complete(@Body() input: RecoveryCompleteDto): Promise { + if (this.recovery === undefined) throw new RecoveryProblemError('RECOVERY_UNAVAILABLE'); + const result = await this.recovery.complete(input.token, input.newPassword); + if (!result.accepted) { + throw new RecoveryProblemError( + result.code === 'RECOVERY_UNAVAILABLE' + ? 'RECOVERY_UNAVAILABLE' + : result.code === 'INVALID_TOKEN' + ? 'RECOVERY_TOKEN_INVALID' + : 'RECOVERY_REQUEST_REJECTED', + ); + } + return { + userId: result.value.userId, + mfaReenrollmentRequired: result.value.mfaReenrollmentRequired, + }; + } +} diff --git a/services/api/src/features/iam/api/recovery.dto.ts b/services/api/src/features/iam/api/recovery.dto.ts new file mode 100644 index 00000000..ef91af94 --- /dev/null +++ b/services/api/src/features/iam/api/recovery.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'; + +export class RecoveryRequestDto { + @ApiProperty({ format: 'email', maxLength: 254 }) + @IsEmail() + @IsString() + @MinLength(3) + @MaxLength(254) + email!: string; +} + +export class RecoveryCompleteDto { + @ApiProperty({ minLength: 32, maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(32) + @MaxLength(512) + token!: string; + + @ApiProperty({ minLength: 12, maxLength: 128, writeOnly: true }) + @IsString() + @MinLength(12) + @MaxLength(128) + newPassword!: string; +} + +export class RecoveryRequestResponseDto { + @ApiProperty({ enum: [true], example: true }) + requested!: true; +} + +export class RecoveryCompleteResponseDto { + @ApiProperty({ format: 'uuid' }) + userId!: string; + + @ApiProperty({ enum: [true], example: true }) + mfaReenrollmentRequired!: true; +} diff --git a/services/api/src/features/iam/application/recovery-problem.error.ts b/services/api/src/features/iam/application/recovery-problem.error.ts new file mode 100644 index 00000000..36eca815 --- /dev/null +++ b/services/api/src/features/iam/application/recovery-problem.error.ts @@ -0,0 +1,11 @@ +export type RecoveryProblemCodeV1 = + | 'RECOVERY_REQUEST_REJECTED' + | 'RECOVERY_TOKEN_INVALID' + | 'RECOVERY_UNAVAILABLE'; + +export class RecoveryProblemError extends Error { + public constructor(readonly code: RecoveryProblemCodeV1) { + super(code); + this.name = 'RecoveryProblemError'; + } +} diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index b4172e0a..9ca985eb 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -2,7 +2,7 @@ import { consumeRecoveryChallengeV1, createRecoveryChallengeV1, RECOVERY_CHALLENGE_MAX_SECONDS_V1, - type RecoveryChallengeV1, + revokeRecoveryChallengeV1, } from '@databreeze/domain/recovery/v1'; import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; import { @@ -103,53 +103,40 @@ export class RecoveryService { } catch { return unavailable(); } - const challenge = createRecoveryChallengeV1({ - id: challengeId, - userId: '00000000-0000-4000-8000-000000000001', - tokenDigest, - emailDigest, - issuedAt, - expiresAt: new Date( - Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, - ).toISOString(), - }); - // The placeholder user ID is replaced inside the transaction after the exact email lookup. - if (!challenge.accepted) return unavailable(); + const expiresAt = new Date( + Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, + ).toISOString(); try { return 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 } }); const active = await transaction.findActiveChallengeForUser(userId); - if (active) { - const revoked = { - ...active, - status: 'REVOKED' as const, - revokedAt: issuedAt, - revision: active.revision + 1, - } satisfies RecoveryChallengeV1; - await transaction.saveChallenge(Object.freeze(revoked)); - } - const bound = createRecoveryChallengeV1({ - id: challenge.value.id, + const challenge = createRecoveryChallengeV1({ + id: challengeId, userId, - tokenDigest: challenge.value.tokenDigest, - emailDigest: challenge.value.emailDigest, - issuedAt: challenge.value.issuedAt, - expiresAt: challenge.value.expiresAt, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, }); - if (!bound.accepted) return unavailable(); + if (!challenge.accepted) return unavailable(); try { await this.ports.delivery.deliver({ - challengeId: bound.value.id, + challengeId: challenge.value.id, recipientEmail: normalized.value, rawToken: raw, - expiresAt: bound.value.expiresAt, + expiresAt: challenge.value.expiresAt, }); } catch { return unavailable(); } - await transaction.saveChallenge(bound.value); + if (active) { + const revoked = revokeRecoveryChallengeV1(active, issuedAt); + if (!revoked.accepted) return unavailable(); + await transaction.saveChallenge(revoked.value); + } + await transaction.saveChallenge(challenge.value); return Object.freeze({ accepted: true as const, value: { requested: true as const } }); }); } catch { diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index c2d4a32a..092345a1 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -110,6 +110,29 @@ import { type RegistrationClockV1, type RegistrationIdGeneratorV1, } from './application/registration.service.js'; +import { + IAM_RECOVERY_SERVICE, + RecoveryService, + type RecoveryClockV1, + type RecoveryIdGeneratorV1, + type RecoveryTokenGeneratorV1, +} from './application/recovery.service.js'; +import { + IAM_RECOVERY_REPOSITORY_PORT, + type RecoveryDigestPortV1, + type RecoveryDeliveryPortV1, + type RecoveryRepositoryPortV1, +} from './application/recovery-repository.port.js'; +import { + HmacSha256IamRecoveryDigestAdapter, + randomIamRecoveryIdV1, + randomIamRecoveryTokenV1, + type IamRecoveryDigestKeyV1, +} from './adapter/iam-recovery-crypto.adapter.js'; +import { + PrismaRecoveryRepositoryAdapter, + type RecoveryDatabaseClientV1, +} from './adapter/prisma-recovery-repository.adapter.js'; import { InMemoryIamHierarchyRepositoryAdapter } from './adapter/in-memory-iam-hierarchy-repository.adapter.js'; import { PrismaIamHierarchyRepositoryAdapter, @@ -118,6 +141,7 @@ import { import { DeviceIdentityController } from './api/device-identity.controller.js'; import { IamInvitationController } from './api/invitation.controller.js'; import { RegistrationController } from './api/registration.controller.js'; +import { RecoveryController } from './api/recovery.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { PrismaDeviceIdentityRepositoryAdapter, @@ -179,6 +203,15 @@ export interface IamModuleOptions { readonly registrationService?: RegistrationService; readonly registrationIdGenerator?: RegistrationIdGeneratorV1; readonly registrationClock?: RegistrationClockV1; + readonly recoveryRepository?: RecoveryRepositoryPortV1; + readonly recoveryDatabase?: RecoveryDatabaseClientV1; + readonly recoveryService?: RecoveryService; + readonly recoveryDelivery?: RecoveryDeliveryPortV1; + readonly recoveryDigest?: RecoveryDigestPortV1; + readonly recoveryDigestKey?: IamRecoveryDigestKeyV1; + readonly recoveryIdGenerator?: RecoveryIdGeneratorV1; + readonly recoveryTokenGenerator?: RecoveryTokenGeneratorV1; + readonly recoveryClock?: RecoveryClockV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -311,6 +344,32 @@ export class IamModule { ...(options.registrationClock ? { clock: options.registrationClock } : {}), }) : undefined); + const recoveryRepository = + options.recoveryRepository ?? + (options.recoveryDatabase === undefined + ? undefined + : new PrismaRecoveryRepositoryAdapter(options.recoveryDatabase)); + const recoveryDigest = + options.recoveryDigest ?? + (options.recoveryDigestKey === undefined + ? undefined + : new HmacSha256IamRecoveryDigestAdapter(options.recoveryDigestKey)); + const recoveryService = + options.recoveryService ?? + (recoveryRepository && + options.passwordCredentials && + options.recoveryDelivery && + recoveryDigest + ? new RecoveryService({ + repository: recoveryRepository, + passwordCredentials: options.passwordCredentials, + digest: recoveryDigest, + delivery: options.recoveryDelivery, + ids: options.recoveryIdGenerator ?? randomIamRecoveryIdV1, + tokens: options.recoveryTokenGenerator ?? randomIamRecoveryTokenV1, + ...(options.recoveryClock ? { clock: options.recoveryClock } : {}), + }) + : undefined); const authentication = options.authentication ?? (credentials && sessions @@ -346,6 +405,8 @@ export class IamModule { if (invitationPrincipalEmails) exports.unshift(IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); if (registrationRepository) exports.unshift(IAM_REGISTRATION_REPOSITORY_PORT); if (registrationService) exports.unshift(IAM_REGISTRATION_SERVICE); + if (recoveryRepository) exports.unshift(IAM_RECOVERY_REPOSITORY_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); return { module: IamModule, controllers: [ @@ -356,6 +417,7 @@ export class IamModule { IamMembershipController, IamInvitationController, RegistrationController, + RecoveryController, IamBootstrapController, ], providers: [ @@ -475,6 +537,22 @@ export class IamModule { }, ] : []), + ...(recoveryRepository + ? [ + { + provide: IAM_RECOVERY_REPOSITORY_PORT, + useValue: recoveryRepository, + }, + ] + : []), + ...(recoveryService + ? [ + { + provide: IAM_RECOVERY_SERVICE, + useValue: recoveryService, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 48b1300f..d520348f 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -14,6 +14,7 @@ import { EntitlementProblemError } from '../../features/bua/application/entitlem import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; import { InvitationProblemError } from '../../features/iam/application/invitation-problem.error.js'; import { RegistrationProblemError } from '../../features/iam/application/registration-problem.error.js'; +import { RecoveryProblemError } from '../../features/iam/application/recovery-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; import { ArtifactExportProblemError } from '../../features/iae/application/artifact-export-problem.error.js'; import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; @@ -140,6 +141,20 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, }; } + if (error instanceof RecoveryProblemError) { + const unavailable = error.code === 'RECOVERY_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.recovery_unavailable' + : error.code === 'RECOVERY_TOKEN_INVALID' + ? 'api.error.recovery_token_invalid' + : 'api.error.recovery_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof AuditProblemError) { const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts new file mode 100644 index 00000000..d5e6fbcd --- /dev/null +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PrismaRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-recovery-repository.adapter.js'; +import { IAM_RECOVERY_REPOSITORY_PORT } from '../../../src/features/iam/application/recovery-repository.port.js'; +import { + IAM_RECOVERY_SERVICE, + RecoveryService, +} from '../../../src/features/iam/application/recovery.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +const passwordCredentials = new PasswordCredentialService({ + hash: async () => ({ + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }), + verify: async () => true, +}); + +void test('[IAM-015] explicitly supplied recovery service is exported with a public controller', () => { + const service = {} as RecoveryService; + const registered = IamModule.register({ recoveryService: service }); + const configured = provider(registered, IAM_RECOVERY_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok(registered.controllers?.some((controller) => controller.name === 'RecoveryController')); +}); + +void test('[IAM-015] durable recovery composition requires password, digest, delivery, and persistence ports', () => { + const incomplete = IamModule.register({ recoveryDatabase: {} as never }); + assert.equal(provider(incomplete, IAM_RECOVERY_SERVICE), undefined); + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { deliver: async () => undefined }, + }); + const repository = provider(configured, IAM_RECOVERY_REPOSITORY_PORT); + const service = provider(configured, IAM_RECOVERY_SERVICE); + assert.ok(repository && 'useValue' in repository); + assert.ok(service && 'useValue' in service); + if (!repository || !('useValue' in repository) || !service || !('useValue' in service)) return; + assert.ok(repository.useValue instanceof PrismaRecoveryRepositoryAdapter); + assert.ok(service.useValue instanceof RecoveryService); +}); diff --git a/services/api/test/features/iam/recovery-controller.test.ts b/services/api/test/features/iam/recovery-controller.test.ts new file mode 100644 index 00000000..820d21a3 --- /dev/null +++ b/services/api/test/features/iam/recovery-controller.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RecoveryController } from '../../../src/features/iam/api/recovery.controller.js'; +import { RecoveryProblemError } from '../../../src/features/iam/application/recovery-problem.error.js'; +import type { RecoveryService } from '../../../src/features/iam/application/recovery.service.js'; + +void test('[IAM-015] recovery controller returns generic request and safe completion values', async () => { + const controller = new RecoveryController({ + request: async () => ({ accepted: true as const, value: { requested: true as const } }), + complete: async () => ({ + accepted: true as const, + value: { userId: 'user-id' as never, mfaReenrollmentRequired: true as const }, + }), + } as unknown as RecoveryService); + assert.deepEqual(await controller.request({ email: 'user@example.com' }), { requested: true }); + assert.deepEqual( + await controller.complete({ + token: 'a'.repeat(32), + newPassword: 'correct horse battery staple', + }), + { userId: 'user-id', mfaReenrollmentRequired: true }, + ); +}); + +void test('[IAM-015] recovery controller maps rejected and unavailable outcomes without account disclosure', async () => { + const controller = new RecoveryController({ + request: async () => ({ accepted: false as const, code: 'INVALID_INPUT' as const }), + complete: async () => ({ accepted: false as const, code: 'INVALID_TOKEN' as const }), + } as unknown as RecoveryService); + await assert.rejects( + controller.request({ email: 'bad' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_REQUEST_REJECTED', + ); + await assert.rejects( + controller.complete({ token: 'a'.repeat(32), newPassword: 'correct horse battery staple' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_TOKEN_INVALID', + ); + await assert.rejects( + new RecoveryController(undefined).request({ email: 'user@example.com' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/iam/recovery-crypto.test.ts b/services/api/test/features/iam/recovery-crypto.test.ts new file mode 100644 index 00000000..35a85d09 --- /dev/null +++ b/services/api/test/features/iam/recovery-crypto.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +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 token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; + assert.equal(first.digestToken(token), second.digestToken(token)); + assert.equal(first.digestEmail('user@example.com').length, 64); + assert.notEqual(first.digestToken(token), first.digestEmail(token)); + assert.notEqual(first.digestToken(token), other.digestToken(token)); + assert.throws(() => first.digestToken(''), /IAM_RECOVERY_INPUT_INVALID/u); +}); diff --git a/services/api/test/features/iam/recovery-http.test.ts b/services/api/test/features/iam/recovery-http.test.ts new file mode 100644 index 00000000..38945259 --- /dev/null +++ b/services/api/test/features/iam/recovery-http.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { HmacSha256IamRecoveryDigestAdapter } from '../../../src/features/iam/adapter/iam-recovery-crypto.adapter.js'; +import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-repository.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const challengeId = '00000000-0000-4000-8000-000000000002'; +const rawToken = 'recovery-token-abcdefghijklmnopqrstuvwxyz-1234567890'; + +function credentials() { + return new PasswordCredentialService({ + hash: async () => ({ + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + }), + verify: async () => true, + }); +} + +void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and consumes a link once', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId, activeSessionFamilies: ['family-1'] }); + const delivered: Array<{ readonly rawToken: string }> = []; + const { app } = await createApiApplication({ + recoveryRepository: repository, + passwordCredentials: credentials(), + recoveryDigest: new HmacSha256IamRecoveryDigestAdapter('test-recovery-key'), + recoveryDelivery: { + deliver: async ({ rawToken: deliveredToken }) => { + delivered.push({ rawToken: deliveredToken }); + }, + }, + recoveryIdGenerator: { next: () => challengeId }, + recoveryTokenGenerator: { next: () => rawToken }, + recoveryClock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); + try { + const known = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'User@example.com' }, + }); + const unknown = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'missing@example.com' }, + }); + assert.equal(known.statusCode, 202); + assert.deepEqual(known.json(), { requested: true }); + assert.equal(unknown.statusCode, 202); + assert.deepEqual(unknown.json(), { requested: true }); + assert.equal(delivered.length, 1); + + const completed = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery/complete', + payload: { token: rawToken, newPassword: 'correct horse battery staple' }, + }); + assert.equal(completed.statusCode, 200); + assert.deepEqual(completed.json(), { userId, mfaReenrollmentRequired: true }); + + const replay = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery/complete', + payload: { token: rawToken, newPassword: 'correct horse battery staple' }, + }); + assert.equal(replay.statusCode, 400); + assert.equal(replay.json<{ code: string }>().code, 'RECOVERY_TOKEN_INVALID'); + } finally { + await app.close(); + } +}); + +void test('[IAM-015] recovery HTTP fails closed without a composed service', async () => { + const { app } = await createApiApplication(); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'user@example.com' }, + }); + assert.equal(response.statusCode, 503); + assert.equal(response.json<{ code: string }>().code, 'RECOVERY_UNAVAILABLE'); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 772dfa56..a662e1a4 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -99,3 +99,21 @@ void test('[IAM-015] recovery delivery failures do not persist a usable challeng }); assert.equal(repository.challenge('a'.repeat(64)), undefined); }); + +void test('[IAM-015] recovery delivery failure preserves an existing active challenge', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + let failDelivery = false; + const recovery = service(repository, { + deliver: async () => { + if (failDelivery) throw new Error('provider down'); + }, + }); + assert.equal((await recovery.request('user@example.com')).accepted, true); + failDelivery = true; + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: false, + code: 'RECOVERY_UNAVAILABLE', + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 596e4e73..2d5c2efb 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -130,6 +130,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/mfa/factors', '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', + '/v1/auth/recovery', + '/v1/auth/recovery/complete', '/v1/auth/refresh', '/v1/auth/register', '/v1/auth/sign-in', @@ -295,6 +297,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, 'POST /v1/system/compatibility/check', 'POST /v1/auth/sign-in', 'POST /v1/auth/register', + 'POST /v1/auth/recovery', + 'POST /v1/auth/recovery/complete', 'POST /v1/auth/refresh', ]); for (const [path, pathItem] of Object.entries(firstDocument.paths) as Array< diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index c9f4fa74..f9d65061 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -83,6 +83,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_tokens"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."recovery_challenges"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -127,6 +128,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803020000_bua_project_usage_scope', '20260803030000_iam_membership_scope_uniqueness', '20260803040000_iam_invitation_tokens', + '20260803050000_iam_recovery_challenges', 'migration_lock.toml', ]); const migration = await readFile( From 20888ec87a0b1e1bda7f16f0fdc40c56ff751ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:40:51 +0700 Subject: [PATCH 18/99] feat(iam): clear recovery gate after MFA verification --- docs/operations/iam-recovery-2026-08-03.md | 2 ++ docs/plans/requirement-traceability.json | 5 ++++ .../in-memory-mfa-repository.adapter.ts | 19 ++++++++++++++ .../adapter/prisma-mfa-repository.adapter.ts | 15 +++++++++++ .../iam/application/mfa-repository.port.ts | 2 ++ .../features/iam/application/mfa.service.ts | 3 +++ .../api/test/features/iam/mfa.service.test.ts | 2 ++ .../iam/prisma-mfa-repository.test.ts | 26 +++++++++++++++++++ 8 files changed, 74 insertions(+) diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md index 2c427b9c..98fa10e7 100644 --- a/docs/operations/iam-recovery-2026-08-03.md +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -8,6 +8,7 @@ It does not claim that IAM-015 or the IAM plan is complete. - Validate a bounded recovery request and return the same accepted response for known and unknown email addresses. - Generate a short-lived, single-use bearer, deliver the raw value only through the delivery port, and persist only keyed HMAC digests. - Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required. +- Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it. - Keep the public completion response free of bearer material; no session is automatically created. - Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. @@ -16,6 +17,7 @@ It does not claim that IAM-015 or the IAM plan is complete. - Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`. - In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. - Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. +- MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`. - Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`. - Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`). - Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index c73b7f03..a38ee31f 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -9448,7 +9448,10 @@ "services/api/prisma/schema/iam.prisma", "services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql", "services/api/src/features/iam/application/recovery.service.ts", + "services/api/src/features/iam/application/mfa.service.ts", + "services/api/src/features/iam/application/mfa-repository.port.ts", "services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts", + "services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts", "services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts", "services/api/src/features/iam/api/recovery.controller.ts" ], @@ -9459,6 +9462,8 @@ "services/api/test/features/iam/recovery.service.test.ts", "services/api/test/features/iam/prisma-recovery-repository.test.ts", "services/api/test/features/iam/recovery-crypto.test.ts", + "services/api/test/features/iam/mfa.service.test.ts", + "services/api/test/features/iam/prisma-mfa-repository.test.ts", "services/api/test/features/iam/recovery-composition.test.ts", "services/api/test/features/iam/recovery-controller.test.ts", "services/api/test/features/iam/recovery-http.test.ts", diff --git a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts index d67720e3..0fe9f705 100644 --- a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts @@ -63,8 +63,17 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { /** In-memory MFA state adapter; secrets remain opaque references and codes remain digests. */ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { private states = new Map(); + private recoveryReenrollment = new Map(); private transactionTail: Promise = Promise.resolve(); + public setRecoveryReenrollmentRequired(userId: StableIdentifierV1, required = true): void { + this.recoveryReenrollment.set(userId, required); + } + + public isRecoveryReenrollmentRequired(userId: StableIdentifierV1): boolean { + return this.recoveryReenrollment.get(userId) === true; + } + public async findState(userId: StableIdentifierV1): Promise { await Promise.resolve(); return cloneState(this.states.get(userId) ?? { factors: [], recoveryCodes: [] }); @@ -82,6 +91,13 @@ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { this.states.set(userId, cloneState(state)); } + public async clearRecoveryReenrollment(userId: StableIdentifierV1): Promise { + await Promise.resolve(); + if (this.recoveryReenrollment.get(userId) !== true) return false; + this.recoveryReenrollment.set(userId, false); + return true; + } + public async withTransaction( work: (transaction: MfaTransactionPortV1) => Promise, ): Promise { @@ -92,13 +108,16 @@ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { }); await previous; const before = new Map(this.states); + const beforeReenrollment = new Map(this.recoveryReenrollment); try { return await work({ findState: this.findState.bind(this), saveState: this.saveState.bind(this), + clearRecoveryReenrollment: this.clearRecoveryReenrollment.bind(this), }); } catch (error) { this.states = before; + this.recoveryReenrollment = beforeReenrollment; throw error; } finally { release(); 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 2384a4e3..bd3ac175 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 @@ -79,6 +79,12 @@ interface MfaRecoveryCodeDelegateV1 { export interface MfaDatabaseClientV1 { readonly mfaFactor: MfaFactorDelegateV1; readonly mfaRecoveryCode: MfaRecoveryCodeDelegateV1; + readonly userIdentity?: { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; + }; $transaction( work: (transaction: MfaDatabaseClientV1) => Promise, ): Promise; @@ -286,6 +292,15 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } } + + public async clearRecoveryReenrollment(userId: string): Promise { + if (!this.client.userIdentity) return false; + const updated = await this.client.userIdentity.updateMany({ + where: { id: userId, mfaReenrollmentRequired: true }, + data: { mfaReenrollmentRequired: false }, + }); + return updated.count === 1; + } } export class PrismaMfaRepositoryAdapter implements MfaRepositoryPortV1 { 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 d3c58329..4ac8b66a 100644 --- a/services/api/src/features/iam/application/mfa-repository.port.ts +++ b/services/api/src/features/iam/application/mfa-repository.port.ts @@ -6,6 +6,8 @@ export const MFA_REPOSITORY_PORT = Symbol('MFA_REPOSITORY_PORT'); 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; } export interface MfaRepositoryPortV1 extends MfaTransactionPortV1 { diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index 80de25e1..f6cb3b9c 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -162,6 +162,9 @@ export class MfaService { recoveryCodes: state.recoveryCodes, }); await transaction.saveState(userId, next); + if (transaction.clearRecoveryReenrollment) { + await transaction.clearRecoveryReenrollment(userId); + } return Object.freeze({ accepted: true, value: view(next) }); }); } diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index e196387c..186d416a 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -14,6 +14,7 @@ const at = '2026-01-01T00:00:00.000Z'; void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revisioned', async () => { const repository = new InMemoryMfaRepositoryAdapter(); + repository.setRecoveryReenrollmentRequired(userId as never); const service = new MfaService( repository, { @@ -41,6 +42,7 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis if (verified.accepted) { assert.equal(verified.value.factors[0]?.status, 'ACTIVE'); assert.equal(verified.value.factors[0]?.verifiedAt, at); + assert.equal(repository.isRecoveryReenrollmentRequired(userId as never), false); } const secondVerify = await service.verifyFactor(userId, factorId, '654321'); assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 115535be..bcf288e3 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -28,7 +28,25 @@ function createDatabase(): { } { const factors = new Map(); const recoveryCodes = new Map(); + const users = new Map([ + [userId, { mfaReenrollmentRequired: true }], + ]); const client = { + userIdentity: { + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Readonly>; + }) => { + const user = users.get(String(where['id'])); + if (!user || user.mfaReenrollmentRequired !== where['mfaReenrollmentRequired']) + return { count: 0 }; + user.mfaReenrollmentRequired = Boolean(data['mfaReenrollmentRequired']); + return { count: 1 }; + }, + }, mfaFactor: { findMany: async ({ where }: { readonly where: Readonly> }) => [...factors.values()].filter((row) => @@ -158,6 +176,14 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence round-trips opaque factors assert.deepEqual(await adapter.findState(factor.userId), input); }); +void test('[IAM-015] Prisma MFA transaction clears the recovery re-enrollment gate by compare-and-set', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const transaction = await adapter.withTransaction(async (current) => current); + assert.equal(await transaction.clearRecoveryReenrollment?.(userId as never), true); + assert.equal(await transaction.clearRecoveryReenrollment?.(userId as never), false); +}); + void test('[IAM-012, IAM-014] status transitions persist by revision while immutable secrets and digests remain fixed', async () => { const { client } = createDatabase(); const adapter = new PrismaMfaRepositoryAdapter(client); From c7faf68b599774e96722309f18a474d1fe9d3a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:43:41 +0700 Subject: [PATCH 19/99] feat(iam): propagate MFA recovery re-enrollment state --- docs/operations/iam-recovery-2026-08-03.md | 2 ++ services/api/openapi/v1.json | 6 ++++-- .../prisma-credential-lookup.adapter.ts | 4 ++++ .../prisma-session-lifecycle.adapter.ts | 4 ++++ .../src/features/iam/api/auth-session.dto.ts | 5 +++++ .../iam/api/authentication.controller.ts | 6 ++++++ .../features/iam/api/current-session.dto.ts | 5 +++++ .../iam/application/authentication.port.ts | 2 ++ .../iam/application/tenant-context.ts | 10 ++++++++++ .../http/session-tenant-context.adapter.ts | 3 +++ .../iam/prisma-credential-lookup.test.ts | 20 +++++++++++++++++++ .../iam/prisma-session-lifecycle.test.ts | 17 ++++++++++++++++ .../http/session-tenant-context.test.ts | 12 +++++++++++ 13 files changed, 94 insertions(+), 2 deletions(-) diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md index 98fa10e7..bc903b88 100644 --- a/docs/operations/iam-recovery-2026-08-03.md +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -9,6 +9,7 @@ It does not claim that IAM-015 or the IAM plan is complete. - Generate a short-lived, single-use bearer, deliver the raw value only through the delivery port, and persist only keyed HMAC digests. - Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required. - Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it. +- Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state. - Keep the public completion response free of bearer material; no session is automatically created. - Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. @@ -18,6 +19,7 @@ It does not claim that IAM-015 or the IAM plan is complete. - In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. - Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. - MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`. +- Live principal/context propagation tests: `services/api/test/features/iam/prisma-credential-lookup.test.ts`, `services/api/test/features/iam/prisma-session-lifecycle.test.ts`, and `services/api/test/platform/http/session-tenant-context.test.ts`. - Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`. - Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`). - Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index fc312a0b..80a4bcb4 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -9414,7 +9414,8 @@ "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, "authorizationEpoch": { "type": "number", "minimum": 1 }, - "mfaRequired": { "type": "boolean" } + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } }, "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] }, @@ -9438,7 +9439,8 @@ "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, "accessExpiresAt": { "type": "string", "format": "date-time" }, "securityEpoch": { "type": "number", "minimum": 1 }, - "mfaRequired": { "type": "boolean" } + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } }, "required": [ "sessionId", 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 bdae6fcc..8c8d641c 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 @@ -11,6 +11,7 @@ export interface UserIdentityDatabaseRowV1 { readonly email: string; readonly status: string; readonly securityEpoch: number; + readonly mfaReenrollmentRequired?: boolean; } export interface PasswordCredentialDatabaseRowV1 { @@ -189,6 +190,9 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspaceId, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, + ...(user.mfaReenrollmentRequired === undefined + ? {} + : { 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 6c57ace8..cff1d55a 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,6 +62,7 @@ export interface SessionUserDatabaseRowV1 { readonly id: string; readonly status: string; readonly securityEpoch: number; + readonly mfaReenrollmentRequired?: boolean; } export interface SessionMembershipDatabaseRowV1 { @@ -561,6 +562,9 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { workspaceId: workspaceId.value, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, + ...(user.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: user.mfaReenrollmentRequired }), }); } } 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 1f181768..ce1d9cb0 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -50,4 +50,9 @@ export class AuthSessionDto { @ApiProperty() @IsBoolean() mfaRequired!: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + 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 664da70a..d31817a1 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -68,6 +68,9 @@ export class AuthenticationController { : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, mfaRequired: context.mfaRequired ?? false, + ...(context.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: context.mfaReenrollmentRequired }), }; } @@ -109,6 +112,9 @@ 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 }), }; } 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 bf333dd3..df1005b0 100644 --- a/services/api/src/features/iam/api/current-session.dto.ts +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -22,4 +22,9 @@ export class CurrentSessionDto { @ApiProperty() @IsBoolean() mfaRequired!: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + mfaReenrollmentRequired?: boolean; } diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index 766dff77..fe085d3d 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -10,6 +10,8 @@ export interface AuthenticatedPrincipalV1 { readonly workspaceId: string; readonly securityEpoch: number; readonly mfaRequired: boolean; + /** Recovery keeps this gate live until a new factor is verified. */ + readonly mfaReenrollmentRequired?: boolean; } export interface CredentialLookupPortV1 { diff --git a/services/api/src/features/iam/application/tenant-context.ts b/services/api/src/features/iam/application/tenant-context.ts index fac5078d..5e7e0498 100644 --- a/services/api/src/features/iam/application/tenant-context.ts +++ b/services/api/src/features/iam/application/tenant-context.ts @@ -12,6 +12,7 @@ export interface IamTenantContextV1 { readonly idempotencyKey: string; readonly authorizationEpoch: number; readonly mfaRequired?: boolean; + readonly mfaReenrollmentRequired?: boolean; readonly expectedRevision?: number; } @@ -37,6 +38,7 @@ export function createIamTenantContextV1(input: { readonly idempotencyKey: unknown; readonly authorizationEpoch: unknown; readonly mfaRequired?: unknown; + readonly mfaReenrollmentRequired?: unknown; readonly expectedRevision?: unknown; }): IamContextResultV1 { const tenantScope = parseTenantScopeV1(input.tenantScope); @@ -59,6 +61,11 @@ export function createIamTenantContextV1(input: { return rejected('INVALID_EPOCH'); if (input.mfaRequired !== undefined && typeof input.mfaRequired !== 'boolean') return rejected('INVALID_TEXT'); + if ( + input.mfaReenrollmentRequired !== undefined && + typeof input.mfaReenrollmentRequired !== 'boolean' + ) + return rejected('INVALID_TEXT'); if ( input.expectedRevision !== undefined && (typeof input.expectedRevision !== 'number' || @@ -75,6 +82,9 @@ export function createIamTenantContextV1(input: { idempotencyKey: input.idempotencyKey, authorizationEpoch: input.authorizationEpoch, ...(input.mfaRequired === undefined ? {} : { mfaRequired: input.mfaRequired }), + ...(input.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: input.mfaReenrollmentRequired }), ...(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 00199e9b..6ca30025 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -93,6 +93,9 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP idempotencyKey: idempotencyKey(input), authorizationEpoch: principal.securityEpoch, mfaRequired: principal.mfaRequired, + ...(principal.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: principal.mfaReenrollmentRequired }), }); if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); return context.value; 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 c82b5805..f15a9423 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -108,6 +108,26 @@ void test('[IAM-001, IAM-009] lookup fails closed when persisted tenancy is inac assert.equal(await malformed.findCredential('user@example.com'), undefined); }); +void test('[IAM-015] credential lookup carries the live MFA re-enrollment gate when recovery set it', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + userIdentity: { + findUnique: async () => ({ + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 4, + mfaReenrollmentRequired: true, + }), + }, + }), + ); + assert.equal( + (await adapter.findCredential('user@example.com'))?.principal.mfaReenrollmentRequired, + true, + ); +}); + void test('[IAM-001, IAM-002] lookup does not authenticate users without an active workspace membership', async () => { const adapter = new PrismaCredentialLookupAdapter( database({ 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 acad931c..7fc37236 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -172,6 +172,23 @@ void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and assert.equal(await adapter.findPrincipalByAccessToken('not-a-token'), undefined); }); +void test('[IAM-015] live session lookup carries the MFA re-enrollment gate from the user record', async () => { + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const original = client.userIdentity.findUnique; + client.userIdentity.findUnique = async () => ({ + id: userId, + status: 'ACTIVE', + securityEpoch: 4, + mfaReenrollmentRequired: true, + }); + const session = await adapter.issue(principal, 'web'); + assert.equal((await adapter.findPrincipal(session.sessionId))?.mfaReenrollmentRequired, true); + client.userIdentity.findUnique = original; +}); + void test('[IAM-005] refresh rotation is transactional and reuse revokes the complete family', async () => { const { client, refreshTokens } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client, { 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 6e6cc77b..b31c6ac9 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -83,6 +83,18 @@ void test('uses the request id for read-only calls and rejects unsafe principal ); }); +void test('[IAM-015] carries the live recovery re-enrollment gate into protected request context', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => + Promise.resolve({ ...principal, mfaReenrollmentRequired: true }), + }); + const context = await adapter.resolve({ + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }); + assert.equal(context.mfaReenrollmentRequired, true); +}); + void test('requires an explicit idempotency key for authenticated mutations', async () => { const adapter = new SessionRequestTenantContextAdapter({ findPrincipalByAccessToken: () => Promise.resolve(principal), From 42bb3222cfe7c5a485a8d0c71b6baf43b8dc9899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:44:45 +0700 Subject: [PATCH 20/99] fix(iam): compare and set recovery challenge revisions --- .../prisma-recovery-repository.adapter.ts | 9 ++++- .../iam/prisma-recovery-repository.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) 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 1041facf..905c0ff6 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 @@ -63,6 +63,10 @@ interface ChallengeDelegateV1 extends UniqueDelegateV1>; readonly data: Readonly>; }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; } interface PasswordCredentialDelegateV1 { @@ -217,10 +221,11 @@ class PrismaRecoveryTransactionAdapter implements RecoveryTransactionPortV1 { return; if (challenge.revision !== existing.revision + 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); - await this.client.recoveryChallenge.update({ - where: { id: challenge.id }, + const updated = await this.client.recoveryChallenge.updateMany({ + where: { id: challenge.id, revision: existing.revision }, data: challengeData(challenge), }); + if (updated.count !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); } catch (error) { if (isConflict(error)) throw new Error('IAM_RECOVERY_CONFLICT'); throw error; 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 130b8110..dc14420f 100644 --- a/services/api/test/features/iam/prisma-recovery-repository.test.ts +++ b/services/api/test/features/iam/prisma-recovery-repository.test.ts @@ -122,6 +122,18 @@ function database() { challenges.set(String(where['id']), { ...row, ...data }); return { ...row, ...data }; }, + updateMany: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + const row = challenges.get(String(where['id'])); + if (!row || row['revision'] !== where['revision']) return { count: 0 }; + challenges.set(String(where['id']), { ...row, ...data }); + return { count: 1 }; + }, }, passwordCredential: { update: async ({ @@ -235,3 +247,28 @@ void test('[IAM-015] Prisma recovery completion rotates credential, epoch, MFA s assert.equal(state.calls.mfa, 1); assert.equal(state.challenges.get(challengeId)?.['status'], 'CONSUMED'); }); + +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); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + const stale = challenge(); + state.challenges.set(challengeId, { + ...state.challenges.get(challengeId), + revision: 2, + status: 'REVOKED', + revokedAt: new Date('2026-08-03T00:05:00.000Z'), + }); + state.client.recoveryChallenge.updateMany = async () => ({ count: 0 }); + await assert.rejects( + adapter.withTransaction((transaction) => + transaction.saveChallenge({ + ...stale, + status: 'CONSUMED', + consumedAt: '2026-08-03T00:10:00.000Z', + revision: 3, + }), + ), + /IAM_RECOVERY_REVISION_CONFLICT/u, + ); +}); From 12694288544941457a3c156494365d63b63f8765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:46:16 +0700 Subject: [PATCH 21/99] feat(iam): add bounded recovery admission --- docs/operations/iam-recovery-2026-08-03.md | 2 ++ docs/plans/requirement-traceability.json | 2 ++ .../in-memory-recovery-admission.adapter.ts | 36 +++++++++++++++++++ .../application/recovery-repository.port.ts | 5 +++ .../iam/application/recovery.service.ts | 10 ++++++ services/api/src/features/iam/iam.module.ts | 4 +++ .../features/iam/recovery-admission.test.ts | 24 +++++++++++++ .../features/iam/recovery.service.test.ts | 25 +++++++++++++ 8 files changed, 108 insertions(+) create mode 100644 services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts create mode 100644 services/api/test/features/iam/recovery-admission.test.ts diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md index bc903b88..dd660bd7 100644 --- a/docs/operations/iam-recovery-2026-08-03.md +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -10,12 +10,14 @@ It does not claim that IAM-015 or the IAM plan is complete. - Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required. - Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it. - Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state. +- Apply a bounded, replaceable recovery-admission port before account lookup; unknown and throttled addresses receive the same generic response. - Keep the public completion response free of bearer material; no session is automatically created. - Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. ## Evidence - Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`. +- Abuse-control tests: `services/api/test/features/iam/recovery-admission.test.ts`. - In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. - Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. - MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index a38ee31f..a02e9b21 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -9453,6 +9453,7 @@ "services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts", "services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts", "services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts", + "services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts", "services/api/src/features/iam/api/recovery.controller.ts" ], "testPaths": [ @@ -9462,6 +9463,7 @@ "services/api/test/features/iam/recovery.service.test.ts", "services/api/test/features/iam/prisma-recovery-repository.test.ts", "services/api/test/features/iam/recovery-crypto.test.ts", + "services/api/test/features/iam/recovery-admission.test.ts", "services/api/test/features/iam/mfa.service.test.ts", "services/api/test/features/iam/prisma-mfa-repository.test.ts", "services/api/test/features/iam/recovery-composition.test.ts", diff --git a/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts new file mode 100644 index 00000000..b89b06fe --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts @@ -0,0 +1,36 @@ +import type { RecoveryAdmissionPortV1 } from '../application/recovery-repository.port.js'; + +export interface InMemoryRecoveryAdmissionOptionsV1 { + readonly maxAttempts?: number; + readonly windowSeconds?: number; +} + +/** Deterministic bounded admission store for alpha/tests; production supplies a shared rate-limit adapter. */ +export class InMemoryRecoveryAdmissionAdapter implements RecoveryAdmissionPortV1 { + private readonly maxAttempts: number; + private readonly windowMs: number; + private readonly attempts = new Map(); + + public constructor(options: InMemoryRecoveryAdmissionOptionsV1 = {}) { + this.maxAttempts = options.maxAttempts ?? 3; + this.windowMs = (options.windowSeconds ?? 15 * 60) * 1_000; + if (!Number.isSafeInteger(this.maxAttempts) || this.maxAttempts < 1 || this.maxAttempts > 100) + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + if (!Number.isSafeInteger(this.windowMs) || this.windowMs < 1_000 || this.windowMs > 86_400_000) + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + } + + public async allow(keyDigest: string, issuedAt: string): Promise { + const at = Date.parse(issuedAt); + if (!/^[a-f0-9]{64}$/u.test(keyDigest) || !Number.isFinite(at)) return false; + const current = this.attempts.get(keyDigest) ?? []; + const kept = current.filter((timestamp) => at - timestamp < this.windowMs); + if (kept.length >= this.maxAttempts) { + this.attempts.set(keyDigest, kept); + return false; + } + kept.push(at); + this.attempts.set(keyDigest, kept); + return true; + } +} 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 3f9fe92a..8c021c60 100644 --- a/services/api/src/features/iam/application/recovery-repository.port.ts +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -30,6 +30,11 @@ export interface RecoveryDigestPortV1 { digestEmail(normalizedEmail: string): string; } +/** Optional abuse-control boundary; callers must not use it to reveal account existence. */ +export interface RecoveryAdmissionPortV1 { + allow(keyDigest: string, issuedAt: string): Promise; +} + export interface RecoveryDeliveryPortV1 { deliver(input: { readonly challengeId: StableIdentifierV1; diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index 9ca985eb..3c0b787b 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -13,6 +13,7 @@ import { import type { PasswordCredentialService } from './password-credential.service.js'; import type { RecoveryCompletionResultV1, + RecoveryAdmissionPortV1, RecoveryDeliveryPortV1, RecoveryDigestPortV1, RecoveryFailureCodeV1, @@ -42,6 +43,7 @@ export interface RecoveryServicePortsV1 { readonly ids: RecoveryIdGeneratorV1; readonly tokens: RecoveryTokenGeneratorV1; readonly clock?: RecoveryClockV1; + readonly admission?: RecoveryAdmissionPortV1; } function stable(input: unknown): StableIdentifierV1 | undefined { @@ -103,6 +105,14 @@ export class RecoveryService { } catch { return unavailable(); } + if (this.ports.admission) { + try { + if (!(await this.ports.admission.allow(emailDigest, issuedAt))) + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + } catch { + return unavailable(); + } + } const expiresAt = new Date( Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, ).toISOString(); diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 092345a1..f84163cb 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -119,6 +119,7 @@ import { } from './application/recovery.service.js'; import { IAM_RECOVERY_REPOSITORY_PORT, + type RecoveryAdmissionPortV1, type RecoveryDigestPortV1, type RecoveryDeliveryPortV1, type RecoveryRepositoryPortV1, @@ -129,6 +130,7 @@ import { randomIamRecoveryTokenV1, type IamRecoveryDigestKeyV1, } from './adapter/iam-recovery-crypto.adapter.js'; +import { InMemoryRecoveryAdmissionAdapter } from './adapter/in-memory-recovery-admission.adapter.js'; import { PrismaRecoveryRepositoryAdapter, type RecoveryDatabaseClientV1, @@ -212,6 +214,7 @@ export interface IamModuleOptions { readonly recoveryIdGenerator?: RecoveryIdGeneratorV1; readonly recoveryTokenGenerator?: RecoveryTokenGeneratorV1; readonly recoveryClock?: RecoveryClockV1; + readonly recoveryAdmission?: RecoveryAdmissionPortV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -367,6 +370,7 @@ export class IamModule { delivery: options.recoveryDelivery, ids: options.recoveryIdGenerator ?? randomIamRecoveryIdV1, tokens: options.recoveryTokenGenerator ?? randomIamRecoveryTokenV1, + admission: options.recoveryAdmission ?? new InMemoryRecoveryAdmissionAdapter(), ...(options.recoveryClock ? { clock: options.recoveryClock } : {}), }) : undefined); diff --git a/services/api/test/features/iam/recovery-admission.test.ts b/services/api/test/features/iam/recovery-admission.test.ts new file mode 100644 index 00000000..f3376872 --- /dev/null +++ b/services/api/test/features/iam/recovery-admission.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; + +const digest = 'a'.repeat(64); + +void test('[IAM-015] in-memory recovery admission bounds attempts and expires a window', async () => { + const admission = new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 2, windowSeconds: 60 }); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:00.000Z'), true); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:01.000Z'), true); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:02.000Z'), false); + assert.equal(await admission.allow(digest, '2026-08-03T00:01:01.000Z'), true); +}); + +void test('[IAM-015] in-memory recovery admission rejects malformed keys and unsafe configuration', async () => { + const admission = new InMemoryRecoveryAdmissionAdapter(); + assert.equal(await admission.allow('not-a-digest', '2026-08-03T00:00:00.000Z'), false); + assert.equal(await admission.allow(digest, 'not-a-timestamp'), false); + assert.throws( + () => new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 0 }), + /IAM_RECOVERY_ADMISSION_INVALID/u, + ); +}); diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index a662e1a4..3b762a01 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-repository.adapter.js'; +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'; @@ -23,6 +24,7 @@ function credentials() { function service( repository: InMemoryRecoveryRepositoryAdapter, delivery: RecoveryDeliveryPortV1 = { deliver: async () => undefined }, + admission?: InMemoryRecoveryAdmissionAdapter, ) { let id = 2; return new RecoveryService({ @@ -36,6 +38,7 @@ function service( ids: { next: () => `00000000-0000-4000-8000-${String(id++).padStart(12, '0')}` }, tokens: { next: () => token }, clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + ...(admission ? { admission } : {}), }); } @@ -61,6 +64,28 @@ 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 admission throttles known and unknown requests through one generic outcome', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const delivered: string[] = []; + const admission = new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 1, windowSeconds: 60 }); + const recovery = service( + repository, + { deliver: async ({ rawToken }) => void delivered.push(rawToken) }, + admission, + ); + assert.deepEqual(await recovery.request('unknown@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(delivered, []); + assert.equal(repository.challenge('a'.repeat(64)), undefined); +}); + void test('[IAM-015] completion atomically consumes the challenge, rotates the credential, advances the epoch, revokes sessions, and requires MFA re-enrollment', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ From 33882d99d7fa292c29e729160234654a8d842ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:50:09 +0700 Subject: [PATCH 22/99] fix(iam): avoid password hashing for invalid recovery tokens --- .../iam/application/recovery.service.ts | 28 ++++++++++++--- .../features/iam/recovery.service.test.ts | 35 ++++++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index 3c0b787b..d5eeb10a 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -160,24 +160,44 @@ export class RecoveryService { ): Promise { const raw = rawToken(rawTokenInput); if (!raw) return inputRejected('INVALID_TOKEN'); + let digest: string; + try { + digest = this.ports.digest.digestToken(raw); + } catch { + return unavailable(); + } + const now = timestamp(this.ports.clock); + if (!now) return unavailable(); + + // 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) => { + const challenge = await transaction.findChallengeByTokenDigest(digest); + return challenge ? consumeRecoveryChallengeV1(challenge, now) : undefined; + }); + } catch { + return unavailable(); + } + if (!candidate?.accepted) return inputRejected('INVALID_TOKEN'); + const credential = await this.ports.passwordCredentials.create(newPassword); if (!credential.accepted) { return inputRejected( credential.code === 'INVALID_PASSWORD' ? 'INVALID_INPUT' : 'RECOVERY_UNAVAILABLE', ); } - let digest: string; + let credentialId: StableIdentifierV1; try { - digest = this.ports.digest.digestToken(raw); const parsedId = stable(this.ports.ids.next()); if (!parsedId) return unavailable(); credentialId = parsedId; } catch { return unavailable(); } - const now = timestamp(this.ports.clock); - if (!now) return unavailable(); + try { return await this.ports.repository.withTransaction(async (transaction) => { const challenge = await transaction.findChallengeByTokenDigest(digest); diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 3b762a01..760a3d83 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -21,15 +21,30 @@ function credentials() { }); } +function countingCredentials(counter: { value: number }) { + return new PasswordCredentialService({ + hash: async (password) => { + counter.value += 1; + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, + }; + }, + verify: async () => true, + }); +} + function service( repository: InMemoryRecoveryRepositoryAdapter, delivery: RecoveryDeliveryPortV1 = { deliver: async () => undefined }, admission?: InMemoryRecoveryAdmissionAdapter, + passwordCredentials: PasswordCredentialService = credentials(), ) { let id = 2; return new RecoveryService({ repository, - passwordCredentials: credentials(), + passwordCredentials, digest: { digestToken: () => 'a'.repeat(64), digestEmail: () => 'b'.repeat(64), @@ -42,6 +57,24 @@ function service( }); } +void test('[IAM-015] invalid recovery tokens do not invoke the password hasher', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + const counter = { value: 0 }; + const recovery = service(repository, undefined, undefined, countingCredentials(counter)); + + assert.deepEqual( + await recovery.complete( + 'invalid-token-abcdefghijklmnopqrstuvwxyz-123456', + 'new correct horse battery staple', + ), + { + accepted: false, + code: 'INVALID_TOKEN', + }, + ); + assert.equal(counter.value, 0); +}); + void test('[IAM-015] recovery request is generic for unknown email and stores a delivered hashed challenge for a known account', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); From c6b72281fe7a1aebf0eb652c6983305c8cf38a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:52:34 +0700 Subject: [PATCH 23/99] feat(iam): enforce recovery mfa gate on approvals --- packages/domain/src/approval/v1.ts | 1 + .../jra/application/approval.service.ts | 8 ++++- .../features/jra/approval.service.test.ts | 29 ++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/domain/src/approval/v1.ts b/packages/domain/src/approval/v1.ts index 4192603c..3fe6b24e 100644 --- a/packages/domain/src/approval/v1.ts +++ b/packages/domain/src/approval/v1.ts @@ -73,6 +73,7 @@ export type ApprovalErrorCodeV1 = | 'SUBJECT_HASH_MISMATCH' | 'SELF_APPROVAL_FORBIDDEN' | 'MFA_REQUIRED' + | 'MFA_REENROLLMENT_REQUIRED' | 'REQUEST_NOT_OPEN'; export type ApprovalResultV1 = diff --git a/services/api/src/features/jra/application/approval.service.ts b/services/api/src/features/jra/application/approval.service.ts index 0bb433e7..7a1383f3 100644 --- a/services/api/src/features/jra/application/approval.service.ts +++ b/services/api/src/features/jra/application/approval.service.ts @@ -14,7 +14,12 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { ApprovalRepositoryPortV1 } from './approval-repository.port.js'; function rejected( - code: 'INVALID_IDENTIFIER' | 'INVALID_ROLE' | 'REQUEST_NOT_OPEN' | 'SUBJECT_HASH_MISMATCH', + code: + | 'INVALID_IDENTIFIER' + | 'INVALID_ROLE' + | 'REQUEST_NOT_OPEN' + | 'SUBJECT_HASH_MISMATCH' + | 'MFA_REENROLLMENT_REQUIRED', ): ApprovalResultV1 { return Object.freeze({ accepted: false, code }); } @@ -72,6 +77,7 @@ export class ApprovalService { readonly decision: ApprovalDecisionRecordV1; }> > { + if (context.mfaReenrollmentRequired === true) return rejected('MFA_REENROLLMENT_REQUIRED'); return this.repository.withTransaction(context, async (transaction) => { const request = await transaction.findRequest(context, input.requestId); if (!request) return rejected('INVALID_IDENTIFIER'); diff --git a/services/api/test/features/jra/approval.service.test.ts b/services/api/test/features/jra/approval.service.test.ts index bac49efd..664ff6ea 100644 --- a/services/api/test/features/jra/approval.service.test.ts +++ b/services/api/test/features/jra/approval.service.test.ts @@ -41,7 +41,7 @@ const ids = { changedDecisionId: stable(changedDecisionId), }; -function context(key: string) { +function context(key: string, options: { readonly mfaReenrollmentRequired?: boolean } = {}) { const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', @@ -52,12 +52,39 @@ function context(key: string) { correlationId: ids.correlationId, idempotencyKey: key, authorizationEpoch: 1, + ...options, }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; } +void test('[IAM-015, JRA-011] recovery-forced MFA re-enrollment blocks privileged approval decisions', async () => { + const service = new ApprovalService(new InMemoryApprovalRepositoryAdapter()); + assert.equal( + (await service.publishPolicy(context('policy-gated'), policyInput())).accepted, + true, + ); + assert.equal( + (await service.openRequest(context('request-gated'), requestInput())).accepted, + true, + ); + + assert.deepEqual( + await service.decide(context('decision-gated', { mfaReenrollmentRequired: true }), { + requestId: ids.requestId, + decisionId: ids.decisionId, + actorId: ids.approverId, + decision: 'APPROVE', + subjectHash, + mfaAssertionId: '00000000-0000-4000-8000-000000000023', + decidedAt: '2026-01-01T00:01:00.000Z', + actorRole: 'ADMIN', + }), + { accepted: false, code: 'MFA_REENROLLMENT_REQUIRED' }, + ); +}); + function policyInput() { return { policyId: ids.policyId, From 94b87665c899b8b14a61d11b25fe1bdc1a5dd086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:54:42 +0700 Subject: [PATCH 24/99] feat(iam): guard high-risk step-up during mfa reenrollment --- packages/domain/src/mfa/v1.ts | 4 ++++ packages/domain/test/mfa-v1.test.mjs | 4 ++++ services/api/src/features/iam/application/mfa.service.ts | 3 ++- services/api/test/features/iam/mfa.service.test.ts | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/mfa/v1.ts b/packages/domain/src/mfa/v1.ts index 876b6017..de7d2e20 100644 --- a/packages/domain/src/mfa/v1.ts +++ b/packages/domain/src/mfa/v1.ts @@ -57,6 +57,7 @@ export type MfaErrorCodeV1 = | 'FACTOR_PROOF_INVALID' | 'RECOVERY_CODE_INVALID' | 'RECOVERY_CODE_USED' + | 'MFA_REENROLLMENT_REQUIRED' | 'STEP_UP_REQUIRED'; export type MfaResultV1 = @@ -216,8 +217,11 @@ export function requiresStepUpV1( assertion: StepUpAssertionV1 | undefined, principalId: StableIdentifierV1, now: unknown, + mfaReenrollmentRequired = false, ): MfaResultV1 { if (risk !== 'NORMAL' && risk !== 'HIGH' && risk !== 'CRITICAL') return rejected('INVALID_STATE'); + if (risk !== 'NORMAL' && mfaReenrollmentRequired === true) + return rejected('MFA_REENROLLMENT_REQUIRED'); if (risk === 'NORMAL') return Object.freeze({ accepted: true, value: true }); if (assertion && isFreshStepUpV1(assertion, principalId, now)) return Object.freeze({ accepted: true, value: true }); diff --git a/packages/domain/test/mfa-v1.test.mjs b/packages/domain/test/mfa-v1.test.mjs index 3eb02b07..1e2c74b3 100644 --- a/packages/domain/test/mfa-v1.test.mjs +++ b/packages/domain/test/mfa-v1.test.mjs @@ -90,4 +90,8 @@ test('[IAM-012] high-risk operations require a fresh, principal-bound step-up as accepted: false, code: 'STEP_UP_REQUIRED', }); + assert.deepEqual(requiresStepUpV1('HIGH', assertion, id('2'), '2026-01-01T00:05:00.000Z', true), { + accepted: false, + code: 'MFA_REENROLLMENT_REQUIRED', + }); }); diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index f6cb3b9c..bd0bbf94 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -195,7 +195,8 @@ export class MfaService { assertion: Parameters[1], principalId: StableIdentifierV1, now: unknown, + mfaReenrollmentRequired = false, ): MfaResultV1 { - return requiresStepUpV1(risk, assertion, principalId, now); + return requiresStepUpV1(risk, assertion, principalId, now, mfaReenrollmentRequired); } } diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 186d416a..d358413a 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -130,6 +130,10 @@ void test('[IAM-012] high-risk operations require a fresh step-up assertion', () service.requireStepUp('HIGH', assertion, userId as never, '2026-01-01T00:05:00.000Z').accepted, true, ); + assert.deepEqual(service.requireStepUp('HIGH', assertion, userId as never, at, true), { + accepted: false, + code: 'MFA_REENROLLMENT_REQUIRED', + }); }); void test('[IAM-015] default recovery-code matching compares normalized bytes safely', () => { From e60170f6c52098b4af53836bb0fff79e57c9dc5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:56:43 +0700 Subject: [PATCH 25/99] feat(iam): add shared recovery admission adapter --- docs/operations/iam-recovery-2026-08-03.md | 3 +- .../redis-recovery-admission.adapter.ts | 56 ++++++++++++++++++ .../redis-recovery-admission.adapter.test.ts | 57 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts create mode 100644 services/api/test/features/iam/redis-recovery-admission.adapter.test.ts diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md index dd660bd7..51efa875 100644 --- a/docs/operations/iam-recovery-2026-08-03.md +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -11,13 +11,14 @@ It does not claim that IAM-015 or the IAM plan is complete. - Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it. - Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state. - Apply a bounded, replaceable recovery-admission port before account lookup; unknown and throttled addresses receive the same generic response. +- The `RedisRecoveryAdmissionAdapter` implements that port for horizontally scaled deployments. It accepts only keyed digests, namespaces counter keys, requires an injected atomic `INCR`/`PEXPIRE` implementation, and fails closed on malformed input or counter failure. The in-memory adapter remains the alpha default until a Redis client is provisioned. - Keep the public completion response free of bearer material; no session is automatically created. - Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. ## Evidence - Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`. -- Abuse-control tests: `services/api/test/features/iam/recovery-admission.test.ts`. +- Abuse-control tests: `services/api/test/features/iam/recovery-admission.test.ts` and `redis-recovery-admission.adapter.test.ts`. - In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. - Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. - MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`. diff --git a/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts new file mode 100644 index 00000000..8d469f36 --- /dev/null +++ b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts @@ -0,0 +1,56 @@ +import type { RecoveryAdmissionPortV1 } from '../application/recovery-repository.port.js'; + +/** Narrow adapter boundary for an atomic Redis INCR/PEXPIRE operation. */ +export interface RecoveryAdmissionCounterPortV1 { + incrementWindow(input: { readonly key: string; readonly ttlMs: number }): Promise; +} + +export interface RedisRecoveryAdmissionOptionsV1 { + readonly maxAttempts?: number; + readonly windowSeconds?: number; + readonly keyPrefix?: string; +} + +/** + * Shared admission policy for horizontally scaled API instances. + * The injected counter must increment and apply the TTL atomically (for example, + * with one Redis Lua script); this class never stores raw email addresses. + */ +export class RedisRecoveryAdmissionAdapter implements RecoveryAdmissionPortV1 { + private readonly maxAttempts: number; + private readonly windowMs: number; + private readonly keyPrefix: string; + + public constructor( + private readonly counter: RecoveryAdmissionCounterPortV1, + options: RedisRecoveryAdmissionOptionsV1 = {}, + ) { + this.maxAttempts = options.maxAttempts ?? 3; + this.windowMs = (options.windowSeconds ?? 15 * 60) * 1_000; + this.keyPrefix = options.keyPrefix ?? 'databreeze:iam:recovery:admission:v1:'; + if ( + !Number.isSafeInteger(this.maxAttempts) || + this.maxAttempts < 1 || + this.maxAttempts > 100 || + !Number.isSafeInteger(this.windowMs) || + this.windowMs < 1_000 || + this.windowMs > 86_400_000 || + !/^[\w:-]{1,120}$/u.test(this.keyPrefix) + ) { + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + } + } + + public async allow(keyDigest: string, issuedAt: string): Promise { + if (!/^[a-f0-9]{64}$/u.test(keyDigest) || !Number.isFinite(Date.parse(issuedAt))) return false; + try { + const count = await this.counter.incrementWindow({ + key: `${this.keyPrefix}${keyDigest}`, + ttlMs: this.windowMs, + }); + return Number.isSafeInteger(count) && count >= 1 && count <= this.maxAttempts; + } catch { + return false; + } + } +} diff --git a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts new file mode 100644 index 00000000..d54ceb72 --- /dev/null +++ b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; + +const digest = 'a'.repeat(64); +const issuedAt = '2026-08-03T00:00:00.000Z'; + +void test('[IAM-015] shared recovery admission uses a namespaced digest key and bounded counter', async () => { + const calls: Array<{ readonly key: string; readonly ttlMs: number }> = []; + let count = 0; + const admission = new RedisRecoveryAdmissionAdapter( + { + incrementWindow: async (input) => { + calls.push(input); + count += 1; + return count; + }, + }, + { maxAttempts: 2, windowSeconds: 60 }, + ); + + assert.equal(await admission.allow(digest, issuedAt), true); + assert.equal(await admission.allow(digest, issuedAt), true); + assert.equal(await admission.allow(digest, issuedAt), false); + assert.deepEqual(calls, [ + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + ]); +}); + +void test('[IAM-015] shared recovery admission fails closed for malformed input and counter outages', async () => { + let calls = 0; + const admission = new RedisRecoveryAdmissionAdapter({ + incrementWindow: async () => { + calls += 1; + throw new Error('redis unavailable'); + }, + }); + + assert.equal(await admission.allow('not-a-digest', issuedAt), false); + assert.equal(await admission.allow(digest, 'not-a-timestamp'), false); + assert.equal(await admission.allow(digest, issuedAt), false); + assert.equal(calls, 1); +}); + +void test('[IAM-015] shared recovery admission rejects unsafe configuration', () => { + assert.throws( + () => + new RedisRecoveryAdmissionAdapter( + { incrementWindow: async () => 1 }, + { keyPrefix: 'raw email ' }, + ), + /IAM_RECOVERY_ADMISSION_INVALID/u, + ); +}); From 8f0a7c248e62e322420a012d66b9c2e6e6edbc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:58:29 +0700 Subject: [PATCH 26/99] feat(iam): compose shared recovery admission --- .../application/recovery-repository.port.ts | 1 + services/api/src/features/iam/iam.module.ts | 23 ++++++++++++++++++- .../features/iam/recovery-composition.test.ts | 21 ++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) 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 8c021c60..c0d0996b 100644 --- a/services/api/src/features/iam/application/recovery-repository.port.ts +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -4,6 +4,7 @@ import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { PasswordCredentialV1 } from '../domain/password-credential.js'; export const IAM_RECOVERY_REPOSITORY_PORT = Symbol('IAM_RECOVERY_REPOSITORY_PORT'); +export const IAM_RECOVERY_ADMISSION_PORT = Symbol('IAM_RECOVERY_ADMISSION_PORT'); export interface RecoveryCompletionInputV1 { readonly challenge: RecoveryChallengeV1; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index f84163cb..ef85659f 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -119,6 +119,7 @@ import { } from './application/recovery.service.js'; import { IAM_RECOVERY_REPOSITORY_PORT, + IAM_RECOVERY_ADMISSION_PORT, type RecoveryAdmissionPortV1, type RecoveryDigestPortV1, type RecoveryDeliveryPortV1, @@ -131,6 +132,11 @@ import { type IamRecoveryDigestKeyV1, } from './adapter/iam-recovery-crypto.adapter.js'; import { InMemoryRecoveryAdmissionAdapter } from './adapter/in-memory-recovery-admission.adapter.js'; +import { + RedisRecoveryAdmissionAdapter, + type RecoveryAdmissionCounterPortV1, + type RedisRecoveryAdmissionOptionsV1, +} from './adapter/redis-recovery-admission.adapter.js'; import { PrismaRecoveryRepositoryAdapter, type RecoveryDatabaseClientV1, @@ -215,6 +221,8 @@ export interface IamModuleOptions { readonly recoveryTokenGenerator?: RecoveryTokenGeneratorV1; readonly recoveryClock?: RecoveryClockV1; readonly recoveryAdmission?: RecoveryAdmissionPortV1; + readonly recoveryAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly recoveryAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -357,6 +365,14 @@ export class IamModule { (options.recoveryDigestKey === undefined ? undefined : new HmacSha256IamRecoveryDigestAdapter(options.recoveryDigestKey)); + const recoveryAdmission = + options.recoveryAdmission ?? + (options.recoveryAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter() + : new RedisRecoveryAdmissionAdapter( + options.recoveryAdmissionCounter, + options.recoveryAdmissionOptions, + )); const recoveryService = options.recoveryService ?? (recoveryRepository && @@ -370,7 +386,7 @@ export class IamModule { delivery: options.recoveryDelivery, ids: options.recoveryIdGenerator ?? randomIamRecoveryIdV1, tokens: options.recoveryTokenGenerator ?? randomIamRecoveryTokenV1, - admission: options.recoveryAdmission ?? new InMemoryRecoveryAdmissionAdapter(), + admission: recoveryAdmission, ...(options.recoveryClock ? { clock: options.recoveryClock } : {}), }) : undefined); @@ -410,6 +426,7 @@ export class IamModule { if (registrationRepository) exports.unshift(IAM_REGISTRATION_REPOSITORY_PORT); if (registrationService) exports.unshift(IAM_REGISTRATION_SERVICE); if (recoveryRepository) exports.unshift(IAM_RECOVERY_REPOSITORY_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); return { module: IamModule, @@ -555,6 +572,10 @@ export class IamModule { provide: IAM_RECOVERY_SERVICE, useValue: recoveryService, }, + { + provide: IAM_RECOVERY_ADMISSION_PORT, + useValue: recoveryAdmission, + }, ] : []), { diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts index d5e6fbcd..7db281d2 100644 --- a/services/api/test/features/iam/recovery-composition.test.ts +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { PrismaRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-recovery-repository.adapter.js'; -import { IAM_RECOVERY_REPOSITORY_PORT } from '../../../src/features/iam/application/recovery-repository.port.js'; +import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; +import { + IAM_RECOVERY_ADMISSION_PORT, + IAM_RECOVERY_REPOSITORY_PORT, +} from '../../../src/features/iam/application/recovery-repository.port.js'; import { IAM_RECOVERY_SERVICE, RecoveryService, @@ -56,3 +60,18 @@ void test('[IAM-015] durable recovery composition requires password, digest, del assert.ok(repository.useValue instanceof PrismaRecoveryRepositoryAdapter); assert.ok(service.useValue instanceof RecoveryService); }); + +void test('[IAM-015] recovery composition selects the shared admission adapter when a Redis counter is provided', () => { + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { deliver: async () => undefined }, + recoveryAdmissionCounter: { incrementWindow: async () => 1 }, + recoveryAdmissionOptions: { maxAttempts: 5, windowSeconds: 30 }, + }); + const admission = provider(configured, IAM_RECOVERY_ADMISSION_PORT); + assert.ok(admission && 'useValue' in admission); + if (!admission || !('useValue' in admission)) return; + assert.ok(admission.useValue instanceof RedisRecoveryAdmissionAdapter); +}); From 32d868702831b321ce268c5ef11bafd3bd6ac119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 00:59:27 +0700 Subject: [PATCH 27/99] feat(iam): bind recovery admission to redis eval --- .../redis-recovery-admission.adapter.ts | 40 +++++++++++++++++ .../redis-recovery-admission.adapter.test.ts | 43 ++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts index 8d469f36..d237084c 100644 --- a/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts +++ b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts @@ -5,6 +5,46 @@ export interface RecoveryAdmissionCounterPortV1 { incrementWindow(input: { readonly key: string; readonly ttlMs: number }): Promise; } +export interface RedisEvalClientPortV1 { + eval(script: string, keys: readonly string[], arguments_: readonly string[]): Promise; +} + +export const REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1 = [ + 'local count = redis.call("INCR", KEYS[1])', + 'if count == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[1]) end', + 'return count', +].join('\n'); + +/** Binds the counter contract to clients that expose Redis EVAL with explicit keys/arguments. */ +export class RedisEvalRecoveryAdmissionCounterAdapter implements RecoveryAdmissionCounterPortV1 { + public constructor(private readonly client: RedisEvalClientPortV1) {} + + public async incrementWindow(input: { + readonly key: string; + readonly ttlMs: number; + }): Promise { + if ( + typeof input.key !== 'string' || + input.key.length === 0 || + !Number.isSafeInteger(input.ttlMs) || + input.ttlMs < 1_000 || + input.ttlMs > 86_400_000 + ) { + throw new Error('IAM_RECOVERY_ADMISSION_COUNTER_INVALID'); + } + const result = await this.client.eval( + REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + [input.key], + [String(input.ttlMs)], + ); + const count = typeof result === 'number' ? result : Number(result); + if (!Number.isSafeInteger(count) || count < 1) { + throw new Error('IAM_RECOVERY_ADMISSION_COUNTER_INVALID'); + } + return count; + } +} + export interface RedisRecoveryAdmissionOptionsV1 { readonly maxAttempts?: number; readonly windowSeconds?: number; diff --git a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts index d54ceb72..8e62eb2e 100644 --- a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts +++ b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; +import { + REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + RedisEvalRecoveryAdmissionCounterAdapter, + RedisRecoveryAdmissionAdapter, +} from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; const digest = 'a'.repeat(64); const issuedAt = '2026-08-03T00:00:00.000Z'; @@ -55,3 +59,40 @@ void test('[IAM-015] shared recovery admission rejects unsafe configuration', () /IAM_RECOVERY_ADMISSION_INVALID/u, ); }); + +void test('[IAM-015] Redis counter wrapper uses one atomic script and validates returned counts', async () => { + const calls: Array<{ + readonly script: string; + readonly keys: readonly string[]; + readonly args: readonly string[]; + }> = []; + const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ + eval: async (script, keys, args) => { + calls.push({ script, keys, args }); + return '4'; + }, + }); + + assert.equal(await counter.incrementWindow({ key: 'databreeze:key', ttlMs: 15_000 }), 4); + assert.deepEqual(calls, [ + { + script: REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + keys: ['databreeze:key'], + args: ['15000'], + }, + ]); +}); + +void test('[IAM-015] Redis counter wrapper rejects invalid TTLs and malformed replies', async () => { + const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ + eval: async () => 'not-a-count', + }); + await assert.rejects( + counter.incrementWindow({ key: 'databreeze:key', ttlMs: 999 }), + /IAM_RECOVERY_ADMISSION_COUNTER_INVALID/u, + ); + await assert.rejects( + counter.incrementWindow({ key: 'databreeze:key', ttlMs: 15_000 }), + /IAM_RECOVERY_ADMISSION_COUNTER_INVALID/u, + ); +}); From 20b592ef7f7bcac7303f58abdbaab373088f1e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:00:38 +0700 Subject: [PATCH 28/99] feat(iam): provide context-aware mfa step-up --- .../src/features/iam/application/mfa.service.ts | 17 +++++++++++++++++ .../api/test/features/iam/mfa.service.test.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index bd0bbf94..4a06fe32 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -12,6 +12,7 @@ import { } from '@databreeze/domain/tenant-scope/v1'; import type { MfaRepositoryPortV1 } from './mfa-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; export const MFA_SERVICE = Symbol('MFA_SERVICE'); @@ -199,4 +200,20 @@ export class MfaService { ): MfaResultV1 { return requiresStepUpV1(risk, assertion, principalId, now, mfaReenrollmentRequired); } + + /** Prefer this boundary for authenticated actions so the recovery gate travels with context. */ + public requireStepUpForContext( + context: Pick, + risk: unknown, + assertion: Parameters[1], + now: unknown, + ): MfaResultV1 { + return this.requireStepUp( + risk, + assertion, + context.actorId, + now, + context.mfaReenrollmentRequired === true, + ); + } } diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index d358413a..bf4db01b 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -134,6 +134,15 @@ void test('[IAM-012] high-risk operations require a fresh step-up assertion', () accepted: false, code: 'MFA_REENROLLMENT_REQUIRED', }); + assert.deepEqual( + service.requireStepUpForContext( + { actorId: userId as never, mfaReenrollmentRequired: true }, + 'HIGH', + assertion, + at, + ), + { accepted: false, code: 'MFA_REENROLLMENT_REQUIRED' }, + ); }); void test('[IAM-015] default recovery-code matching compares normalized bytes safely', () => { From 1302f88e954bc606d5a4aec87f048ef31600a4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:01:42 +0700 Subject: [PATCH 29/99] feat(iam): bound recovery completion attempts --- .../application/recovery-repository.port.ts | 3 +++ .../iam/application/recovery.service.ts | 10 ++++++++ services/api/src/features/iam/iam.module.ts | 10 ++++++++ .../features/iam/recovery.service.test.ts | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+) 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 c0d0996b..72616f2f 100644 --- a/services/api/src/features/iam/application/recovery-repository.port.ts +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -5,6 +5,9 @@ import type { PasswordCredentialV1 } from '../domain/password-credential.js'; export const IAM_RECOVERY_REPOSITORY_PORT = Symbol('IAM_RECOVERY_REPOSITORY_PORT'); export const IAM_RECOVERY_ADMISSION_PORT = Symbol('IAM_RECOVERY_ADMISSION_PORT'); +export const IAM_RECOVERY_COMPLETION_ADMISSION_PORT = Symbol( + 'IAM_RECOVERY_COMPLETION_ADMISSION_PORT', +); export interface RecoveryCompletionInputV1 { readonly challenge: RecoveryChallengeV1; diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts index d5eeb10a..4a339b06 100644 --- a/services/api/src/features/iam/application/recovery.service.ts +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -44,6 +44,7 @@ export interface RecoveryServicePortsV1 { readonly tokens: RecoveryTokenGeneratorV1; readonly clock?: RecoveryClockV1; readonly admission?: RecoveryAdmissionPortV1; + readonly completionAdmission?: RecoveryAdmissionPortV1; } function stable(input: unknown): StableIdentifierV1 | undefined { @@ -169,6 +170,15 @@ export class RecoveryService { const now = timestamp(this.ports.clock); if (!now) return unavailable(); + if (this.ports.completionAdmission) { + try { + if (!(await this.ports.completionAdmission.allow(digest, now))) + return inputRejected('INVALID_TOKEN'); + } catch { + return unavailable(); + } + } + // 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; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index ef85659f..94fd66d4 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -120,6 +120,7 @@ import { import { IAM_RECOVERY_REPOSITORY_PORT, IAM_RECOVERY_ADMISSION_PORT, + IAM_RECOVERY_COMPLETION_ADMISSION_PORT, type RecoveryAdmissionPortV1, type RecoveryDigestPortV1, type RecoveryDeliveryPortV1, @@ -223,6 +224,7 @@ export interface IamModuleOptions { readonly recoveryAdmission?: RecoveryAdmissionPortV1; readonly recoveryAdmissionCounter?: RecoveryAdmissionCounterPortV1; readonly recoveryAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; + readonly recoveryCompletionAdmission?: RecoveryAdmissionPortV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -373,6 +375,8 @@ export class IamModule { options.recoveryAdmissionCounter, options.recoveryAdmissionOptions, )); + const recoveryCompletionAdmission = + options.recoveryCompletionAdmission ?? new InMemoryRecoveryAdmissionAdapter(); const recoveryService = options.recoveryService ?? (recoveryRepository && @@ -387,6 +391,7 @@ export class IamModule { ids: options.recoveryIdGenerator ?? randomIamRecoveryIdV1, tokens: options.recoveryTokenGenerator ?? randomIamRecoveryTokenV1, admission: recoveryAdmission, + completionAdmission: recoveryCompletionAdmission, ...(options.recoveryClock ? { clock: options.recoveryClock } : {}), }) : undefined); @@ -427,6 +432,7 @@ export class IamModule { if (registrationService) exports.unshift(IAM_REGISTRATION_SERVICE); if (recoveryRepository) exports.unshift(IAM_RECOVERY_REPOSITORY_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_ADMISSION_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_COMPLETION_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); return { module: IamModule, @@ -576,6 +582,10 @@ export class IamModule { provide: IAM_RECOVERY_ADMISSION_PORT, useValue: recoveryAdmission, }, + { + provide: IAM_RECOVERY_COMPLETION_ADMISSION_PORT, + useValue: recoveryCompletionAdmission, + }, ] : []), { diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index 760a3d83..d3ea9d71 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -40,6 +40,7 @@ function service( delivery: RecoveryDeliveryPortV1 = { deliver: async () => undefined }, admission?: InMemoryRecoveryAdmissionAdapter, passwordCredentials: PasswordCredentialService = credentials(), + completionAdmission?: InMemoryRecoveryAdmissionAdapter, ) { let id = 2; return new RecoveryService({ @@ -54,6 +55,7 @@ function service( tokens: { next: () => token }, clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, ...(admission ? { admission } : {}), + ...(completionAdmission ? { completionAdmission } : {}), }); } @@ -75,6 +77,27 @@ void test('[IAM-015] invalid recovery tokens do not invoke the password hasher', assert.equal(counter.value, 0); }); +void test('[IAM-015] completion admission consumes unknown token attempts before challenge lookup', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const completionAdmission = new InMemoryRecoveryAdmissionAdapter({ + maxAttempts: 1, + windowSeconds: 60, + }); + const recovery = service(repository, undefined, undefined, credentials(), completionAdmission); + + assert.deepEqual(await recovery.complete(token, 'new correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); + assert.equal((await recovery.request('user@example.com')).accepted, true); + assert.deepEqual(await recovery.complete(token, 'new correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); + void test('[IAM-015] recovery request is generic for unknown email and stores a delivered hashed challenge for a known account', async () => { const repository = new InMemoryRecoveryRepositoryAdapter(); repository.seed({ email: 'user@example.com', userId }); From fe8380daa952fc08f90505e7253cd6e05b634b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:02:42 +0700 Subject: [PATCH 30/99] feat(iam): separate recovery completion admission --- docs/operations/iam-recovery-2026-08-03.md | 1 + services/api/src/features/iam/iam.module.ts | 10 +++++++++- .../features/iam/recovery-composition.test.ts | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md index 51efa875..b0564bbc 100644 --- a/docs/operations/iam-recovery-2026-08-03.md +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -12,6 +12,7 @@ It does not claim that IAM-015 or the IAM plan is complete. - Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state. - Apply a bounded, replaceable recovery-admission port before account lookup; unknown and throttled addresses receive the same generic response. - The `RedisRecoveryAdmissionAdapter` implements that port for horizontally scaled deployments. It accepts only keyed digests, namespaces counter keys, requires an injected atomic `INCR`/`PEXPIRE` implementation, and fails closed on malformed input or counter failure. The in-memory adapter remains the alpha default until a Redis client is provisioned. +- Completion attempts use a separate admission port and, when Redis is configured, a distinct `databreeze:iam:recovery:completion:v1:` namespace so email-request and token-brute-force limits cannot collide. - Keep the public completion response free of bearer material; no session is automatically created. - Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 94fd66d4..9f6ca8a4 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -225,6 +225,8 @@ export interface IamModuleOptions { readonly recoveryAdmissionCounter?: RecoveryAdmissionCounterPortV1; readonly recoveryAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; readonly recoveryCompletionAdmission?: RecoveryAdmissionPortV1; + readonly recoveryCompletionAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly recoveryCompletionAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -376,7 +378,13 @@ export class IamModule { options.recoveryAdmissionOptions, )); const recoveryCompletionAdmission = - options.recoveryCompletionAdmission ?? new InMemoryRecoveryAdmissionAdapter(); + options.recoveryCompletionAdmission ?? + (options.recoveryCompletionAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter() + : new RedisRecoveryAdmissionAdapter(options.recoveryCompletionAdmissionCounter, { + keyPrefix: 'databreeze:iam:recovery:completion:v1:', + ...options.recoveryCompletionAdmissionOptions, + })); const recoveryService = options.recoveryService ?? (recoveryRepository && diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts index 7db281d2..ae8d8fa3 100644 --- a/services/api/test/features/iam/recovery-composition.test.ts +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -5,6 +5,7 @@ import { PrismaRecoveryRepositoryAdapter } from '../../../src/features/iam/adapt import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; import { IAM_RECOVERY_ADMISSION_PORT, + IAM_RECOVERY_COMPLETION_ADMISSION_PORT, IAM_RECOVERY_REPOSITORY_PORT, } from '../../../src/features/iam/application/recovery-repository.port.js'; import { @@ -75,3 +76,17 @@ void test('[IAM-015] recovery composition selects the shared admission adapter w if (!admission || !('useValue' in admission)) return; assert.ok(admission.useValue instanceof RedisRecoveryAdmissionAdapter); }); + +void test('[IAM-015] recovery composition gives completion counters a separate Redis namespace', () => { + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { deliver: async () => undefined }, + recoveryCompletionAdmissionCounter: { incrementWindow: async () => 1 }, + }); + const admission = provider(configured, IAM_RECOVERY_COMPLETION_ADMISSION_PORT); + assert.ok(admission && 'useValue' in admission); + if (!admission || !('useValue' in admission)) return; + assert.ok(admission.useValue instanceof RedisRecoveryAdmissionAdapter); +}); From fee95797c92e7c56527d40f83466b10f1f5e0edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:18:20 +0700 Subject: [PATCH 31/99] fix(repo): align iam mocks with async lint contract --- docs/plans/requirement-traceability.json | 4 ++ packages/domain/src/recovery/v1.ts | 1 - ...emory-iam-invitation-repository.adapter.ts | 6 +++ .../in-memory-recovery-admission.adapter.ts | 1 + .../in-memory-recovery-repository.adapter.ts | 17 +++++-- ...-memory-registration-repository.adapter.ts | 6 ++- ...risma-iam-invitation-repository.adapter.ts | 18 ------- .../iam/iam-invitation-crypto.adapter.test.ts | 2 + .../iam/invitation-composition.test.ts | 13 ++++- .../iam/invitation-controller.test.ts | 33 ++++++++++-- .../features/iam/invitation-service.test.ts | 32 ++++++++---- .../prisma-iam-invitation-repository.test.ts | 15 ++++-- ...sma-principal-email-lookup.adapter.test.ts | 1 + .../iam/prisma-recovery-repository.test.ts | 32 +++++++++--- .../prisma-registration-repository.test.ts | 18 ++++--- .../iam/prisma-session-lifecycle.test.ts | 2 +- .../features/iam/recovery-composition.test.ts | 50 +++++++++++++++---- .../features/iam/recovery-controller.test.ts | 26 +++++++--- .../test/features/iam/recovery-http.test.ts | 19 ++++--- .../features/iam/recovery.service.test.ts | 40 +++++++++++---- .../redis-recovery-admission.adapter.test.ts | 15 +++++- .../iam/registration-composition.test.ts | 18 ++++--- .../iam/registration-controller.test.ts | 15 ++++-- .../features/iam/registration-http.test.ts | 18 ++++--- .../features/iam/registration.service.test.ts | 30 ++++++++--- 25 files changed, 313 insertions(+), 119 deletions(-) diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index a02e9b21..8adc4881 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -9454,6 +9454,9 @@ "services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts", "services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts", "services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts", + "services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts", + "services/api/src/features/iam/application/recovery-repository.port.ts", + "services/api/src/features/iam/iam.module.ts", "services/api/src/features/iam/api/recovery.controller.ts" ], "testPaths": [ @@ -9464,6 +9467,7 @@ "services/api/test/features/iam/prisma-recovery-repository.test.ts", "services/api/test/features/iam/recovery-crypto.test.ts", "services/api/test/features/iam/recovery-admission.test.ts", + "services/api/test/features/iam/redis-recovery-admission.adapter.test.ts", "services/api/test/features/iam/mfa.service.test.ts", "services/api/test/features/iam/prisma-mfa-repository.test.ts", "services/api/test/features/iam/recovery-composition.test.ts", diff --git a/packages/domain/src/recovery/v1.ts b/packages/domain/src/recovery/v1.ts index 99d8f188..eb2fe570 100644 --- a/packages/domain/src/recovery/v1.ts +++ b/packages/domain/src/recovery/v1.ts @@ -2,7 +2,6 @@ import { parseStableIdentifierV1, parseStrictUtcTimestampV1, type StableIdentifierV1, - type StrictUtcTimestampV1, } from '../tenant-scope/v1.js'; /** IAM-015: short-lived recovery bearers are hashed and single-use at rest. */ 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 bbc0fc49..545d4242 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 @@ -87,6 +87,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, principalId: StableIdentifierV1, ): Promise { + await Promise.resolve(); const visibleMemberships = this.memberships.filter((membership) => visible(context.tenantScope, membership.scope), ); @@ -97,6 +98,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, membershipId: StableIdentifierV1, ): Promise { + await Promise.resolve(); const membership = this.memberships.find((item) => item.id === membershipId); return membership && visible(context.tenantScope, membership.scope) ? cloneMembership(membership) @@ -107,6 +109,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, tokenDigest: string, ): Promise { + await Promise.resolve(); const invitation = this.invitations.find( (item) => item.tokenDigest === tokenDigest && tenantScopeContainsV1(context.tenantScope, item.scope), @@ -118,6 +121,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, membershipId: StableIdentifierV1, ): Promise { + await Promise.resolve(); const invitation = this.invitations.find( (item) => item.membershipId === membershipId && @@ -131,6 +135,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, invitation: InvitationTokenV1, ): Promise { + await Promise.resolve(); if (!tenantScopeContainsV1(context.tenantScope, invitation.scope)) throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const existing = this.invitations.find((item) => item.id === invitation.id); @@ -161,6 +166,7 @@ export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepo context: IamTenantContextV1, membership: IamMembershipRecordV1, ): Promise { + await Promise.resolve(); if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const index = this.memberships.findIndex((item) => item.id === membership.id); diff --git a/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts index b89b06fe..c1113772 100644 --- a/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts @@ -21,6 +21,7 @@ export class InMemoryRecoveryAdmissionAdapter implements RecoveryAdmissionPortV1 } public async allow(keyDigest: string, issuedAt: string): Promise { + await Promise.resolve(); const at = Date.parse(issuedAt); if (!/^[a-f0-9]{64}$/u.test(keyDigest) || !Number.isFinite(at)) return false; const current = this.attempts.get(keyDigest) ?? []; 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 7a30a11c..9f8e978b 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 @@ -70,23 +70,30 @@ export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPort ); const beforeChallenges = new Map(this.challenges); const transaction: RecoveryTransactionPortV1 = { - findUserIdByEmail: async (email) => - this.accounts.get(email)?.userId as StableIdentifierV1 | undefined, + findUserIdByEmail: async (email) => { + await Promise.resolve(); + return this.accounts.get(email)?.userId as StableIdentifierV1 | undefined; + }, findChallengeByTokenDigest: async (tokenDigest) => { + await Promise.resolve(); const challenge = this.challenges.get(tokenDigest); return challenge ? cloneChallenge(challenge) : undefined; }, - findActiveChallengeForUser: async (userId) => - [...this.challenges.values()].find( + findActiveChallengeForUser: async (userId) => { + await Promise.resolve(); + return [...this.challenges.values()].find( (challenge) => challenge.userId === userId && challenge.status === 'ACTIVE', - ), + ); + }, saveChallenge: async (challenge) => { + await Promise.resolve(); const existing = this.challenges.get(challenge.tokenDigest); if (existing && existing.revision + 1 !== challenge.revision) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); this.challenges.set(challenge.tokenDigest, cloneChallenge(challenge)); }, completeRecovery: async (input: RecoveryCompletionInputV1) => { + await Promise.resolve(); const account = [...this.accounts.values()].find( (candidate) => candidate.userId === input.challenge.userId, ); diff --git a/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts index 4e648230..4f012304 100644 --- a/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts @@ -25,8 +25,12 @@ export class InMemoryRegistrationRepositoryAdapter implements RegistrationReposi await previous; const before = new Map(this.records); const transaction: RegistrationTransactionPortV1 = { - findByEmail: async (email) => this.records.has(email), + findByEmail: async (email) => { + await Promise.resolve(); + return this.records.has(email); + }, save: async (input) => { + await Promise.resolve(); if (this.records.has(input.email)) throw new RegistrationConflictError(); this.records.set(input.email, clone(input)); }, 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 b87a0a58..80039d33 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 @@ -180,24 +180,6 @@ function invitationFromRow(row: IamInvitationDatabaseRowV1): InvitationTokenV1 { }); } -function membershipRow(membership: IamMembershipRecordV1): IamInvitationMembershipDatabaseRowV1 { - return { - id: membership.id, - principalType: 'USER', - principalId: membership.principalId, - scopeType: membership.scope.scopeType.toUpperCase(), - organizationId: membership.scope.organizationId, - workspaceId: - membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, - projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, - roleId: membership.roleId, - status: membership.status, - startsAt: membership.startsAt ? new Date(membership.startsAt) : null, - expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, - revision: membership.revision, - }; -} - function invitationRow(invitation: InvitationTokenV1): IamInvitationDatabaseRowV1 { return { id: invitation.id, 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 2dc91c3d..c2cff028 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 @@ -39,6 +39,8 @@ void test('[IAM-010] generated invitation identifiers and tokens are fresh and n assert.equal(parsed.accepted, true); const token = randomIamInvitationTokenV1(); assert.ok(token.length >= 43); + // This assertion intentionally checks the full C0/control range. + // eslint-disable-next-line no-control-regex assert.doesNotMatch(token, /[\u0000-\u001f\u007f]/u); assert.notEqual(token, randomIamInvitationTokenV1()); }); diff --git a/services/api/test/features/iam/invitation-composition.test.ts b/services/api/test/features/iam/invitation-composition.test.ts index 10816ee7..dd48f795 100644 --- a/services/api/test/features/iam/invitation-composition.test.ts +++ b/services/api/test/features/iam/invitation-composition.test.ts @@ -45,8 +45,17 @@ void test('[IAM-010] durable invitation composition requires all secret and deli void test('[IAM-010] durable invitation composition selects Prisma persistence when configured', () => { const registered = IamModule.register({ invitationDatabase: {} as never, - invitationPrincipalEmails: { findEmail: async () => 'invitee@example.com' }, - invitationDelivery: { deliver: async () => undefined }, + invitationPrincipalEmails: { + findEmail: async () => { + await Promise.resolve(); + return 'invitee@example.com'; + }, + }, + invitationDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, invitationDigest: { digestToken: () => 'a'.repeat(64), digestEmail: () => 'b'.repeat(64), diff --git a/services/api/test/features/iam/invitation-controller.test.ts b/services/api/test/features/iam/invitation-controller.test.ts index 5032fde5..b9297ddc 100644 --- a/services/api/test/features/iam/invitation-controller.test.ts +++ b/services/api/test/features/iam/invitation-controller.test.ts @@ -9,6 +9,7 @@ void test('[IAM-010] invitation controller forwards bounded issue and accept com const calls: Array = []; const service = { issue: async (...input: unknown[]) => { + await Promise.resolve(); calls.push(input); return { accepted: true as const, @@ -21,6 +22,7 @@ void test('[IAM-010] invitation controller forwards bounded issue and accept com }; }, accept: async (...input: unknown[]) => { + await Promise.resolve(); calls.push(input); return { accepted: true as const, @@ -29,7 +31,12 @@ void test('[IAM-010] invitation controller forwards bounded issue and accept com }, } as unknown as IamInvitationService; const context = { actorId: 'actor', tenantScope: { scopeType: 'organization' } } as never; - const controller = new IamInvitationController(service, { resolve: async () => context }); + const controller = new IamInvitationController(service, { + resolve: async () => { + await Promise.resolve(); + return context; + }, + }); const issued = await controller.issue( {}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }, @@ -55,10 +62,21 @@ void test('[IAM-010] invitation controller forwards bounded issue and accept com void test('[IAM-010] invitation controller maps rejected application outcomes to safe problem codes', async () => { const service = { - issue: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), - accept: async () => ({ accepted: false as const, code: 'INVALID_TOKEN' as const }), + issue: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'SCOPE_DENIED' as const }; + }, + accept: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_TOKEN' as const }; + }, } as unknown as IamInvitationService; - const controller = new IamInvitationController(service, { resolve: async () => ({}) as never }); + const controller = new IamInvitationController(service, { + resolve: async () => { + await Promise.resolve(); + return {} as never; + }, + }); await assert.rejects( controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), (error: unknown) => @@ -72,7 +90,12 @@ void test('[IAM-010] invitation controller maps rejected application outcomes to }); void test('[IAM-010] invitation controller fails closed when service composition is incomplete', async () => { - const controller = new IamInvitationController(undefined, { resolve: async () => ({}) as never }); + const controller = new IamInvitationController(undefined, { + resolve: async () => { + await Promise.resolve(); + return {} as never; + }, + }); await assert.rejects( controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), (error: unknown) => diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts index addcfd0b..9a8bfbac 100644 --- a/services/api/test/features/iam/invitation-service.test.ts +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -96,21 +96,30 @@ class Repository implements IamInvitationRepositoryPortV1 { const invitations = [...this.invitations]; try { return await work({ - findMembershipForPrincipal: async (_context, principalId) => - this.memberships.find( + findMembershipForPrincipal: async (_context, principalId) => { + await Promise.resolve(); + return this.memberships.find( (membership) => membership.principalId === principalId && membership.status === 'ACTIVE', - ), - findMembershipById: async (_context, id) => - this.memberships.find((membership) => membership.id === id), - findInvitationByDigest: async (_context, digest) => - this.invitations.find((invitation) => invitation.tokenDigest === digest), - findActiveInvitationForMembership: async (_context, membershipId) => - this.invitations.find( + ); + }, + findMembershipById: async (_context, id) => { + await Promise.resolve(); + return this.memberships.find((membership) => membership.id === id); + }, + findInvitationByDigest: async (_context, digest) => { + await Promise.resolve(); + return this.invitations.find((invitation) => invitation.tokenDigest === digest); + }, + findActiveInvitationForMembership: async (_context, membershipId) => { + await Promise.resolve(); + return this.invitations.find( (invitation) => invitation.membershipId === membershipId && invitation.status === 'ACTIVE', - ), + ); + }, saveInvitation: async (_context, invitation) => { + await Promise.resolve(); const index = this.invitations.findIndex((item) => item.id === invitation.id); if (index >= 0) { if (this.invitations[index]?.revision !== invitation.revision - 1) @@ -123,6 +132,7 @@ class Repository implements IamInvitationRepositoryPortV1 { } }, saveMembership: async (_context, membership) => { + await Promise.resolve(); 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'); @@ -141,6 +151,7 @@ class Repository implements IamInvitationRepositoryPortV1 { class EmailLookup implements IamPrincipalEmailLookupPortV1 { async findEmail(principalId: string): Promise { + await Promise.resolve(); return principalId === ids.invitee ? 'invitee@example.com' : 'owner@example.com'; } } @@ -162,6 +173,7 @@ class Delivery implements IamInvitationDeliveryPortV1 { readonly rawToken: string; readonly recipientEmail: string; }): Promise { + await Promise.resolve(); this.sent.push({ token: input.rawToken, email: input.recipientEmail }); } } diff --git a/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts index e2e23c49..64eb9d64 100644 --- a/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts @@ -10,7 +10,6 @@ import { type IamInvitationDatabaseRowV1, type IamInvitationMembershipDatabaseRowV1, } from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; -import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; const ids = { @@ -83,16 +82,19 @@ function client(options: { readonly updateCount?: number } = {}) { const database: IamInvitationDatabaseClientV1 = { membershipIdentity: { findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); calls.push({ operation: 'membership.findUnique', input: where }); return memberships.find((row) => row.id === where['id']) ?? null; }, findMany: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); calls.push({ operation: 'membership.findMany', input: where }); return memberships.filter((row) => Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value), ); }, create: async ({ data }: { readonly data: IamInvitationMembershipDatabaseRowV1 }) => { + await Promise.resolve(); memberships.push(data); return data; }, @@ -103,6 +105,7 @@ function client(options: { readonly updateCount?: number } = {}) { readonly where: Readonly>; readonly data: Partial; }) => { + await Promise.resolve(); const index = memberships.findIndex( (row) => row.id === where['id'] && row.revision === where['revision'], ); @@ -116,6 +119,7 @@ function client(options: { readonly updateCount?: number } = {}) { }, invitationTokenRecord: { findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); calls.push({ operation: 'invitation.findUnique', input: where }); return ( invitations.find( @@ -124,6 +128,7 @@ function client(options: { readonly updateCount?: number } = {}) { ); }, findFirst: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); calls.push({ operation: 'invitation.findFirst', input: where }); return ( invitations.find( @@ -132,6 +137,7 @@ function client(options: { readonly updateCount?: number } = {}) { ); }, create: async ({ data }: { readonly data: IamInvitationDatabaseRowV1 }) => { + await Promise.resolve(); invitations.push(data); return data; }, @@ -142,6 +148,7 @@ function client(options: { readonly updateCount?: number } = {}) { readonly where: Readonly>; readonly data: Partial; }) => { + await Promise.resolve(); const index = invitations.findIndex( (row) => row.id === where['id'] && @@ -153,8 +160,10 @@ function client(options: { readonly updateCount?: number } = {}) { return { count: options.updateCount ?? 1 }; }, }, - $transaction: async (work: (transaction: typeof database) => Promise) => - work(database), + $transaction: async (work: (transaction: typeof database) => Promise) => { + await Promise.resolve(); + return work(database); + }, }; return { database, memberships, invitations, calls }; } diff --git a/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts index 31acb91c..fba1ed24 100644 --- a/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts +++ b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts @@ -22,6 +22,7 @@ function client(row: IamPrincipalEmailDatabaseRowV1 | null) { calls, userIdentity: { findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); calls.push(where); return row; }, 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 dc14420f..9d809e45 100644 --- a/services/api/test/features/iam/prisma-recovery-repository.test.ts +++ b/services/api/test/features/iam/prisma-recovery-repository.test.ts @@ -71,9 +71,9 @@ function database() { ], ]); const calls = { transactions: 0, refresh: 0, access: 0, sessions: 0, mfa: 0 }; - let client!: RecoveryDatabaseClientV1; const unique = (records: Map>) => ({ findUnique: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); if (where['id']) return records.get(where['id']) ?? null; if (where['email']) return [...records.values()].find((row) => row['email'] === where['email']) ?? null; @@ -84,7 +84,7 @@ function database() { return null; }, }); - client = { + const client = { userIdentity: { ...unique(users), updateMany: async ({ @@ -94,6 +94,7 @@ function database() { where: Record; data: Record; }) => { + await Promise.resolve(); const row = users.get(String(where['id'])); if (!row || row['securityEpoch'] !== where['securityEpoch']) return { count: 0 }; users.set(String(where['id']), { ...row, ...data }); @@ -102,11 +103,14 @@ function database() { }, recoveryChallenge: { ...unique(challenges), - findMany: async ({ where }: { where: Record }) => - [...challenges.values()].filter( + findMany: async ({ where }: { where: Record }) => { + await Promise.resolve(); + return [...challenges.values()].filter( (row) => row['userId'] === where['userId'] && row['status'] === where['status'], - ), + ); + }, create: async ({ data }: { data: Record }) => { + await Promise.resolve(); challenges.set(String(data['id']), data); return data; }, @@ -117,6 +121,7 @@ function database() { where: Record; data: Record; }) => { + await Promise.resolve(); const row = challenges.get(String(where['id'])); if (!row) throw new Error('missing'); challenges.set(String(where['id']), { ...row, ...data }); @@ -129,6 +134,7 @@ function database() { where: Record; data: Record; }) => { + await Promise.resolve(); const row = challenges.get(String(where['id'])); if (!row || row['revision'] !== where['revision']) return { count: 0 }; challenges.set(String(where['id']), { ...row, ...data }); @@ -143,6 +149,7 @@ function database() { where: Record; data: Record; }) => { + await Promise.resolve(); const row = credentials.get(String(where['userId'])); if (!row) throw new Error('credential missing'); credentials.set(String(where['userId']), { ...row, ...data }); @@ -150,7 +157,10 @@ function database() { }, }, sessionRecord: { - findMany: async () => [...sessions.values()], + findMany: async () => { + await Promise.resolve(); + return [...sessions.values()]; + }, update: async ({ where, data, @@ -158,6 +168,7 @@ function database() { where: Record; data: Record; }) => { + await Promise.resolve(); calls.sessions += 1; const row = sessions.get(String(where['id'])); if (!row) throw new Error('session missing'); @@ -167,23 +178,27 @@ function database() { }, refreshTokenRecord: { updateMany: async () => { + await Promise.resolve(); calls.refresh += 1; return { count: 1 }; }, }, accessTokenRecord: { updateMany: async () => { + await Promise.resolve(); calls.access += 1; return { count: 1 }; }, }, mfaFactor: { updateMany: async () => { + await Promise.resolve(); calls.mfa += 1; return { count: 1 }; }, }, $transaction: async (work: (transaction: RecoveryDatabaseClientV1) => Promise) => { + await Promise.resolve(); calls.transactions += 1; return work(client); }, @@ -259,7 +274,10 @@ void test('[IAM-015] Prisma recovery challenge compare-and-set rejects a stale t status: 'REVOKED', revokedAt: new Date('2026-08-03T00:05:00.000Z'), }); - state.client.recoveryChallenge.updateMany = async () => ({ count: 0 }); + state.client.recoveryChallenge.updateMany = async () => { + await Promise.resolve(); + return { count: 0 }; + }; await assert.rejects( adapter.withTransaction((transaction) => transaction.saveChallenge({ diff --git a/services/api/test/features/iam/prisma-registration-repository.test.ts b/services/api/test/features/iam/prisma-registration-repository.test.ts index 71a91d6b..3f3454e2 100644 --- a/services/api/test/features/iam/prisma-registration-repository.test.ts +++ b/services/api/test/features/iam/prisma-registration-repository.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { PasswordCredentialV1 } from '../../../src/features/iam/domain/password-credential.js'; import { PrismaRegistrationRepositoryAdapter, type RegistrationDatabaseClientV1, @@ -106,10 +105,10 @@ function createDatabase() { projects: new Map(), memberships: new Map(), }; - let client!: RegistrationDatabaseClientV1; const transactionCalls = { value: 0 }; const makeIdentityDelegate = (records: Map>) => ({ findUnique: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); const id = where['id']; if (id) return records.get(id) ?? null; const email = where['email']; @@ -117,17 +116,20 @@ function createDatabase() { return null; }, create: async ({ data }: { readonly data: Record }) => { + await Promise.resolve(); if (records.has(String(data['id']))) throw Object.assign(new Error('P2002'), { code: 'P2002' }); records.set(String(data['id']), data); return data; }, - findMany: async ({ where }: { readonly where: Record }) => - [...records.values()].filter((row) => + findMany: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); + return [...records.values()].filter((row) => Object.entries(where).every(([key, value]) => row[key] === value), - ), + ); + }, }); - client = { + const client = { userIdentity: makeIdentityDelegate(state.users), passwordCredential: makeIdentityDelegate(state.credentials), organizationIdentity: makeIdentityDelegate(state.organizations), @@ -190,12 +192,12 @@ void test('[IAM-001] Prisma registration maps an existing normalized email and c void test('[IAM-001] Prisma registration rolls back user and credential when hierarchy persistence fails', async () => { const database = createDatabase(); - let failing!: RegistrationDatabaseClientV1; - failing = { + const failing = { ...database.client, projectIdentity: { ...database.client.projectIdentity, create: async () => { + await Promise.resolve(); throw new Error('project write failed'); }, }, 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 7fc37236..4ead346c 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -177,7 +177,7 @@ void test('[IAM-015] live session lookup carries the MFA re-enrollment gate from const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date('2026-01-01T00:00:00.000Z'), }); - const original = client.userIdentity.findUnique; + const original = client.userIdentity.findUnique.bind(client.userIdentity); client.userIdentity.findUnique = async () => ({ id: userId, status: 'ACTIVE', diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts index ae8d8fa3..96b1fc1d 100644 --- a/services/api/test/features/iam/recovery-composition.test.ts +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -26,12 +26,18 @@ function provider(module: ReturnType, token: symbol) } const passwordCredentials = new PasswordCredentialService({ - hash: async () => ({ - schemaVersion: 1, - algorithm: 'argon2id', - encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', - }), - verify: async () => true, + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); void test('[IAM-015] explicitly supplied recovery service is exported with a public controller', () => { @@ -51,7 +57,11 @@ void test('[IAM-015] durable recovery composition requires password, digest, del recoveryDatabase: {} as never, passwordCredentials, recoveryDigestKey: 'test-recovery-key', - recoveryDelivery: { deliver: async () => undefined }, + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, }); const repository = provider(configured, IAM_RECOVERY_REPOSITORY_PORT); const service = provider(configured, IAM_RECOVERY_SERVICE); @@ -67,8 +77,17 @@ void test('[IAM-015] recovery composition selects the shared admission adapter w recoveryDatabase: {} as never, passwordCredentials, recoveryDigestKey: 'test-recovery-key', - recoveryDelivery: { deliver: async () => undefined }, - recoveryAdmissionCounter: { incrementWindow: async () => 1 }, + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + recoveryAdmissionCounter: { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, recoveryAdmissionOptions: { maxAttempts: 5, windowSeconds: 30 }, }); const admission = provider(configured, IAM_RECOVERY_ADMISSION_PORT); @@ -82,8 +101,17 @@ void test('[IAM-015] recovery composition gives completion counters a separate R recoveryDatabase: {} as never, passwordCredentials, recoveryDigestKey: 'test-recovery-key', - recoveryDelivery: { deliver: async () => undefined }, - recoveryCompletionAdmissionCounter: { incrementWindow: async () => 1 }, + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + recoveryCompletionAdmissionCounter: { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, }); const admission = provider(configured, IAM_RECOVERY_COMPLETION_ADMISSION_PORT); assert.ok(admission && 'useValue' in admission); diff --git a/services/api/test/features/iam/recovery-controller.test.ts b/services/api/test/features/iam/recovery-controller.test.ts index 820d21a3..54f3d5f0 100644 --- a/services/api/test/features/iam/recovery-controller.test.ts +++ b/services/api/test/features/iam/recovery-controller.test.ts @@ -7,11 +7,17 @@ import type { RecoveryService } from '../../../src/features/iam/application/reco void test('[IAM-015] recovery controller returns generic request and safe completion values', async () => { const controller = new RecoveryController({ - request: async () => ({ accepted: true as const, value: { requested: true as const } }), - complete: async () => ({ - accepted: true as const, - value: { userId: 'user-id' as never, mfaReenrollmentRequired: true as const }, - }), + request: async () => { + await Promise.resolve(); + return { accepted: true as const, value: { requested: true as const } }; + }, + complete: async () => { + await Promise.resolve(); + return { + accepted: true as const, + value: { userId: 'user-id' as never, mfaReenrollmentRequired: true as const }, + }; + }, } as unknown as RecoveryService); assert.deepEqual(await controller.request({ email: 'user@example.com' }), { requested: true }); assert.deepEqual( @@ -25,8 +31,14 @@ void test('[IAM-015] recovery controller returns generic request and safe comple void test('[IAM-015] recovery controller maps rejected and unavailable outcomes without account disclosure', async () => { const controller = new RecoveryController({ - request: async () => ({ accepted: false as const, code: 'INVALID_INPUT' as const }), - complete: async () => ({ accepted: false as const, code: 'INVALID_TOKEN' as const }), + request: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_INPUT' as const }; + }, + complete: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_TOKEN' as const }; + }, } as unknown as RecoveryService); await assert.rejects( controller.request({ email: 'bad' }), diff --git a/services/api/test/features/iam/recovery-http.test.ts b/services/api/test/features/iam/recovery-http.test.ts index 38945259..7ded3b84 100644 --- a/services/api/test/features/iam/recovery-http.test.ts +++ b/services/api/test/features/iam/recovery-http.test.ts @@ -12,12 +12,18 @@ const rawToken = 'recovery-token-abcdefghijklmnopqrstuvwxyz-1234567890'; function credentials() { return new PasswordCredentialService({ - hash: async () => ({ - schemaVersion: 1, - algorithm: 'argon2id', - encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', - }), - verify: async () => true, + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); } @@ -31,6 +37,7 @@ void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and recoveryDigest: new HmacSha256IamRecoveryDigestAdapter('test-recovery-key'), recoveryDelivery: { deliver: async ({ rawToken: deliveredToken }) => { + await Promise.resolve(); delivered.push({ rawToken: deliveredToken }); }, }, diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts index d3ea9d71..5c41a053 100644 --- a/services/api/test/features/iam/recovery.service.test.ts +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -12,18 +12,25 @@ const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; function credentials() { return new PasswordCredentialService({ - hash: async (password) => ({ - schemaVersion: 1, - algorithm: 'argon2id', - encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, - }), - verify: async () => true, + hash: async (password) => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); } function countingCredentials(counter: { value: number }) { return new PasswordCredentialService({ hash: async (password) => { + await Promise.resolve(); counter.value += 1; return { schemaVersion: 1, @@ -31,13 +38,20 @@ function countingCredentials(counter: { value: number }) { encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, }; }, - verify: async () => true, + verify: async () => { + await Promise.resolve(); + return true; + }, }); } function service( repository: InMemoryRecoveryRepositoryAdapter, - delivery: RecoveryDeliveryPortV1 = { deliver: async () => undefined }, + delivery: RecoveryDeliveryPortV1 = { + deliver: async () => { + await Promise.resolve(); + }, + }, admission?: InMemoryRecoveryAdmissionAdapter, passwordCredentials: PasswordCredentialService = credentials(), completionAdmission?: InMemoryRecoveryAdmissionAdapter, @@ -104,6 +118,7 @@ void test('[IAM-015] recovery request is generic for unknown email and stores a const delivered: string[] = []; const recovery = service(repository, { deliver: async (input: Parameters[0]) => { + await Promise.resolve(); delivered.push(input.rawToken); }, }); @@ -127,7 +142,12 @@ void test('[IAM-015] recovery admission throttles known and unknown requests thr const admission = new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 1, windowSeconds: 60 }); const recovery = service( repository, - { deliver: async ({ rawToken }) => void delivered.push(rawToken) }, + { + deliver: async ({ rawToken }) => { + await Promise.resolve(); + delivered.push(rawToken); + }, + }, admission, ); assert.deepEqual(await recovery.request('unknown@example.com'), { @@ -171,6 +191,7 @@ void test('[IAM-015] recovery delivery failures do not persist a usable challeng repository.seed({ email: 'user@example.com', userId }); const recovery = service(repository, { deliver: async () => { + await Promise.resolve(); throw new Error('provider down'); }, }); @@ -187,6 +208,7 @@ void test('[IAM-015] recovery delivery failure preserves an existing active chal let failDelivery = false; const recovery = service(repository, { deliver: async () => { + await Promise.resolve(); if (failDelivery) throw new Error('provider down'); }, }); diff --git a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts index 8e62eb2e..41f35d6e 100644 --- a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts +++ b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts @@ -16,6 +16,7 @@ void test('[IAM-015] shared recovery admission uses a namespaced digest key and const admission = new RedisRecoveryAdmissionAdapter( { incrementWindow: async (input) => { + await Promise.resolve(); calls.push(input); count += 1; return count; @@ -38,6 +39,7 @@ void test('[IAM-015] shared recovery admission fails closed for malformed input let calls = 0; const admission = new RedisRecoveryAdmissionAdapter({ incrementWindow: async () => { + await Promise.resolve(); calls += 1; throw new Error('redis unavailable'); }, @@ -53,7 +55,12 @@ void test('[IAM-015] shared recovery admission rejects unsafe configuration', () assert.throws( () => new RedisRecoveryAdmissionAdapter( - { incrementWindow: async () => 1 }, + { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, { keyPrefix: 'raw email ' }, ), /IAM_RECOVERY_ADMISSION_INVALID/u, @@ -68,6 +75,7 @@ void test('[IAM-015] Redis counter wrapper uses one atomic script and validates }> = []; const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ eval: async (script, keys, args) => { + await Promise.resolve(); calls.push({ script, keys, args }); return '4'; }, @@ -85,7 +93,10 @@ void test('[IAM-015] Redis counter wrapper uses one atomic script and validates void test('[IAM-015] Redis counter wrapper rejects invalid TTLs and malformed replies', async () => { const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ - eval: async () => 'not-a-count', + eval: async () => { + await Promise.resolve(); + return 'not-a-count'; + }, }); await assert.rejects( counter.incrementWindow({ key: 'databreeze:key', ttlMs: 999 }), diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts index 1330117c..27e7805e 100644 --- a/services/api/test/features/iam/registration-composition.test.ts +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -21,12 +21,18 @@ function provider(module: ReturnType, token: symbol) } const passwordCredentials = new PasswordCredentialService({ - hash: async () => ({ - schemaVersion: 1, - algorithm: 'argon2id', - encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', - }), - verify: async () => true, + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); void test('[IAM-001] registration composition exports an explicitly supplied service and controller', () => { diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts index 2e4ea537..df534fe7 100644 --- a/services/api/test/features/iam/registration-controller.test.ts +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -18,7 +18,10 @@ const value = { void test('[IAM-001] registration controller returns hierarchy identifiers without bearer material', async () => { const controller = new RegistrationController({ - register: async () => ({ accepted: true as const, value }), + register: async () => { + await Promise.resolve(); + return { accepted: true as const, value }; + }, } as unknown as RegistrationService); const response = await controller.register({ email: 'user@example.com', @@ -39,7 +42,10 @@ void test('[IAM-001] registration controller returns hierarchy identifiers witho void test('[IAM-001] registration controller maps rejected and unavailable outcomes to stable problems', async () => { const rejected = new RegistrationController({ - register: async () => ({ accepted: false as const, code: 'REGISTRATION_REJECTED' as const }), + register: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'REGISTRATION_REJECTED' as const }; + }, } as unknown as RegistrationService); await assert.rejects( rejected.register({ @@ -51,7 +57,10 @@ void test('[IAM-001] registration controller maps rejected and unavailable outco error instanceof RegistrationProblemError && error.code === 'REGISTRATION_REQUEST_REJECTED', ); const unavailable = new RegistrationController({ - register: async () => ({ accepted: false as const, code: 'REGISTRATION_UNAVAILABLE' as const }), + register: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'REGISTRATION_UNAVAILABLE' as const }; + }, } as unknown as RegistrationService); await assert.rejects( unavailable.register({ diff --git a/services/api/test/features/iam/registration-http.test.ts b/services/api/test/features/iam/registration-http.test.ts index 292dbd19..3793f49d 100644 --- a/services/api/test/features/iam/registration-http.test.ts +++ b/services/api/test/features/iam/registration-http.test.ts @@ -7,12 +7,18 @@ import { PasswordCredentialService } from '../../../src/features/iam/application function credentials() { return new PasswordCredentialService({ - hash: async () => ({ - schemaVersion: 1, - algorithm: 'argon2id', - encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', - }), - verify: async () => true, + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); } diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts index cb178f57..10d47406 100644 --- a/services/api/test/features/iam/registration.service.test.ts +++ b/services/api/test/features/iam/registration.service.test.ts @@ -14,12 +14,18 @@ function ids() { function passwordCredentials() { return new PasswordCredentialService({ - hash: async () => ({ - schemaVersion: 1 as const, - algorithm: 'argon2id' as const, - encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', - }), - verify: async () => true, + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1 as const, + algorithm: 'argon2id' as const, + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, }); } @@ -95,9 +101,13 @@ void test('[IAM-001] registration validates input before persistence and maps ha repository, passwordCredentials: new PasswordCredentialService({ hash: async () => { + await Promise.resolve(); throw new Error('hash'); }, - verify: async () => false, + verify: async () => { + await Promise.resolve(); + return false; + }, }), ids: ids(), }); @@ -117,8 +127,12 @@ void test('[IAM-001] registration rolls back when persistence fails after stagin repository: { withTransaction: async (work) => work({ - findByEmail: async () => false, + findByEmail: async () => { + await Promise.resolve(); + return false; + }, save: async () => { + await Promise.resolve(); throw new Error('database unavailable'); }, }), From 265923b4b83653857f285680a0a319bec87b2914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:40:47 +0700 Subject: [PATCH 32/99] feat(iam): define service account identity contract --- packages/domain/package.json | 4 + packages/domain/src/service-account/v1.ts | 261 ++++++++++++++++++ packages/domain/src/v1.ts | 1 + .../domain/test/built-public-api-smoke.mjs | 3 + packages/domain/test/public-api-v1.test.mjs | 2 + .../domain/test/service-account-v1.test.mjs | 115 ++++++++ 6 files changed, 386 insertions(+) create mode 100644 packages/domain/src/service-account/v1.ts create mode 100644 packages/domain/test/service-account-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 49eb5c06..062856d2 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -28,6 +28,10 @@ "types": "./src/identity/v1.ts", "import": "./dist/identity/v1.js" }, + "./service-account/v1": { + "types": "./src/service-account/v1.ts", + "import": "./dist/service-account/v1.js" + }, "./entitlements/v1": { "types": "./src/entitlements/v1.ts", "import": "./dist/entitlements/v1.js" diff --git a/packages/domain/src/service-account/v1.ts b/packages/domain/src/service-account/v1.ts new file mode 100644 index 00000000..74fd33b2 --- /dev/null +++ b/packages/domain/src/service-account/v1.ts @@ -0,0 +1,261 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '../tenant-scope/v1.js'; +import { isPermissionV1, type PermissionV1 } from '../permissions/v1.js'; + +/** IAM-013: organization-owned, non-interactive, action-scoped service identities. */ +export const SERVICE_ACCOUNT_SCHEMA_VERSION_V1 = 1 as const; +export const SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1 = 64 as const; +export const SERVICE_ACCOUNT_MAX_LIFETIME_SECONDS_V1 = 365 * 24 * 60 * 60; + +export type ServiceAccountStatusV1 = 'ACTIVE' | 'REVOKED'; + +export interface ServiceAccountV1 { + readonly schemaVersion: typeof SERVICE_ACCOUNT_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly organizationId: StableIdentifierV1; + readonly workspaceId?: StableIdentifierV1; + readonly name: string; + readonly permissions: readonly PermissionV1[]; + readonly status: ServiceAccountStatusV1; + readonly secretDigest: string; + readonly secretVersion: number; + readonly secretIssuedAt: StrictUtcTimestampV1; + readonly secretExpiresAt?: StrictUtcTimestampV1; + readonly lastUsedAt?: StrictUtcTimestampV1; + readonly createdAt: StrictUtcTimestampV1; + readonly revokedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ServiceAccountErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_TEXT' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_PERMISSION' + | 'INVALID_DIGEST' + | 'INVALID_REVISION' + | 'INVALID_STATE' + | 'SECRET_REVOKED' + | 'SECRET_EXPIRED' + | 'REVISION_CONFLICT'; + +export type ServiceAccountResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ServiceAccountErrorCodeV1 }; + +function accepted(value: TValue): ServiceAccountResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ServiceAccountErrorCodeV1): ServiceAccountResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function boundedText(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function lifetimeWithin( + issuedAt: StrictUtcTimestampV1, + expiresAt: StrictUtcTimestampV1, +): boolean { + const issued = Date.parse(issuedAt); + const expires = Date.parse(expiresAt); + return ( + Number.isFinite(issued) && + Number.isFinite(expires) && + expires > issued && + expires - issued <= SERVICE_ACCOUNT_MAX_LIFETIME_SECONDS_V1 * 1_000 + ); +} + +function permissions(input: unknown): readonly PermissionV1[] | undefined { + if (!Array.isArray(input) || input.length === 0 || input.length > SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1) + return undefined; + const values = input.filter((permission): permission is PermissionV1 => isPermissionV1(permission)); + if (values.length !== input.length) return undefined; + return Object.freeze([...new Set(values)]); +} + +function validSecretWindow( + issuedAt: StrictUtcTimestampV1, + expiresAt: StrictUtcTimestampV1 | undefined, +): boolean { + return expiresAt === undefined || lifetimeWithin(issuedAt, expiresAt); +} + +/** Create a service account record from a keyed digest; raw credentials never enter this value. */ +export function createServiceAccountV1(input: { + readonly id: unknown; + readonly organizationId: unknown; + readonly workspaceId?: unknown; + readonly name: unknown; + readonly permissions: unknown; + readonly secretDigest: unknown; + readonly secretIssuedAt: unknown; + readonly secretExpiresAt?: unknown; + readonly createdAt: unknown; +}): ServiceAccountResultV1 { + const id = stableId(input.id); + const organizationId = stableId(input.organizationId); + const workspaceId = input.workspaceId === undefined ? undefined : stableId(input.workspaceId); + const name = boundedText(input.name, 200); + const permissionValues = permissions(input.permissions); + const secretDigest = digest(input.secretDigest); + const secretIssuedAt = timestamp(input.secretIssuedAt); + const secretExpiresAt = + input.secretExpiresAt === undefined ? undefined : timestamp(input.secretExpiresAt); + const createdAt = timestamp(input.createdAt); + if (!id || !organizationId || (input.workspaceId !== undefined && !workspaceId)) + return rejected('INVALID_IDENTIFIER'); + if (!name) return rejected('INVALID_TEXT'); + if (!permissionValues) return rejected('INVALID_PERMISSION'); + if (!secretDigest) return rejected('INVALID_DIGEST'); + if (!secretIssuedAt || !createdAt) return rejected('INVALID_TIMESTAMP'); + if (input.secretExpiresAt !== undefined && !secretExpiresAt) + return rejected('INVALID_TIMESTAMP'); + if (!validSecretWindow(secretIssuedAt, secretExpiresAt)) return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: SERVICE_ACCOUNT_SCHEMA_VERSION_V1, + id, + organizationId, + ...(workspaceId === undefined ? {} : { workspaceId }), + name, + permissions: permissionValues, + status: 'ACTIVE' as const, + secretDigest, + secretVersion: 1, + secretIssuedAt, + ...(secretExpiresAt === undefined ? {} : { secretExpiresAt }), + createdAt, + revision: 1, + }), + ); +} + +/** Rotate the stored digest atomically; the old secret must be rejected after this successor version. */ +export function rotateServiceAccountSecretV1( + current: ServiceAccountV1, + input: { + readonly secretDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt?: unknown; + readonly expectedRevision: unknown; + }, +): ServiceAccountResultV1 { + const secretDigest = digest(input.secretDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = input.expiresAt === undefined ? undefined : timestamp(input.expiresAt); + const expectedRevision = positiveInteger(input.expectedRevision); + if (!secretDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || (input.expiresAt !== undefined && !expiresAt)) return rejected('INVALID_TIMESTAMP'); + if (!expectedRevision || expectedRevision !== current.revision) return rejected('REVISION_CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (!validSecretWindow(issuedAt, expiresAt)) return rejected('INVALID_LIFETIME'); + if (Date.parse(issuedAt) < Date.parse(current.secretIssuedAt)) return rejected('INVALID_TIMESTAMP'); + if (expiresAt === undefined) { + const { secretExpiresAt: _previousExpiry, ...withoutExpiry } = current; + return accepted( + Object.freeze({ + ...withoutExpiry, + secretDigest, + secretVersion: current.secretVersion + 1, + secretIssuedAt: issuedAt, + revision: current.revision + 1, + }), + ); + } + return accepted( + Object.freeze({ + ...current, + secretDigest, + secretVersion: current.secretVersion + 1, + secretIssuedAt: issuedAt, + secretExpiresAt: expiresAt, + revision: current.revision + 1, + }), + ); +} + +/** Mark use without changing permissions or secret material; timestamps may only move forward. */ +export function markServiceAccountUsedV1( + current: ServiceAccountV1, + usedAtInput: unknown, +): ServiceAccountResultV1 { + const usedAt = timestamp(usedAtInput); + if (!usedAt) return rejected('INVALID_TIMESTAMP'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (current.secretExpiresAt && Date.parse(usedAt) >= Date.parse(current.secretExpiresAt)) + return rejected('SECRET_EXPIRED'); + if (Date.parse(usedAt) < Date.parse(current.secretIssuedAt)) return rejected('INVALID_TIMESTAMP'); + if (current.lastUsedAt && Date.parse(usedAt) < Date.parse(current.lastUsedAt)) + return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...current, + lastUsedAt: usedAt, + revision: current.revision + 1, + }), + ); +} + +/** Revocation is permanent; callers must create a new account instead of reactivating this identity. */ +export function revokeServiceAccountV1( + current: ServiceAccountV1, + revokedAtInput: unknown, + expectedRevisionInput: unknown, +): ServiceAccountResultV1 { + const revokedAt = timestamp(revokedAtInput); + const expectedRevision = positiveInteger(expectedRevisionInput); + if (!revokedAt) return rejected('INVALID_TIMESTAMP'); + if (!expectedRevision || expectedRevision !== current.revision) return rejected('REVISION_CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (Date.parse(revokedAt) < Date.parse(current.createdAt)) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...current, + status: 'REVOKED' as const, + revokedAt, + revision: current.revision + 1, + }), + ); +} + +export function isServiceAccountSecretUsableV1( + account: ServiceAccountV1, + nowInput: unknown, +): ServiceAccountResultV1 { + const now = timestamp(nowInput); + if (!now) return rejected('INVALID_TIMESTAMP'); + if (account.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (account.secretExpiresAt && Date.parse(now) >= Date.parse(account.secretExpiresAt)) + return rejected('SECRET_EXPIRED'); + return Object.freeze({ accepted: true, value: true }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 0c0c4793..cbd712b3 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -25,6 +25,7 @@ export * from './mapping/v1.js'; export * from './rule-set/v1.js'; export * from './evidence-grant/v1.js'; export * from './identity/v1.js'; +export * from './service-account/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; export * from './invitation/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index dfa8168c..007640de 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -12,6 +12,7 @@ const [ artifactExport, artifactUpload, protectedDocument, + serviceAccount, dataset, datasetGovernance, datasetQuality, @@ -43,6 +44,7 @@ const [ import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/protected-document/v1'), + import('@databreeze/domain/service-account/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), @@ -97,4 +99,5 @@ assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); assert.equal(recovery.RECOVERY_CHALLENGE_SCHEMA_VERSION_V1, 1); +assert.equal(serviceAccount.SERVICE_ACCOUNT_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 81414390..96d906fc 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -15,6 +15,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './authorization/v1', './audit/v1', './identity/v1', + './service-account/v1', './entitlements/v1', './mfa/v1', './invitation/v1', @@ -66,6 +67,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.IDENTITY_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.SERVICE_ACCOUNT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.INVITATION_TOKEN_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/service-account-v1.test.mjs b/packages/domain/test/service-account-v1.test.mjs new file mode 100644 index 00000000..b8302e8c --- /dev/null +++ b/packages/domain/test/service-account-v1.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createServiceAccountV1, + isServiceAccountSecretUsableV1, + markServiceAccountUsedV1, + revokeServiceAccountV1, + rotateServiceAccountSecretV1, +} from '@databreeze/domain/service-account/v1'; + +const ids = { + account: '00000000-0000-4000-8000-000000000601', + organization: '00000000-0000-4000-8000-000000000602', + workspace: '00000000-0000-4000-8000-000000000603', +}; +const createdAt = '2026-08-03T00:00:00.000Z'; + +function input(overrides = {}) { + return { + id: ids.account, + organizationId: ids.organization, + workspaceId: ids.workspace, + name: 'Import worker', + permissions: ['artifact.record.read', 'job.execution.create'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: createdAt, + createdAt, + ...overrides, + }; +} + +void test('[IAM-013] service account stores only bounded scoped permissions and a digest', () => { + const result = createServiceAccountV1(input()); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.status, 'ACTIVE'); + assert.equal(result.value.secretVersion, 1); + assert.deepEqual(result.value.permissions, ['artifact.record.read', 'job.execution.create']); + assert.equal(Object.hasOwn(result.value, 'secret'), false); +}); + +void test('[IAM-013] invalid permissions, wildcard, digest, and lifetime fail closed', () => { + assert.deepEqual(createServiceAccountV1(input({ permissions: ['*'] })), { + accepted: false, + code: 'INVALID_PERMISSION', + }); + assert.deepEqual(createServiceAccountV1(input({ secretDigest: 'secret' })), { + accepted: false, + code: 'INVALID_DIGEST', + }); + assert.deepEqual( + createServiceAccountV1({ + ...input(), + secretExpiresAt: '2027-08-04T00:00:00.000Z', + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-013] secret rotation requires the current revision and increments the version', () => { + const created = createServiceAccountV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rotated = rotateServiceAccountSecretV1(created.value, { + secretDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:01:00.000Z', + expectedRevision: 1, + }); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.equal(rotated.value.secretVersion, 2); + assert.equal(rotated.value.revision, 2); + assert.deepEqual( + rotateServiceAccountSecretV1(created.value, { + secretDigest: 'c'.repeat(64), + issuedAt: '2026-08-03T00:01:00.000Z', + expectedRevision: 2, + }), + { accepted: false, code: 'REVISION_CONFLICT' }, + ); +}); + +void test('[IAM-013] last-use is monotonic and unusable secrets fail closed', () => { + const created = createServiceAccountV1( + input({ secretExpiresAt: '2026-08-03T01:00:00.000Z' }), + ); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const used = markServiceAccountUsedV1(created.value, '2026-08-03T00:10:00.000Z'); + assert.equal(used.accepted, true); + if (!used.accepted) return; + assert.deepEqual(markServiceAccountUsedV1(used.value, '2026-08-03T00:09:00.000Z'), { + accepted: false, + code: 'INVALID_TIMESTAMP', + }); + assert.deepEqual(isServiceAccountSecretUsableV1(used.value, '2026-08-03T01:00:00.000Z'), { + accepted: false, + code: 'SECRET_EXPIRED', + }); +}); + +void test('[IAM-013] revocation is permanent and revision guarded', () => { + const created = createServiceAccountV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const revoked = revokeServiceAccountV1(created.value, '2026-08-03T00:02:00.000Z', 1); + assert.equal(revoked.accepted, true); + if (!revoked.accepted) return; + assert.equal(revoked.value.status, 'REVOKED'); + assert.deepEqual(revokeServiceAccountV1(revoked.value, '2026-08-03T00:03:00.000Z', 2), { + accepted: false, + code: 'SECRET_REVOKED', + }); +}); From 9c238e860a7b0b87b89bc6b01222d6fd368d84bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:43:37 +0700 Subject: [PATCH 33/99] feat(iam): scope service account permissions --- packages/domain/src/authorization/v1.ts | 1 + packages/domain/src/permissions/v1.ts | 10 ++++++++++ .../domain/test/permission-applicability-v1.test.mjs | 5 +++++ packages/domain/test/permissions-v1.test.mjs | 9 +++++++++ 4 files changed, 25 insertions(+) diff --git a/packages/domain/src/authorization/v1.ts b/packages/domain/src/authorization/v1.ts index 0a73edf4..b0d98ecc 100644 --- a/packages/domain/src/authorization/v1.ts +++ b/packages/domain/src/authorization/v1.ts @@ -143,6 +143,7 @@ const resourceScopeTypes: Readonly([ diff --git a/packages/domain/test/permission-applicability-v1.test.mjs b/packages/domain/test/permission-applicability-v1.test.mjs index 0183c95c..d6829b60 100644 --- a/packages/domain/test/permission-applicability-v1.test.mjs +++ b/packages/domain/test/permission-applicability-v1.test.mjs @@ -26,6 +26,9 @@ const expectedChannels = Object.freeze({ 'billing.account.manage': ['api', 'web'], 'device.identity.read': ['api', 'web'], 'device.identity.revoke': ['api', 'web'], + 'service.account.read': ['api', 'web'], + 'service.account.manage': ['api', 'web'], + 'service.account.revoke': ['api', 'web'], }); test('[IAM-002, IAM-003] every permission has an explicit closed channel policy', () => { @@ -61,6 +64,8 @@ test('[IAM-002, IAM-003] sensitive actions are closed to shared-link, stream, an 'approval.decision.create', 'billing.account.manage', 'device.identity.revoke', + 'service.account.manage', + 'service.account.revoke', ]; for (const permission of sensitive) { diff --git a/packages/domain/test/permissions-v1.test.mjs b/packages/domain/test/permissions-v1.test.mjs index 4c09180d..cb4649bc 100644 --- a/packages/domain/test/permissions-v1.test.mjs +++ b/packages/domain/test/permissions-v1.test.mjs @@ -35,6 +35,9 @@ test('[IAM-004] publishes a closed versioned permission vocabulary', async () => 'billing.account.manage', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ]); assert.ok(Object.isFrozen(api.PERMISSIONS_V1)); }); @@ -66,6 +69,9 @@ test('[IAM-004] maps exactly six immutable initial role bundles', async () => { 'billing.account.manage', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ], admin: [ 'organization.profile.read', @@ -77,6 +83,9 @@ test('[IAM-004] maps exactly six immutable initial role bundles', async () => { 'job.execution.read', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ], analyst: [ 'organization.profile.read', From 1db01863fcc145aed1bd06b9538a0762c9ccdd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:47:20 +0700 Subject: [PATCH 34/99] feat(iam): add service account repository port --- ...mory-service-account-repository.adapter.ts | 121 ++++++++++++++++++ .../service-account-repository.port.ts | 27 ++++ .../iam/service-account-repository.test.ts | 102 +++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts create mode 100644 services/api/src/features/iam/application/service-account-repository.port.ts create mode 100644 services/api/test/features/iam/service-account-repository.test.ts 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 new file mode 100644 index 00000000..f873442d --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -0,0 +1,121 @@ +import { + tenantScopeContainsV1, + 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 { + ServiceAccountRepositoryPortV1, + ServiceAccountTransactionPortV1, +} from '../application/service-account-repository.port.js'; + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +function visibleInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + const scope = accountScope(account); + return tenantScopeContainsV1(context.tenantScope, scope) || tenantScopeContainsV1(scope, context.tenantScope); +} + +function writableInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + return tenantScopeContainsV1(context.tenantScope, accountScope(account)); +} + +function clone(account: ServiceAccountV1): ServiceAccountV1 { + return Object.freeze({ ...account, permissions: Object.freeze([...account.permissions]) }); +} + +/** Deterministic local adapter with the same visibility and optimistic-write rules as PostgreSQL. */ +export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRepositoryPortV1 { + private accounts = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: ServiceAccountV1['id'], + ): Promise { + await Promise.resolve(); + const account = this.accounts.get(serviceAccountId); + return account && visibleInScope(context, account) ? clone(account) : undefined; + } + + public async listServiceAccounts( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.accounts.values()] + .filter((account) => visibleInScope(context, account)) + .sort((left, right) => left.id.localeCompare(right.id)) + .map(clone); + } + + public async saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + ): Promise { + await Promise.resolve(); + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const existing = this.accounts.get(account.id); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(account)) throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); + return; + } + const duplicateDigest = [...this.accounts.values()].find( + (candidate) => candidate.secretDigest === account.secretDigest, + ); + if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + this.accounts.set(account.id, clone(account)); + } + + public async replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise { + await Promise.resolve(); + 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 (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); + if (account.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); + const duplicateDigest = [...this.accounts.values()].find( + (candidate) => candidate.id !== account.id && candidate.secretDigest === account.secretDigest, + ); + if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + this.accounts.set(account.id, clone(account)); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.accounts); + try { + return await work({ + findServiceAccount: this.findServiceAccount.bind(this), + listServiceAccounts: this.listServiceAccounts.bind(this), + saveServiceAccount: this.saveServiceAccount.bind(this), + replaceServiceAccount: this.replaceServiceAccount.bind(this), + }); + } catch (error) { + this.accounts = before; + throw error; + } finally { + release(); + } + } +} 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 new file mode 100644 index 00000000..e903b783 --- /dev/null +++ b/services/api/src/features/iam/application/service-account-repository.port.ts @@ -0,0 +1,27 @@ +import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import type { StableIdentifierV1 } 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 ServiceAccountTransactionPortV1 { + findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: StableIdentifierV1, + ): Promise; + listServiceAccounts(context: IamTenantContextV1): Promise; + saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1): Promise; + replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise; +} + +export interface ServiceAccountRepositoryPortV1 extends ServiceAccountTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/test/features/iam/service-account-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts new file mode 100644 index 00000000..b6de58a5 --- /dev/null +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000701'; +const otherOrganizationId = '00000000-0000-4000-8000-000000000702'; +const workspaceId = '00000000-0000-4000-8000-000000000703'; +const accountId = '00000000-0000-4000-8000-000000000704'; +const correlationId = '00000000-0000-4000-8000-000000000705'; +const actorId = '00000000-0000-4000-8000-000000000706'; + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +const stableAccountId = stable(accountId); + +function context(scope: unknown, key = 'service-account-repository') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function account(overrides: Record = {}) { + const result = createServiceAccountV1({ + id: accountId, + organizationId, + workspaceId, + name: 'Import worker', + permissions: ['artifact.record.read'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid account'); + return result.value; +} + +void test('[IAM-013] service account repository preserves tenant scope and immutable copies', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }); + await repository.saveServiceAccount(organizationContext, account()); + + const found = await repository.findServiceAccount(organizationContext, stableAccountId); + assert.deepEqual(found, account()); + assert.notEqual(found, account()); + assert.equal( + (await repository.findServiceAccount(context({ scopeType: 'organization', organizationId: otherOrganizationId }), stableAccountId)), + undefined, + ); + assert.equal((await repository.listServiceAccounts(organizationContext)).length, 1); +}); + +void test('[IAM-013] workspace scope is visible to its parent and child context but never another workspace', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'parent'); + await repository.saveServiceAccount(organizationContext, account()); + assert.equal( + (await repository.listServiceAccounts(context({ scopeType: 'workspace', organizationId, workspaceId }, 'child'))).length, + 1, + ); + assert.equal( + (await repository.listServiceAccounts(context({ scopeType: 'workspace', organizationId, workspaceId: otherOrganizationId }, 'sibling'))).length, + 0, + ); +}); + +void test('[IAM-013] replacement is revision guarded and transactions roll back on failure', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'transaction'); + await repository.saveServiceAccount(organizationContext, account()); + const changed = Object.freeze({ ...account({ name: 'Changed' }), revision: 2 }); + await assert.rejects( + repository.replaceServiceAccount(organizationContext, changed, 2), + /REVISION_CONFLICT/, + ); + await assert.rejects( + repository.withTransaction(organizationContext, async (transaction) => { + await transaction.replaceServiceAccount(organizationContext, changed, 1); + throw new Error('ROLLBACK'); + }), + /ROLLBACK/, + ); + assert.equal((await repository.findServiceAccount(organizationContext, stableAccountId))?.name, 'Import worker'); +}); From 4a23c050e0c5f38a1604d132db1ffdeaaddd45ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:50:09 +0700 Subject: [PATCH 35/99] feat(iam): authorize service account lifecycle --- .../application/service-account.service.ts | 338 ++++++++++++++++++ .../iam/service-account.service.test.ts | 135 +++++++ 2 files changed, 473 insertions(+) create mode 100644 services/api/src/features/iam/application/service-account.service.ts create mode 100644 services/api/test/features/iam/service-account.service.test.ts diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts new file mode 100644 index 00000000..e0d7f4ce --- /dev/null +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -0,0 +1,338 @@ +import { randomUUID } from 'node:crypto'; + +import { + createServiceAccountV1, + isServiceAccountSecretUsableV1, + revokeServiceAccountV1, + rotateServiceAccountSecretV1, + type ServiceAccountV1, + type ServiceAccountErrorCodeV1, +} from '@databreeze/domain/service-account/v1'; +import { + roleHasPermissionV1, + PERMISSIONS_V1, + type PermissionV1, +} from '@databreeze/domain/permissions/v1'; +import { + parseStableIdentifierV1, + 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 { IamTenantContextV1 } from './tenant-context.js'; + +export const SERVICE_ACCOUNT_SERVICE = Symbol('SERVICE_ACCOUNT_SERVICE'); + +export interface ServiceAccountSecretIssueV1 { + readonly secret: string; + readonly digest: string; +} + +export interface ServiceAccountSecretIssuerV1 { + issue(): ServiceAccountSecretIssueV1; +} + +export type ServiceAccountClockV1 = () => Date; +export type ServiceAccountIdGeneratorV1 = () => string; + +export type ServiceAccountSafeViewV1 = Omit; + +export interface IssuedServiceAccountV1 { + readonly account: ServiceAccountSafeViewV1; + /** Returned only from create/rotate; never persisted or logged. */ + readonly secret: string; +} + +export type ServiceAccountApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_INPUT' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'REVOKED' + | 'EXPIRED' + | 'UNAVAILABLE'; + +export type ServiceAccountApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ServiceAccountApplicationCodeV1 }; + +export interface CreateServiceAccountInputV1 { + readonly name: unknown; + readonly workspaceId?: unknown; + readonly permissions: unknown; + readonly secretExpiresAt?: unknown; +} + +function accepted(value: TValue): ServiceAccountApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ServiceAccountApplicationCodeV1): ServiceAccountApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function safeView(account: ServiceAccountV1): ServiceAccountSafeViewV1 { + const { secretDigest: _secretDigest, ...withoutDigest } = account; + void _secretDigest; + return Object.freeze({ + ...withoutDigest, + permissions: Object.freeze([...withoutDigest.permissions]), + }); +} + +function mapDomainCode(code: ServiceAccountErrorCodeV1): ServiceAccountApplicationCodeV1 { + if (code === 'INVALID_IDENTIFIER') return 'INVALID_IDENTIFIER'; + if (code === 'INVALID_STATE' || code === 'SECRET_REVOKED') return 'REVOKED'; + if (code === 'SECRET_EXPIRED') return 'EXPIRED'; + if (code === 'REVISION_CONFLICT') return 'CONFLICT'; + if (code === 'INVALID_PERMISSION' || code === 'INVALID_TEXT' || code === 'INVALID_LIFETIME') + return 'INVALID_INPUT'; + return 'INVALID_INPUT'; +} + +function mapRepositoryError(error: unknown): ServiceAccountApplicationCodeV1 { + const message = error instanceof Error ? error.message : ''; + if (message === 'SCOPE_DENIED') return 'SCOPE_DENIED'; + if (message === 'SERVICE_ACCOUNT_NOT_FOUND') return 'NOT_FOUND'; + if (message === 'REVISION_CONFLICT' || message === 'INVALID_REVISION' || message.endsWith('CONFLICT')) + return 'CONFLICT'; + return 'UNAVAILABLE'; +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scopeForAccount( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1 | undefined, +): TenantScopeV1 | undefined { + if (workspaceId === undefined) { + return context.tenantScope.scopeType === 'organization' + ? { scopeType: 'organization', organizationId: context.tenantScope.organizationId } + : undefined; + } + const scope: TenantScopeV1 = { + scopeType: 'workspace', + organizationId: context.tenantScope.organizationId, + workspaceId, + }; + return tenantScopeContainsV1(context.tenantScope, scope) ? scope : undefined; +} + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +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, + ) + ); +} + +/** IAM-013: action-scoped service identities with one-time credential issuance. */ +export class ServiceAccountService { + public constructor( + private readonly repository: ServiceAccountRepositoryPortV1, + private readonly iamRepository: IamRepositoryPortV1, + private readonly secretIssuer: ServiceAccountSecretIssuerV1, + private readonly clock: ServiceAccountClockV1 = () => new Date(), + private readonly idGenerator: ServiceAccountIdGeneratorV1 = () => randomUUID(), + ) {} + + public async create( + context: IamTenantContextV1, + input: CreateServiceAccountInputV1, + ): Promise> { + const workspaceId = + input.workspaceId === undefined ? undefined : identifier(input.workspaceId); + if (input.workspaceId !== undefined && workspaceId === undefined) + return rejected('INVALID_IDENTIFIER'); + const targetScope = scopeForAccount(context, workspaceId); + if (!targetScope) return rejected('SCOPE_DENIED'); + const authorization = await this.authorize(context, targetScope, PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE); + if (authorization !== 'ALLOWED') return rejected(authorization); + if (!serviceAccountPermissions(input.permissions)) return rejected('INVALID_INPUT'); + 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)); + try { + await this.repository.saveServiceAccount(context, candidate.value); + return accepted(Object.freeze({ account: safeView(candidate.value), secret: secret.secret })); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + } + + public async list( + context: IamTenantContextV1, + ): Promise> { + const authorization = await this.authorize( + context, + context.tenantScope, + PERMISSIONS_V1.SERVICE_ACCOUNT_READ, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + try { + return accepted((await this.repository.listServiceAccounts(context)).map(safeView)); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + } + + public async rotate( + context: IamTenantContextV1, + serviceAccountIdInput: unknown, + expectedRevisionInput: unknown, + secretExpiresAt?: unknown, + ): Promise> { + const serviceAccountId = identifier(serviceAccountIdInput); + if (!serviceAccountId) return rejected('INVALID_IDENTIFIER'); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + let now: string; + let secret: ServiceAccountSecretIssueV1; + try { + now = this.clock().toISOString(); + secret = this.secretIssuer.issue(); + } catch { + return rejected('UNAVAILABLE'); + } + const rotated = rotateServiceAccountSecretV1(current, { + secretDigest: secret.digest, + issuedAt: now, + ...(secretExpiresAt === undefined ? {} : { expiresAt: secretExpiresAt }), + expectedRevision: expectedRevisionInput, + }); + if (!rotated.accepted) return rejected(mapDomainCode(rotated.code)); + try { + await transaction.replaceServiceAccount(context, rotated.value, current.revision); + return accepted(Object.freeze({ account: safeView(rotated.value), secret: secret.secret })); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }).catch((error) => rejected(mapRepositoryError(error))); + } + + public async revoke( + context: IamTenantContextV1, + serviceAccountIdInput: unknown, + expectedRevisionInput: unknown, + ): Promise> { + const serviceAccountId = identifier(serviceAccountIdInput); + if (!serviceAccountId) return rejected('INVALID_IDENTIFIER'); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + return this.repository.withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); + const revoked = revokeServiceAccountV1(current, now, expectedRevisionInput); + if (!revoked.accepted) return rejected(mapDomainCode(revoked.code)); + try { + await transaction.replaceServiceAccount(context, revoked.value, current.revision); + return accepted(safeView(revoked.value)); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }).catch((error) => rejected(mapRepositoryError(error))); + } + + public validateSecret( + account: ServiceAccountV1, + nowInput: unknown, + ): ServiceAccountApplicationResultV1 { + const result = isServiceAccountSecretUsableV1(account, nowInput); + return result.accepted ? result : rejected(mapDomainCode(result.code)); + } + + private now(): string | undefined { + try { + const now = this.clock(); + return now instanceof Date && Number.isFinite(now.getTime()) ? now.toISOString() : undefined; + } catch { + return undefined; + } + } + + private async authorize( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + permission: PermissionV1, + ): Promise<'ALLOWED' | 'SCOPE_DENIED' | 'UNAVAILABLE'> { + if (!tenantScopeContainsV1(context.tenantScope, targetScope)) return 'SCOPE_DENIED'; + try { + const membership = await this.iamRepository.findMembership(context, context.actorId); + if ( + !membership || + !tenantScopeContainsV1(membership.scope, targetScope) || + !roleHasPermissionV1(membership.roleId, permission) + ) + return 'SCOPE_DENIED'; + return 'ALLOWED'; + } catch { + return 'UNAVAILABLE'; + } + } +} diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts new file mode 100644 index 00000000..20a32a8c --- /dev/null +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +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 { ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1, parseTenantScopeV1, type StableIdentifierV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000711'; +const workspaceId = '00000000-0000-4000-8000-000000000712'; +const actorId = '00000000-0000-4000-8000-000000000713'; +const correlationId = '00000000-0000-4000-8000-000000000714'; +const accountId = '00000000-0000-4000-8000-000000000715'; + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function scopeValue(value: unknown): TenantScopeV1 { + const parsed = parseTenantScopeV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid scope'); + return parsed.value; +} + +function context(scope: unknown, key = 'service-account-service') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function membership(roleId = 'owner', scope: unknown = { scopeType: 'organization', organizationId }) { + return { + id: stable('00000000-0000-4000-8000-000000000717'), + principalId: stable(actorId), + scope: scopeValue(scope), + roleId, + status: 'ACTIVE' as const, + revision: 1, + }; +} + +function service() { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership()]); + const secrets = [ + { secret: 'dbsa_first', digest: 'a'.repeat(64) }, + { secret: 'dbsa_second', digest: 'b'.repeat(64) }, + ]; + const service = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + iam, + { issue: () => secrets.shift() ?? { secret: 'dbsa_fallback', digest: 'c'.repeat(64) } }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + ); + return service; +} + +void test('[IAM-013] authorized creation returns a one-time secret but never the persisted digest', async () => { + const accountService = service(); + const result = await accountService.create(context({ scopeType: 'organization', organizationId }), { + name: 'Import worker', + permissions: ['artifact.record.read'], + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.secret, 'dbsa_first'); + assert.equal('secretDigest' in result.value.account, false); + assert.equal(result.value.account.status, 'ACTIVE'); +}); + +void test('[IAM-013] service account management requires the delegated IAM permission and target scope', async () => { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership('viewer')]); + const accountService = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + iam, + { issue: () => ({ secret: 'dbsa', digest: 'd'.repeat(64) }) }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + ); + assert.deepEqual( + await accountService.create(context({ scopeType: 'organization', organizationId }), { + name: 'Denied', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + iam.seed([membership('owner')]); + assert.deepEqual( + await accountService.create(context({ scopeType: 'workspace', organizationId, workspaceId }), { + name: 'Workspace worker', + workspaceId: '00000000-0000-4000-8000-000000000799', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); +}); + +void test('[IAM-013] rotation is revision guarded and revocation is permanent', async () => { + const accountService = service(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'lifecycle'); + const created = await accountService.create(organizationContext, { + name: 'Lifecycle worker', + permissions: ['artifact.record.read'], + }); + assert.equal(created.accepted, true); + const rotated = await accountService.rotate(organizationContext, accountId, 1); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.equal(rotated.value.secret, 'dbsa_second'); + assert.deepEqual(await accountService.rotate(organizationContext, accountId, 1), { + accepted: false, + code: 'CONFLICT', + }); + const revoked = await accountService.revoke(organizationContext, accountId, 2); + assert.equal(revoked.accepted, true); + assert.deepEqual(await accountService.revoke(organizationContext, accountId, 3), { + accepted: false, + code: 'REVOKED', + }); + assert.equal((await accountService.list(organizationContext)).accepted, true); +}); From 9ebad8fcf32ce58b8ffd46c3669d309999c6448e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:51:00 +0700 Subject: [PATCH 36/99] feat(iam): generate one-time service account secrets --- .../random-service-account-secret.adapter.ts | 23 +++++++++++++++++++ .../service-account-secret.adapter.test.ts | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts create mode 100644 services/api/test/features/iam/service-account-secret.adapter.test.ts diff --git a/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts b/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts new file mode 100644 index 00000000..ddec4f72 --- /dev/null +++ b/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import type { + ServiceAccountSecretIssueV1, + ServiceAccountSecretIssuerV1, +} from '../application/service-account.service.js'; + +export type ServiceAccountRandomBytesV1 = (size: number) => Buffer; + +/** Generates credentials only at issuance time; callers must persist the digest, never the secret. */ +export class RandomServiceAccountSecretIssuer implements ServiceAccountSecretIssuerV1 { + public constructor( + private readonly source: ServiceAccountRandomBytesV1 = (size) => randomBytes(size), + ) {} + + public issue(): ServiceAccountSecretIssueV1 { + const bytes = this.source(32); + if (!Buffer.isBuffer(bytes) || bytes.length !== 32) throw new Error('SECRET_GENERATION_FAILED'); + const secret = `dbsa_${bytes.toString('base64url')}`; + const digest = createHash('sha256').update(secret, 'utf8').digest('hex'); + return Object.freeze({ secret, digest }); + } +} diff --git a/services/api/test/features/iam/service-account-secret.adapter.test.ts b/services/api/test/features/iam/service-account-secret.adapter.test.ts new file mode 100644 index 00000000..42157895 --- /dev/null +++ b/services/api/test/features/iam/service-account-secret.adapter.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createHash } from 'node:crypto'; + +import { RandomServiceAccountSecretIssuer } from '../../../src/features/iam/adapter/random-service-account-secret.adapter.js'; + +void test('[IAM-013] random service-account secrets are high-entropy and digestable without retaining raw bytes', () => { + const issuer = new RandomServiceAccountSecretIssuer(() => Buffer.alloc(32, 7)); + const issued = issuer.issue(); + assert.match(issued.secret, /^dbsa_[A-Za-z0-9_-]{43}$/u); + assert.equal( + issued.digest, + createHash('sha256').update(issued.secret, 'utf8').digest('hex'), + ); + assert.equal(issued.digest.length, 64); +}); + +void test('[IAM-013] malformed random sources fail closed instead of issuing a short credential', () => { + const issuer = new RandomServiceAccountSecretIssuer(() => Buffer.alloc(8, 1)); + assert.throws(() => issuer.issue(), /SECRET_GENERATION_FAILED/); +}); From 87530e71379cafcc5027384860583ad04239e26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:51:38 +0700 Subject: [PATCH 37/99] feat(iam): persist service account records --- .../migration.sql | 28 +++++++++++++++++++ services/api/prisma/schema/iam.prisma | 23 +++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql diff --git a/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql b/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql new file mode 100644 index 00000000..4f0e1c6d --- /dev/null +++ b/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql @@ -0,0 +1,28 @@ +-- IAM-013: store only scoped service-account metadata and a digest of the one-time secret. +CREATE TABLE "iam"."service_accounts" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "name" VARCHAR(200) NOT NULL, + "permissions" JSONB NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "secret_digest" CHAR(64) NOT NULL, + "secret_version" INTEGER NOT NULL DEFAULT 1, + "secret_issued_at" TIMESTAMPTZ(6) NOT NULL, + "secret_expires_at" TIMESTAMPTZ(6), + "last_used_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "service_accounts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "service_accounts_secret_digest_key" +ON "iam"."service_accounts"("secret_digest"); + +CREATE INDEX "service_accounts_scope_status_idx" +ON "iam"."service_accounts"("organization_id", "workspace_id", "status"); + +CREATE INDEX "service_accounts_expiry_status_idx" +ON "iam"."service_accounts"("secret_expires_at", "status"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 3140d3d0..4e1a6404 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -283,3 +283,26 @@ model AuthorizationSnapshot { @@map("authorization_snapshots") @@schema("iam") } + +/// IAM-013: non-interactive identities retain only a digest of their one-time secret. +model ServiceAccountRecord { + id String @id @db.Uuid + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + name String @db.VarChar(200) + permissions Json + status String @default("ACTIVE") @db.VarChar(16) + secretDigest String @unique(map: "service_accounts_secret_digest_key") @map("secret_digest") @db.Char(64) + secretVersion Int @default(1) @map("secret_version") + secretIssuedAt DateTime @map("secret_issued_at") @db.Timestamptz(6) + secretExpiresAt DateTime? @map("secret_expires_at") @db.Timestamptz(6) + lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([organizationId, workspaceId, status], map: "service_accounts_scope_status_idx") + @@index([secretExpiresAt, status], map: "service_accounts_expiry_status_idx") + @@map("service_accounts") + @@schema("iam") +} From 73b4d99fb2cd441256b3f5daff4211100a439550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:54:02 +0700 Subject: [PATCH 38/99] feat(iam): add service account prisma adapter --- ...isma-service-account-repository.adapter.ts | 268 ++++++++++++++++++ .../prisma-service-account-repository.test.ts | 187 ++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-service-account-repository.test.ts 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 new file mode 100644 index 00000000..aeda7bd0 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -0,0 +1,268 @@ +import { + createServiceAccountV1, + type ServiceAccountV1, +} from '@databreeze/domain/service-account/v1'; +import { + parseStrictUtcTimestampV1, + tenantScopeContainsV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import type { + ServiceAccountRepositoryPortV1, + ServiceAccountTransactionPortV1, +} from '../application/service-account-repository.port.js'; + +export interface ServiceAccountDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly name: string; + readonly permissions: unknown; + readonly status: string; + readonly secretDigest: string; + readonly secretVersion: number; + readonly secretIssuedAt: Date; + readonly secretExpiresAt: Date | null; + readonly lastUsedAt: Date | null; + readonly createdAt: Date; + readonly revokedAt: Date | null; + readonly revision: number; +} + +interface ServiceAccountDelegateV1 { + create(input: { + readonly data: Readonly>; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +export interface ServiceAccountDatabaseClientV1 { + readonly serviceAccount: ServiceAccountDelegateV1; + $transaction( + work: (transaction: ServiceAccountDatabaseClientV1) => Promise, + ): Promise; +} + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +function writableInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + return tenantScopeContainsV1(context.tenantScope, accountScope(account)); +} + +function timestamp(value: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return undefined; + const parsed = parseStrictUtcTimestampV1(value.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function accountFromRow(row: ServiceAccountDatabaseRowV1): ServiceAccountV1 { + const created = createServiceAccountV1({ + id: row.id, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + name: row.name, + permissions: row.permissions, + secretDigest: row.secretDigest, + secretIssuedAt: timestamp(row.secretIssuedAt), + ...(row.secretExpiresAt === null ? {} : { secretExpiresAt: timestamp(row.secretExpiresAt) }), + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + if ( + (row.status !== 'ACTIVE' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.secretVersion) || + row.secretVersion < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + const secretExpiresAt = timestamp(row.secretExpiresAt); + const lastUsedAt = timestamp(row.lastUsedAt); + const revokedAt = timestamp(row.revokedAt); + if ( + (row.secretExpiresAt !== null && !secretExpiresAt) || + (row.lastUsedAt !== null && !lastUsedAt) || + (row.revokedAt !== null && !revokedAt) || + (row.status === 'ACTIVE' && revokedAt !== undefined) || + (row.status === 'REVOKED' && revokedAt === undefined) + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + if (lastUsedAt && Date.parse(lastUsedAt) < Date.parse(created.value.secretIssuedAt)) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + secretVersion: row.secretVersion, + revision: row.revision, + ...(secretExpiresAt ? { secretExpiresAt } : {}), + ...(lastUsedAt ? { lastUsedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function accountData(account: ServiceAccountV1): Readonly> { + return { + id: account.id, + organizationId: account.organizationId, + workspaceId: account.workspaceId ?? null, + name: account.name, + permissions: account.permissions, + status: account.status, + secretDigest: account.secretDigest, + secretVersion: account.secretVersion, + secretIssuedAt: new Date(account.secretIssuedAt), + secretExpiresAt: account.secretExpiresAt ? new Date(account.secretExpiresAt) : null, + lastUsedAt: account.lastUsedAt ? new Date(account.lastUsedAt) : null, + createdAt: new Date(account.createdAt), + revokedAt: account.revokedAt ? new Date(account.revokedAt) : null, + revision: account.revision, + }; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + const organizationId = context.tenantScope.organizationId; + if (context.tenantScope.scopeType === 'organization') return { organizationId }; + return { + organizationId, + OR: [{ workspaceId: null }, { workspaceId: context.tenantScope.workspaceId }], + }; +} + +function isUniqueConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactionPortV1 { + public constructor(private readonly client: ServiceAccountDatabaseClientV1) {} + + public async findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: StableIdentifierV1, + ): Promise { + const row = await this.client.serviceAccount.findFirst({ + where: { id: serviceAccountId, ...scopeWhere(context) }, + }); + return row ? accountFromRow(row) : undefined; + } + + public async listServiceAccounts( + context: IamTenantContextV1, + ): Promise { + const rows = await this.client.serviceAccount.findMany({ + where: scopeWhere(context), + orderBy: { createdAt: 'desc' }, + }); + return rows.map(accountFromRow); + } + + public async saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + ): Promise { + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const existing = await this.client.serviceAccount.findFirst({ + where: { id: account.id, organizationId: account.organizationId }, + }); + if (existing) { + if (JSON.stringify(accountFromRow(existing)) !== JSON.stringify(account)) + throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); + return; + } + try { + await this.client.serviceAccount.create({ data: accountData(account) }); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + throw error; + } + } + + public async replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise { + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const current = await this.findServiceAccount(context, account.id); + if (!current) throw new Error('SERVICE_ACCOUNT_NOT_FOUND'); + if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); + if (account.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); + try { + const updated = await this.client.serviceAccount.updateMany({ + where: { + id: account.id, + organizationId: account.organizationId, + workspaceId: account.workspaceId ?? null, + revision: expectedRevision, + }, + data: accountData(account), + }); + if (updated.count !== 1) throw new Error('REVISION_CONFLICT'); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + throw error; + } + } +} + +/** PostgreSQL adapter for scoped service-account metadata and optimistic lifecycle writes. */ +export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepositoryPortV1 { + public constructor(private readonly client: ServiceAccountDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaServiceAccountTransactionAdapter(transaction)), + ); + } + + public saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).saveServiceAccount(context, account); + } + + public findServiceAccount(context: IamTenantContextV1, serviceAccountId: StableIdentifierV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccount( + context, + serviceAccountId, + ); + } + + public listServiceAccounts(context: IamTenantContextV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).listServiceAccounts(context); + } + + public replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ) { + return new PrismaServiceAccountTransactionAdapter(this.client).replaceServiceAccount( + context, + account, + expectedRevision, + ); + } +} 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 new file mode 100644 index 00000000..391d1280 --- /dev/null +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createServiceAccountV1, type ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaServiceAccountRepositoryAdapter, + type ServiceAccountDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-service-account-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000721'; +const workspaceId = '00000000-0000-4000-8000-000000000722'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000723'; +const accountId = '00000000-0000-4000-8000-000000000724'; +const actorId = '00000000-0000-4000-8000-000000000725'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(scope: unknown, key = 'prisma-service-account') { + const result = createIamTenantContextV1({ + actorId, + correlationId: '00000000-0000-4000-8000-000000000726', + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function account(): ServiceAccountV1 { + const result = createServiceAccountV1({ + id: accountId, + organizationId, + workspaceId, + name: 'Import worker', + permissions: ['artifact.record.read'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid account'); + return result.value; +} + +function delegate(rows: Record[], forceConflict = false) { + return { + create({ data }: { readonly data: Record }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => { + if (where['OR']) { + const alternatives = where['OR'] as readonly Record[]; + const base = Object.fromEntries(Object.entries(where).filter(([key]) => key !== 'OR')); + return ( + Object.entries(base).every(([key, value]) => row[key] === value) && + alternatives.some((candidate) => + Object.entries(candidate).every(([key, value]) => row[key] === value), + ) + ); + } + return Object.entries(where).every(([key, value]) => row[key] === value); + }) ?? null, + ); + }, + findMany({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.filter((row) => { + if (where['OR']) { + const alternatives = where['OR'] as readonly Record[]; + const base = Object.fromEntries(Object.entries(where).filter(([key]) => key !== 'OR')); + return ( + Object.entries(base).every(([key, value]) => row[key] === value) && + alternatives.some((candidate) => + Object.entries(candidate).every(([key, value]) => row[key] === value), + ) + ); + } + return Object.entries(where).every(([key, value]) => row[key] === value); + }), + ); + }, + updateMany({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Record; + }) { + if (forceConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, + }; +} + +function rowFor(value = account()): Record { + return { + id: value.id, + organizationId: value.organizationId, + workspaceId: value.workspaceId ?? null, + name: value.name, + permissions: value.permissions, + status: value.status, + secretDigest: value.secretDigest, + secretVersion: value.secretVersion, + secretIssuedAt: new Date(value.secretIssuedAt), + secretExpiresAt: value.secretExpiresAt ? new Date(value.secretExpiresAt) : null, + lastUsedAt: value.lastUsedAt ? new Date(value.lastUsedAt) : null, + createdAt: new Date(value.createdAt), + revokedAt: value.revokedAt ? new Date(value.revokedAt) : null, + revision: value.revision, + }; +} + +function client( + rows: Record[] = [], + forceConflict = false, +): ServiceAccountDatabaseClientV1 { + const database = { + serviceAccount: delegate(rows, forceConflict), + async $transaction( + work: (transaction: ServiceAccountDatabaseClientV1) => Promise, + ) { + return work(database as unknown as ServiceAccountDatabaseClientV1); + }, + }; + return database as unknown as ServiceAccountDatabaseClientV1; +} + +void test('[IAM-013] Prisma service-account adapter persists and filters workspace scope', async () => { + const rows: Record[] = []; + const repository = new PrismaServiceAccountRepositoryAdapter(client(rows)); + await repository.saveServiceAccount(context({ scopeType: 'organization', organizationId }), account()); + assert.equal( + (await repository.findServiceAccount(context({ scopeType: 'workspace', organizationId, workspaceId }), stable(accountId)))?.name, + 'Import worker', + ); + assert.equal( + (await repository.findServiceAccount(context({ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }), stable(accountId))), + undefined, + ); + assert.equal((await repository.listServiceAccounts(context({ scopeType: 'organization', organizationId }))).length, 1); +}); + +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 }); + await repository.replaceServiceAccount(context({ scopeType: 'organization', organizationId }), next, 1); + await assert.rejects( + new PrismaServiceAccountRepositoryAdapter(client([rowFor()], true)).replaceServiceAccount( + context({ scopeType: 'organization', organizationId }), + next, + 1, + ), + /REVISION_CONFLICT/u, + ); +}); + +void test('[IAM-013] Prisma service-account adapter fails closed on malformed persisted state', async () => { + const malformed = rowFor(); + malformed['secretDigest'] = 'not-a-digest'; + const repository = new PrismaServiceAccountRepositoryAdapter( + client([malformed]), + ); + await assert.rejects( + repository.findServiceAccount(context({ scopeType: 'organization', organizationId }), stable(accountId)), + /IAM_PERSISTED_SERVICE_ACCOUNT_INVALID/u, + ); +}); From e32ea588839f0e1a8819a103a45a190f26336673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:57:48 +0700 Subject: [PATCH 39/99] feat(iam): expose service account lifecycle api --- .../iam/api/service-account.controller.ts | 102 ++++++++++++++++++ .../features/iam/api/service-account.dto.ts | 48 +++++++++ .../service-account-problem.error.ts | 15 +++ services/api/src/features/iam/iam.module.ts | 55 ++++++++++ .../iam/service-account-composition.test.ts | 27 +++++ .../iam/service-account.controller.test.ts | 78 ++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 services/api/src/features/iam/api/service-account.controller.ts create mode 100644 services/api/src/features/iam/api/service-account.dto.ts create mode 100644 services/api/src/features/iam/application/service-account-problem.error.ts create mode 100644 services/api/test/features/iam/service-account-composition.test.ts create mode 100644 services/api/test/features/iam/service-account.controller.test.ts diff --git a/services/api/src/features/iam/api/service-account.controller.ts b/services/api/src/features/iam/api/service-account.controller.ts new file mode 100644 index 00000000..8cd41695 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.controller.ts @@ -0,0 +1,102 @@ +import { Body, Controller, Get, Headers, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + SERVICE_ACCOUNT_SERVICE, + type ServiceAccountApplicationResultV1, + type ServiceAccountService, +} from '../application/service-account.service.js'; +import { ServiceAccountProblemError } from '../application/service-account-problem.error.js'; +import { CreateServiceAccountDto, ServiceAccountRevisionDto } from './service-account.dto.js'; + +@ApiTags('service-accounts') +@ApiBearerAuth() +@Controller('v1') +export class ServiceAccountController { + public constructor( + @Inject(SERVICE_ACCOUNT_SERVICE) + private readonly serviceAccounts: ServiceAccountService, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private async execute( + work: () => Promise>, + ): Promise { + let result: ServiceAccountApplicationResultV1; + try { + result = await work(); + } catch { + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'SCOPE_DENIED') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_SCOPE_DENIED'); + if (result.code === 'NOT_FOUND') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_NOT_FOUND'); + if (result.code === 'CONFLICT') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_CONFLICT'); + if (result.code === 'REVOKED') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REVOKED'); + if (result.code === 'EXPIRED') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_EXPIRED'); + if (result.code === 'UNAVAILABLE') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_UNAVAILABLE'); + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REQUEST_REJECTED'); + } + + @Get('organizations/:organizationId/service-accounts') + @ApiOperation({ summary: 'List content-free service-account identities in an organization scope' }) + async list(@Req() request: unknown, @Param('organizationId') organizationId: string): Promise { + const context = await this.requestContext.resolve(request); + const parsed = parseStableIdentifierV1(organizationId); + if (!parsed.accepted || parsed.value !== context.tenantScope.organizationId) + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_SCOPE_DENIED'); + return this.execute(() => this.serviceAccounts.list(context)); + } + + @Post('service-accounts') + @HttpCode(201) + @ApiOperation({ 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 { + const context = await this.requestContext.resolve(request); + void _idempotencyKey; + return this.execute(() => this.serviceAccounts.create(context, input)); + } + + @Post('service-accounts/:serviceAccountId/rotate') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate a service-account secret and return the successor once' }) + @ApiBody({ type: ServiceAccountRevisionDto }) + async rotate( + @Req() request: unknown, + @Param('serviceAccountId') serviceAccountId: string, + @Body() input: ServiceAccountRevisionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => this.serviceAccounts.rotate(context, serviceAccountId, input.expectedRevision)); + } + + @Post('service-accounts/:serviceAccountId/revoke') + @HttpCode(200) + @ApiOperation({ summary: 'Permanently revoke a service-account identity' }) + @ApiBody({ type: ServiceAccountRevisionDto }) + async revoke( + @Req() request: unknown, + @Param('serviceAccountId') serviceAccountId: string, + @Body() input: ServiceAccountRevisionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => this.serviceAccounts.revoke(context, serviceAccountId, input.expectedRevision)); + } +} diff --git a/services/api/src/features/iam/api/service-account.dto.ts b/services/api/src/features/iam/api/service-account.dto.ts new file mode 100644 index 00000000..538fb8b8 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsISO8601, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateServiceAccountDto { + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Optional workspace narrowing for the identity' }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 64 }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(64) + @IsString({ each: true }) + permissions!: string[]; + + @ApiPropertyOptional({ format: 'date-time', description: 'Optional expiry, at most 365 days after issue' }) + @IsOptional() + @IsISO8601() + secretExpiresAt?: string; +} + +export class ServiceAccountRevisionDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + expectedRevision!: number; +} diff --git a/services/api/src/features/iam/application/service-account-problem.error.ts b/services/api/src/features/iam/application/service-account-problem.error.ts new file mode 100644 index 00000000..66ff60f5 --- /dev/null +++ b/services/api/src/features/iam/application/service-account-problem.error.ts @@ -0,0 +1,15 @@ +export type ServiceAccountProblemCodeV1 = + | 'SERVICE_ACCOUNT_REQUEST_REJECTED' + | 'SERVICE_ACCOUNT_SCOPE_DENIED' + | 'SERVICE_ACCOUNT_NOT_FOUND' + | 'SERVICE_ACCOUNT_CONFLICT' + | 'SERVICE_ACCOUNT_REVOKED' + | 'SERVICE_ACCOUNT_EXPIRED' + | 'SERVICE_ACCOUNT_UNAVAILABLE'; + +export class ServiceAccountProblemError extends Error { + public constructor(readonly code: ServiceAccountProblemCodeV1) { + super(code); + this.name = 'ServiceAccountProblemError'; + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 9f6ca8a4..330e8ddb 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -151,6 +151,7 @@ import { DeviceIdentityController } from './api/device-identity.controller.js'; import { IamInvitationController } from './api/invitation.controller.js'; import { RegistrationController } from './api/registration.controller.js'; import { RecoveryController } from './api/recovery.controller.js'; +import { ServiceAccountController } from './api/service-account.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { PrismaDeviceIdentityRepositoryAdapter, @@ -166,6 +167,23 @@ import { DEVICE_IDENTITY_REPOSITORY_PORT, type DeviceIdentityRepositoryPortV1, } from './application/device-identity-repository.port.js'; +import { + SERVICE_ACCOUNT_REPOSITORY_PORT, + type ServiceAccountRepositoryPortV1, +} from './application/service-account-repository.port.js'; +import { + SERVICE_ACCOUNT_SERVICE, + ServiceAccountService, + type ServiceAccountClockV1, + type ServiceAccountIdGeneratorV1, + type ServiceAccountSecretIssuerV1, +} from './application/service-account.service.js'; +import { InMemoryServiceAccountRepositoryAdapter } from './adapter/in-memory-service-account-repository.adapter.js'; +import { + PrismaServiceAccountRepositoryAdapter, + type ServiceAccountDatabaseClientV1, +} from './adapter/prisma-service-account-repository.adapter.js'; +import { RandomServiceAccountSecretIssuer } from './adapter/random-service-account-secret.adapter.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -231,6 +249,12 @@ export interface IamModuleOptions { readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; readonly deviceEnrollmentProofVerifier?: DeviceEnrollmentProofVerifierV1; + readonly serviceAccountService?: ServiceAccountService; + readonly serviceAccountRepository?: ServiceAccountRepositoryPortV1; + readonly serviceAccountDatabase?: ServiceAccountDatabaseClientV1; + readonly serviceAccountSecretIssuer?: ServiceAccountSecretIssuerV1; + readonly serviceAccountClock?: ServiceAccountClockV1; + readonly serviceAccountIdGenerator?: ServiceAccountIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -419,11 +443,28 @@ export class IamModule { deviceIdentityRepository, options.deviceEnrollmentProofVerifier ?? new UnavailableDeviceEnrollmentProofVerifier(), ); + const serviceAccountRepository = + options.serviceAccountRepository ?? + (options.serviceAccountDatabase === undefined + ? new InMemoryServiceAccountRepositoryAdapter() + : new PrismaServiceAccountRepositoryAdapter(options.serviceAccountDatabase)); + const serviceAccountService = + options.serviceAccountService ?? + (options.iamRepository === undefined + ? undefined + : new ServiceAccountService( + serviceAccountRepository, + options.iamRepository, + options.serviceAccountSecretIssuer ?? new RandomServiceAccountSecretIssuer(), + options.serviceAccountClock, + options.serviceAccountIdGenerator, + )); const exports = [ DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE, IAM_HIERARCHY_REPOSITORY, IAM_HIERARCHY_SERVICE, + SERVICE_ACCOUNT_REPOSITORY_PORT, ]; if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); @@ -442,6 +483,7 @@ export class IamModule { if (recoveryService) exports.unshift(IAM_RECOVERY_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_COMPLETION_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); + if (serviceAccountService) exports.unshift(SERVICE_ACCOUNT_SERVICE); return { module: IamModule, controllers: [ @@ -454,6 +496,7 @@ export class IamModule { RegistrationController, RecoveryController, IamBootstrapController, + ...(serviceAccountService ? [ServiceAccountController] : []), ], providers: [ { @@ -604,6 +647,18 @@ export class IamModule { provide: DEVICE_IDENTITY_SERVICE, useValue: deviceIdentityService, }, + { + provide: SERVICE_ACCOUNT_REPOSITORY_PORT, + useValue: serviceAccountRepository, + }, + ...(serviceAccountService + ? [ + { + provide: SERVICE_ACCOUNT_SERVICE, + useValue: serviceAccountService, + }, + ] + : []), { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/test/features/iam/service-account-composition.test.ts b/services/api/test/features/iam/service-account-composition.test.ts new file mode 100644 index 00000000..f25a5e47 --- /dev/null +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ServiceAccountController } from '../../../src/features/iam/api/service-account.controller.js'; +import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { + SERVICE_ACCOUNT_REPOSITORY_PORT, +} from '../../../src/features/iam/application/service-account-repository.port.js'; +import { SERVICE_ACCOUNT_SERVICE, ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; + +void test('[IAM-013] IAM composition registers a replaceable service-account repository and lifecycle service', () => { + const service = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + { findMembership: () => Promise.resolve(undefined) } as never, + { issue: () => ({ secret: 'dbsa', digest: 'a'.repeat(64) }) }, + ); + const registered = IamModule.register({ serviceAccountService: service }); + assert.ok(registered.controllers?.includes(ServiceAccountController)); + assert.ok(registered.exports?.includes(SERVICE_ACCOUNT_REPOSITORY_PORT)); + assert.ok(registered.exports?.includes(SERVICE_ACCOUNT_SERVICE)); + assert.ok( + registered.providers?.some( + (provider) => typeof provider === 'object' && provider !== null && 'provide' in provider && provider.provide === SERVICE_ACCOUNT_SERVICE, + ), + ); +}); diff --git a/services/api/test/features/iam/service-account.controller.test.ts b/services/api/test/features/iam/service-account.controller.test.ts new file mode 100644 index 00000000..5c9ad82f --- /dev/null +++ b/services/api/test/features/iam/service-account.controller.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ServiceAccountController } from '../../../src/features/iam/api/service-account.controller.js'; +import { ServiceAccountProblemError } from '../../../src/features/iam/application/service-account-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const actorId = '00000000-0000-4000-8000-000000000732'; +const correlationId = '00000000-0000-4000-8000-000000000733'; +const serviceAccountId = '00000000-0000-4000-8000-000000000734'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'organization', organizationId }, + idempotencyKey: 'controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const service = { + list: () => Promise.resolve({ accepted: true as const, value: [{ id: serviceAccountId }] }), + create: () => Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'one-time' }, + }), + rotate: () => Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'successor' }, + }), + revoke: () => Promise.resolve({ accepted: true as const, value: { id: serviceAccountId } }), + ...overrides, + }; + const requestContext = { resolve: () => Promise.resolve(context()) }; + return new ServiceAccountController(service as never, requestContext); +} + +void test('[IAM-013] controller exposes safe list/create/rotate/revoke results', async () => { + 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'], + }), { account: { id: serviceAccountId }, secret: 'one-time' }); + assert.deepEqual(await instance.rotate({}, serviceAccountId, { expectedRevision: 1 }), { + account: { id: serviceAccountId }, + secret: 'successor', + }); + assert.deepEqual(await instance.revoke({}, serviceAccountId, { expectedRevision: 2 }), { + id: serviceAccountId, + }); +}); + +void test('[IAM-013] controller rejects a path outside the authenticated organization', async () => { + await assert.rejects( + controller().list({}, '00000000-0000-4000-8000-000000000799'), + (error: unknown) => + error instanceof ServiceAccountProblemError && error.code === 'SERVICE_ACCOUNT_SCOPE_DENIED', + ); +}); + +void test('[IAM-013] controller maps lifecycle failures to stable problem codes', async () => { + await assert.rejects( + controller({ revoke: () => Promise.resolve({ accepted: false as const, code: 'CONFLICT' as const }) }).revoke( + {}, + serviceAccountId, + { expectedRevision: 1 }, + ), + (error: unknown) => + error instanceof ServiceAccountProblemError && error.code === 'SERVICE_ACCOUNT_CONFLICT', + ); +}); From 7bf5ba0f05a312f96c52d3d5a5c774d93a524aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 01:59:08 +0700 Subject: [PATCH 40/99] feat(iam): resolve service accounts by secret digest --- ...memory-service-account-repository.adapter.ts | 12 ++++++++++++ ...prisma-service-account-repository.adapter.ts | 17 +++++++++++++++++ .../service-account-repository.port.ts | 4 ++++ .../prisma-service-account-repository.test.ts | 4 ++++ .../iam/service-account-repository.test.ts | 8 ++++++++ 5 files changed, 45 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 f873442d..1fb19b1e 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 @@ -47,6 +47,17 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe return account && visibleInScope(context, account) ? clone(account) : undefined; } + public async findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise { + await Promise.resolve(); + const account = [...this.accounts.values()].find( + (candidate) => candidate.secretDigest === secretDigest && visibleInScope(context, candidate), + ); + return account ? clone(account) : undefined; + } + public async listServiceAccounts( context: IamTenantContextV1, ): Promise { @@ -107,6 +118,7 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe try { return await work({ findServiceAccount: this.findServiceAccount.bind(this), + findServiceAccountByDigest: this.findServiceAccountByDigest.bind(this), listServiceAccounts: this.listServiceAccounts.bind(this), saveServiceAccount: this.saveServiceAccount.bind(this), replaceServiceAccount: this.replaceServiceAccount.bind(this), 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 aeda7bd0..4d8d7945 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 @@ -167,6 +167,16 @@ class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactio return row ? accountFromRow(row) : undefined; } + public async findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise { + const row = await this.client.serviceAccount.findFirst({ + where: { secretDigest, ...scopeWhere(context) }, + }); + return row ? accountFromRow(row) : undefined; + } + public async listServiceAccounts( context: IamTenantContextV1, ): Promise { @@ -250,6 +260,13 @@ export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepo ); } + public findServiceAccountByDigest(context: IamTenantContextV1, secretDigest: string) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccountByDigest( + context, + secretDigest, + ); + } + public listServiceAccounts(context: IamTenantContextV1) { return new PrismaServiceAccountTransactionAdapter(this.client).listServiceAccounts(context); } 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 e903b783..c7fc438c 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 @@ -10,6 +10,10 @@ export interface ServiceAccountTransactionPortV1 { context: IamTenantContextV1, serviceAccountId: StableIdentifierV1, ): Promise; + findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise; listServiceAccounts(context: IamTenantContextV1): Promise; saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1): Promise; replaceServiceAccount( 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 391d1280..5fff1b1f 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 @@ -157,6 +157,10 @@ void test('[IAM-013] Prisma service-account adapter persists and filters workspa (await repository.findServiceAccount(context({ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }), stable(accountId))), undefined, ); + assert.equal( + (await repository.findServiceAccountByDigest(context({ scopeType: 'organization', organizationId }), 'a'.repeat(64)))?.id, + stable(accountId), + ); assert.equal((await repository.listServiceAccounts(context({ scopeType: 'organization', organizationId }))).length, 1); }); 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 b6de58a5..75389f09 100644 --- a/services/api/test/features/iam/service-account-repository.test.ts +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -61,6 +61,14 @@ void test('[IAM-013] service account repository preserves tenant scope and immut const found = await repository.findServiceAccount(organizationContext, stableAccountId); assert.deepEqual(found, account()); assert.notEqual(found, account()); + assert.equal( + (await repository.findServiceAccountByDigest(organizationContext, 'a'.repeat(64)))?.id, + stableAccountId, + ); + assert.equal( + await repository.findServiceAccountByDigest(organizationContext, 'b'.repeat(64)), + undefined, + ); assert.equal( (await repository.findServiceAccount(context({ scopeType: 'organization', organizationId: otherOrganizationId }), stableAccountId)), undefined, From d02aa7eaaf1b561dc140b39cac3a5a7939bd76da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:01:08 +0700 Subject: [PATCH 41/99] feat(iam): authenticate service account credentials --- .../application/service-account.service.ts | 46 ++++++++++++++++++- .../iam/service-account.service.test.ts | 33 ++++++++++++- 2 files changed, 76 insertions(+), 3 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 e0d7f4ce..5a712af0 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -1,8 +1,9 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import { createServiceAccountV1, isServiceAccountSecretUsableV1, + markServiceAccountUsedV1, revokeServiceAccountV1, rotateServiceAccountSecretV1, type ServiceAccountV1, @@ -46,6 +47,8 @@ export interface IssuedServiceAccountV1 { readonly secret: string; } +export type ServiceAccountPrincipalV1 = ServiceAccountSafeViewV1; + export type ServiceAccountApplicationCodeV1 = | 'INVALID_IDENTIFIER' | 'INVALID_SCOPE' @@ -53,6 +56,7 @@ export type ServiceAccountApplicationCodeV1 = | 'SCOPE_DENIED' | 'NOT_FOUND' | 'CONFLICT' + | 'INVALID_CREDENTIALS' | 'REVOKED' | 'EXPIRED' | 'UNAVAILABLE'; @@ -104,6 +108,18 @@ function mapRepositoryError(error: unknown): ServiceAccountApplicationCodeV1 { return 'UNAVAILABLE'; } +function digestSecret(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > 512 || /\p{Cc}/u.test(input)) + return undefined; + return createHash('sha256').update(input, 'utf8').digest('hex'); +} + +function safeDigestEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + function identifier(input: unknown): StableIdentifierV1 | undefined { const parsed = parseStableIdentifierV1(input); return parsed.accepted ? parsed.value : undefined; @@ -217,6 +233,34 @@ export class ServiceAccountService { } } + /** Authenticate an already-scoped service-account bearer and advance last-use atomically. */ + public async authenticate( + context: IamTenantContextV1, + presentedSecret: unknown, + nowInput: unknown, + ): Promise> { + const digest = digestSecret(presentedSecret); + if (!digest) return rejected('INVALID_CREDENTIALS'); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccountByDigest(context, digest); + if (!current || !safeDigestEqual(current.secretDigest, digest)) + return rejected('INVALID_CREDENTIALS'); + const usable = isServiceAccountSecretUsableV1(current, nowInput); + if (!usable.accepted) return rejected('INVALID_CREDENTIALS'); + const used = markServiceAccountUsedV1(current, nowInput); + if (!used.accepted) return rejected('INVALID_CREDENTIALS'); + try { + await transaction.replaceServiceAccount(context, used.value, current.revision); + return accepted(safeView(used.value)); + } catch (error) { + const mapped = mapRepositoryError(error); + return rejected(mapped === 'CONFLICT' ? 'CONFLICT' : 'UNAVAILABLE'); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); + } + public async rotate( context: IamTenantContextV1, serviceAccountIdInput: unknown, 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 20a32a8c..25d0d4b0 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +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'; @@ -54,9 +55,10 @@ function membership(roleId = 'owner', scope: unknown = { scopeType: 'organizatio function service() { const iam = new InMemoryIamRepositoryAdapter(); iam.seed([membership()]); + const digest = (secret: string) => createHash('sha256').update(secret, 'utf8').digest('hex'); const secrets = [ - { secret: 'dbsa_first', digest: 'a'.repeat(64) }, - { secret: 'dbsa_second', digest: 'b'.repeat(64) }, + { secret: 'dbsa_first', digest: digest('dbsa_first') }, + { secret: 'dbsa_second', digest: digest('dbsa_second') }, ]; const service = new ServiceAccountService( new InMemoryServiceAccountRepositoryAdapter(), @@ -133,3 +135,30 @@ void test('[IAM-013] rotation is revision guarded and revocation is permanent', }); assert.equal((await accountService.list(organizationContext)).accepted, true); }); + +void test('[IAM-013] credential authentication is digest-bound, updates last use, and fails closed', async () => { + const accountService = service(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'authenticate'); + const created = await accountService.create(organizationContext, { + name: 'Auth worker', + permissions: ['artifact.record.read'], + }); + assert.equal(created.accepted, true); + const authenticated = await accountService.authenticate( + organizationContext, + 'dbsa_first', + '2026-01-01T00:01:00.000Z', + ); + assert.equal(authenticated.accepted, true); + if (!authenticated.accepted) return; + assert.equal(authenticated.value.id, accountId); + assert.equal(authenticated.value.lastUsedAt, '2026-01-01T00:01:00.000Z'); + assert.deepEqual( + await accountService.authenticate(organizationContext, 'wrong-secret', '2026-01-01T00:02:00.000Z'), + { accepted: false, code: 'INVALID_CREDENTIALS' }, + ); + assert.deepEqual( + await accountService.authenticate(organizationContext, 'dbsa_first', '2026-01-01T00:00:30.000Z'), + { accepted: false, code: 'INVALID_CREDENTIALS' }, + ); +}); From 03ef9db7964fb99430c9003b895e20b549a19d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:01:48 +0700 Subject: [PATCH 42/99] feat(audit): register service account lifecycle actions --- packages/domain/src/audit/v1.ts | 3 +++ .../test/audit-service-account-actions-v1.test.mjs | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/domain/test/audit-service-account-actions-v1.test.mjs diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index ba3a4ce6..55fe7f6b 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -25,6 +25,9 @@ export const AUDIT_ACTIONS_V1 = Object.freeze([ 'device.enrolled', 'device.activated', 'device.revoked', + 'service_account.created', + 'service_account.rotated', + 'service_account.revoked', 'entitlement.granted', 'entitlement.suspended', 'artifact.registered', diff --git a/packages/domain/test/audit-service-account-actions-v1.test.mjs b/packages/domain/test/audit-service-account-actions-v1.test.mjs new file mode 100644 index 00000000..b54d0169 --- /dev/null +++ b/packages/domain/test/audit-service-account-actions-v1.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as audit from '../dist/audit/v1.js'; + +void test('[IAM-013, AUD-002] service-account lifecycle actions are part of the closed audit vocabulary', () => { + assert.deepEqual( + ['service_account.created', 'service_account.rotated', 'service_account.revoked'].map((action) => + audit.AUDIT_ACTIONS_V1.includes(action), + ), + [true, true, true], + ); +}); From 528f0c912453d5fab2f84853dde6d686f3b07a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:04:52 +0700 Subject: [PATCH 43/99] feat(iam): publish service account api contract --- services/api/openapi/v1.json | 342 ++++++++++++++++++ .../application/service-account.service.ts | 58 +++ services/api/src/features/iam/iam.module.ts | 19 +- 3 files changed, 408 insertions(+), 11 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 80a4bcb4..16880f8e 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3397,6 +3397,320 @@ "tags": ["identity"] } }, + "/v1/organizations/{organizationId}/service-accounts": { + "get": { + "operationId": "ServiceAccountController.list", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List content-free service-account identities in an organization scope", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts": { + "post": { + "operationId": "ServiceAccountController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateServiceAccountDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an action-scoped service account and return its one-time secret", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts/{serviceAccountId}/rotate": { + "post": { + "operationId": "ServiceAccountController.rotate", + "parameters": [ + { + "name": "serviceAccountId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServiceAccountRevisionDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Rotate a service-account secret and return the successor once", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts/{serviceAccountId}/revoke": { + "post": { + "operationId": "ServiceAccountController.revoke", + "parameters": [ + { + "name": "serviceAccountId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServiceAccountRevisionDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Permanently revoke a service-account identity", + "tags": ["service-accounts"] + } + }, "/v1/artifacts/inbox": { "post": { "operationId": "InboxController.create", @@ -9813,6 +10127,34 @@ }, "required": ["accepted"] }, + "CreateServiceAccountDto": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "Optional workspace narrowing for the identity" + }, + "permissions": { + "minItems": 1, + "maxItems": 64, + "type": "array", + "items": { "type": "string" } + }, + "secretExpiresAt": { + "type": "string", + "format": "date-time", + "description": "Optional expiry, at most 365 days after issue" + } + }, + "required": ["name", "permissions"] + }, + "ServiceAccountRevisionDto": { + "type": "object", + "properties": { "expectedRevision": { "type": "number", "minimum": 1 } }, + "required": ["expectedRevision"] + }, "CreateInboxItemDto": { "type": "object", "properties": { 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 5a712af0..6a319069 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -80,6 +80,10 @@ function rejected(code: ServiceAccountApplicationCodeV1): ServiceAccountApplicat return Object.freeze({ accepted: false, code }); } +function unavailable(): ServiceAccountApplicationResultV1 { + return rejected('UNAVAILABLE'); +} + function safeView(account: ServiceAccountV1): ServiceAccountSafeViewV1 { const { secretDigest: _secretDigest, ...withoutDigest } = account; void _secretDigest; @@ -380,3 +384,57 @@ export class ServiceAccountService { } } } + +/** Safe default for hosts that have not composed an IAM membership repository yet. */ +export class UnavailableServiceAccountService { + public create( + _context: IamTenantContextV1, + _input: CreateServiceAccountInputV1, + ): Promise> { + void _context; + void _input; + return Promise.resolve(unavailable()); + } + + public list( + _context: IamTenantContextV1, + ): Promise> { + void _context; + return Promise.resolve(unavailable()); + } + + public rotate( + _context: IamTenantContextV1, + _serviceAccountId: unknown, + _expectedRevision: unknown, + _secretExpiresAt?: unknown, + ): Promise> { + void _context; + void _serviceAccountId; + void _expectedRevision; + void _secretExpiresAt; + return Promise.resolve(unavailable()); + } + + public revoke( + _context: IamTenantContextV1, + _serviceAccountId: unknown, + _expectedRevision: unknown, + ): Promise> { + void _context; + void _serviceAccountId; + void _expectedRevision; + return Promise.resolve(unavailable()); + } + + public authenticate( + _context: IamTenantContextV1, + _presentedSecret: unknown, + _now: unknown, + ): Promise> { + void _context; + void _presentedSecret; + void _now; + return Promise.resolve(unavailable()); + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 330e8ddb..c6dfd3bf 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -174,6 +174,7 @@ import { import { SERVICE_ACCOUNT_SERVICE, ServiceAccountService, + UnavailableServiceAccountService, type ServiceAccountClockV1, type ServiceAccountIdGeneratorV1, type ServiceAccountSecretIssuerV1, @@ -451,7 +452,7 @@ export class IamModule { const serviceAccountService = options.serviceAccountService ?? (options.iamRepository === undefined - ? undefined + ? new UnavailableServiceAccountService() : new ServiceAccountService( serviceAccountRepository, options.iamRepository, @@ -483,7 +484,7 @@ export class IamModule { if (recoveryService) exports.unshift(IAM_RECOVERY_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_COMPLETION_ADMISSION_PORT); if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); - if (serviceAccountService) exports.unshift(SERVICE_ACCOUNT_SERVICE); + exports.unshift(SERVICE_ACCOUNT_SERVICE); return { module: IamModule, controllers: [ @@ -496,7 +497,7 @@ export class IamModule { RegistrationController, RecoveryController, IamBootstrapController, - ...(serviceAccountService ? [ServiceAccountController] : []), + ServiceAccountController, ], providers: [ { @@ -651,14 +652,10 @@ export class IamModule { provide: SERVICE_ACCOUNT_REPOSITORY_PORT, useValue: serviceAccountRepository, }, - ...(serviceAccountService - ? [ - { - provide: SERVICE_ACCOUNT_SERVICE, - useValue: serviceAccountService, - }, - ] - : []), + { + provide: SERVICE_ACCOUNT_SERVICE, + useValue: serviceAccountService, + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), From f015b11c1aabedbd7d73ee0d0663aca0103e075b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:06:55 +0700 Subject: [PATCH 44/99] feat(audit): add signed seal attestations --- packages/domain/src/audit/v1.ts | 87 +++++++++++++++++++ .../test/audit-seal-attestation-v1.test.mjs | 61 +++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/domain/test/audit-seal-attestation-v1.test.mjs diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index 55fe7f6b..be01131c 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -102,6 +102,28 @@ export interface AuditSealV1 { readonly sealedAt: StrictUtcTimestampV1; } +/** AUD-015/016: an independently stored signature over an immutable seal range. */ +export const AUDIT_ATTESTATION_SCHEMA_VERSION_V1 = 1 as const; + +export interface AuditSealAttestationV1 { + readonly schemaVersion: typeof AUDIT_ATTESTATION_SCHEMA_VERSION_V1; + readonly attestationId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: StrictUtcTimestampV1; + readonly signerKeyId: string; + readonly payload: string; + readonly signature: string; +} + +export interface AuditSealAttestationSignerV1 { + sign(payload: string): string; + verify(payload: string, signature: string): boolean; +} + export type AuditErrorCodeV1 = | 'INVALID_IDENTIFIER' | 'INVALID_TIMESTAMP' @@ -188,6 +210,22 @@ function canonicalEvent(event: Omit): string { }); } +function canonicalAttestation( + input: Omit, +): string { + return JSON.stringify({ + schemaVersion: input.schemaVersion, + attestationId: input.attestationId, + tenantScope: input.tenantScope, + firstSequence: input.firstSequence, + lastSequence: input.lastSequence, + eventCount: input.eventCount, + rootDigest: input.rootDigest, + sealedAt: input.sealedAt, + signerKeyId: input.signerKeyId, + }); +} + export function sanitizeAuditSummaryV1(input: unknown): AuditResultV1 { if (input === undefined) return Object.freeze({ accepted: true, value: Object.freeze({}) }); if (typeof input !== 'object' || input === null || Array.isArray(input)) @@ -356,3 +394,52 @@ export function createAuditSealV1( }), }); } + +export function createAuditSealAttestationV1( + seal: AuditSealV1, + input: { readonly attestationId: unknown; readonly signerKeyId: unknown }, + signer: AuditSealAttestationSignerV1, +): AuditResultV1 { + const attestationId = stableId(input.attestationId); + const signerKeyId = text(input.signerKeyId, 200); + if (!attestationId || !signerKeyId) return rejected('INVALID_IDENTIFIER'); + const unsigned: Omit = { + schemaVersion: AUDIT_ATTESTATION_SCHEMA_VERSION_V1, + attestationId, + tenantScope: seal.tenantScope, + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + eventCount: seal.eventCount, + rootDigest: seal.rootDigest, + sealedAt: seal.sealedAt, + signerKeyId, + }; + const payload = canonicalAttestation(unsigned); + const signature = text(signer.sign(payload), 2048); + if (!signature) return rejected('INVALID_TEXT'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ ...unsigned, payload, signature }), + }); +} + +export function verifyAuditSealAttestationV1( + attestation: AuditSealAttestationV1, + seal: AuditSealV1, + signer: AuditSealAttestationSignerV1, +): AuditResultV1 { + if ( + attestation.schemaVersion !== AUDIT_ATTESTATION_SCHEMA_VERSION_V1 || + tenantScopeKeyV1(attestation.tenantScope) !== tenantScopeKeyV1(seal.tenantScope) || + attestation.firstSequence !== seal.firstSequence || + attestation.lastSequence !== seal.lastSequence || + attestation.eventCount !== seal.eventCount || + attestation.rootDigest !== seal.rootDigest || + attestation.sealedAt !== seal.sealedAt + ) + return rejected('CHAIN_INVALID'); + const { payload, signature, ...unsigned } = attestation; + if (canonicalAttestation(unsigned) !== payload || !signer.verify(payload, signature)) + return rejected('CHAIN_INVALID'); + return Object.freeze({ accepted: true, value: true }); +} diff --git a/packages/domain/test/audit-seal-attestation-v1.test.mjs b/packages/domain/test/audit-seal-attestation-v1.test.mjs new file mode 100644 index 00000000..b2fe1ed2 --- /dev/null +++ b/packages/domain/test/audit-seal-attestation-v1.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; + +import { + appendAuditEventV1, + createAuditSealAttestationV1, + createAuditSealV1, + verifyAuditSealAttestationV1, +} from '../dist/audit/v1.js'; + +const digest = { digest: (value) => createHash('sha256').update(value, 'utf8').digest('hex') }; +const scope = { scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000741' }; + +function event() { + const result = appendAuditEventV1( + { events: [] }, + { + eventId: '00000000-0000-4000-8000-000000000742', + action: 'service_account.created', + tenantScope: scope, + actor: { actorType: 'USER', actorId: '00000000-0000-4000-8000-000000000743' }, + entityType: 'service-account', + entityId: '00000000-0000-4000-8000-000000000744', + entityRevision: 1, + occurredAt: '2026-01-01T00:00:00.000Z', + correlationId: '00000000-0000-4000-8000-000000000745', + idempotencyKey: 'attestation', + }, + digest, + ); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid event'); + return result.value.event; +} + +void test('[AUD-015, AUD-016] attestations bind an immutable seal range and signer key', () => { + const sealResult = createAuditSealV1([event()], scope, '2026-01-01T00:01:00.000Z', digest); + assert.equal(sealResult.accepted, true); + if (!sealResult.accepted) return; + const signer = { sign: (payload) => `sig:${payload}`, verify: (payload, signature) => signature === `sig:${payload}` }; + const attestation = createAuditSealAttestationV1( + sealResult.value, + { attestationId: '00000000-0000-4000-8000-000000000746', signerKeyId: 'audit-key-1' }, + signer, + ); + assert.equal(attestation.accepted, true); + if (!attestation.accepted) return; + assert.deepEqual(verifyAuditSealAttestationV1(attestation.value, sealResult.value, signer), { + accepted: true, + value: true, + }); + assert.deepEqual( + verifyAuditSealAttestationV1( + { ...attestation.value, tenantScope: { ...scope, organizationId: '00000000-0000-4000-8000-000000000747' } }, + sealResult.value, + signer, + ), + { accepted: false, code: 'CHAIN_INVALID' }, + ); +}); From 34b79bc26b10ec6d55502a7bb1f8f384b5f38a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:09:04 +0700 Subject: [PATCH 45/99] feat(bua): issue bounded entitlement leases --- packages/domain/src/entitlements/v1.ts | 115 ++++++++++++++++++ .../entitlement-lease-issuance-v1.test.mjs | 93 ++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/domain/test/entitlement-lease-issuance-v1.test.mjs diff --git a/packages/domain/src/entitlements/v1.ts b/packages/domain/src/entitlements/v1.ts index 91d7fc8c..0dbac13b 100644 --- a/packages/domain/src/entitlements/v1.ts +++ b/packages/domain/src/entitlements/v1.ts @@ -95,6 +95,10 @@ export interface LeaseSignatureVerifierV1 { verify(payload: string, signature: string): boolean; } +export interface LeaseSignatureIssuerV1 { + sign(payload: string): string; +} + export type EntitlementErrorCodeV1 = | 'INVALID_IDENTIFIER' | 'INVALID_SCOPE' @@ -198,6 +202,117 @@ function snapshotAllows( return undefined; } +function validSnapshotStatus(input: unknown): input is EntitlementStatusV1 { + return input === 'ACTIVE' || input === 'SUSPENDED' || input === 'EXPIRED'; +} + +/** Create an immutable organization/workspace entitlement snapshot from a governed plan. */ +export function createEntitlementSnapshotV1(input: { + readonly snapshotId: unknown; + readonly tenantScope: unknown; + readonly plan: unknown; + readonly status: unknown; + readonly revision: unknown; + readonly securityEpoch: unknown; + readonly effectiveAt: unknown; + readonly expiresAt?: unknown; +}): EntitlementResultV1 { + const snapshotId = stableId(input.snapshotId); + const tenantScope = scope(input.tenantScope); + const plan = input.plan; + const revision = positiveInteger(input.revision); + const securityEpoch = positiveInteger(input.securityEpoch); + const effectiveAt = timestamp(input.effectiveAt); + const expiresAt = input.expiresAt === undefined ? undefined : timestamp(input.expiresAt); + if (!snapshotId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope || tenantScope.scopeType === 'project') return rejected('INVALID_SCOPE'); + if ( + typeof plan !== 'object' || + plan === null || + (plan as Partial).schemaVersion !== ENTITLEMENT_SCHEMA_VERSION_V1 || + (plan as Partial).providerIndependent !== true + ) + return rejected('INVALID_PLAN'); + if (!validSnapshotStatus(input.status)) return rejected('INVALID_STATE'); + if (!revision || !securityEpoch) return rejected('INVALID_STATE'); + if (!effectiveAt || (input.expiresAt !== undefined && !expiresAt)) + return rejected('INVALID_TIMESTAMP'); + if (expiresAt && Date.parse(expiresAt) <= Date.parse(effectiveAt)) + return rejected('INVALID_TIMESTAMP'); + const typedPlan = plan as EntitlementPlanV1; + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + snapshotId, + organizationId: tenantScope.organizationId, + ...(tenantScope.scopeType === 'workspace' ? { workspaceId: tenantScope.workspaceId } : {}), + planCode: typedPlan.planCode, + status: input.status, + revision, + securityEpoch, + effectiveAt, + ...(expiresAt ? { expiresAt } : {}), + features: Object.freeze([...typedPlan.features]), + quotas: Object.freeze(typedPlan.quotas.map((quota) => Object.freeze({ ...quota }))), + }), + }); +} + +function canonicalLease(input: Omit): string { + return JSON.stringify({ + schemaVersion: input.schemaVersion, + leaseId: input.leaseId, + tenantScope: input.tenantScope, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + issuedAt: input.issuedAt, + expiresAt: input.expiresAt, + }); +} + +/** Issue a bounded, signed offline entitlement lease tied to a snapshot revision and epoch. */ +export function createEntitlementLeaseV1( + snapshot: EntitlementSnapshotV1, + input: { readonly leaseId: unknown; readonly issuedAt: unknown; readonly expiresAt: unknown }, + signer: LeaseSignatureIssuerV1, +): EntitlementResultV1 { + const leaseId = stableId(input.leaseId); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const snapshotScope: TenantScopeV1 = snapshot.workspaceId + ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + : { scopeType: 'organization', organizationId: snapshot.organizationId }; + if (!leaseId) return rejected('INVALID_IDENTIFIER'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + const blocked = snapshotAllows(snapshot, issuedAt); + if (blocked) return rejected(blocked); + if ( + !Number.isFinite(Date.parse(issuedAt)) || + !Number.isFinite(Date.parse(expiresAt)) || + Date.parse(expiresAt) <= Date.parse(issuedAt) || + Date.parse(expiresAt) - Date.parse(issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 || + (snapshot.expiresAt !== undefined && Date.parse(expiresAt) > Date.parse(snapshot.expiresAt)) + ) + return rejected('LEASE_INVALID'); + const unsigned: Omit = { + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + leaseId, + tenantScope: snapshotScope, + snapshotRevision: snapshot.revision, + securityEpoch: snapshot.securityEpoch, + issuedAt, + expiresAt, + }; + const payload = canonicalLease(unsigned); + const signature = text(signer.sign(payload), 2048); + if (!signature) return rejected('LEASE_INVALID'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ ...unsigned, payload, signature }), + }); +} + export function createPlanV1(input: { readonly planCode: unknown; readonly displayNameKey: unknown; diff --git a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs new file mode 100644 index 00000000..ec91a4a0 --- /dev/null +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + acceptEntitlementLeaseV1, + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, +} from '../dist/entitlements/v1.js'; + +const scope = { scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000751' }; +const signer = { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, +}; + +function snapshot(status = 'ACTIVE') { + const plan = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['spreadsheet.audit'], + quotas: [{ metric: 'job_count', limit: 20 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const created = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000752', + tenantScope: scope, + plan: plan.value, + status, + revision: 3, + securityEpoch: 2, + effectiveAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid snapshot'); + return created.value; +} + +void test('[BUA-001, BUA-017, BUA-018] snapshots are immutable plan projections and leases are signed and bounded', () => { + const lease = createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000753', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T12:00:00.000Z', + }, + signer, + ); + assert.equal(lease.accepted, true); + if (!lease.accepted) return; + assert.deepEqual( + acceptEntitlementLeaseV1( + lease.value, + { + now: '2026-01-01T01:00:00.000Z', + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 2, + }, + signer, + ), + { accepted: true, value: true }, + ); +}); + +void test('[BUA-017, BUA-018] suspended snapshots and overlong leases fail closed', () => { + assert.deepEqual( + createEntitlementLeaseV1( + snapshot('SUSPENDED'), + { + leaseId: '00000000-0000-4000-8000-000000000754', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'ENTITLEMENT_SUSPENDED' }, + ); + assert.deepEqual( + createEntitlementLeaseV1( + { ...snapshot(), expiresAt: '2026-01-10T00:00:00.000Z' }, + { + leaseId: '00000000-0000-4000-8000-000000000755', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-03T00:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); +}); From 796a681795f558bef9313edf65ad01edee4f9d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:10:22 +0700 Subject: [PATCH 46/99] feat(bua): persist offline entitlement leases --- ...ry-entitlement-lease-repository.adapter.ts | 66 +++++++++++++++++++ .../entitlement-lease-repository.port.ts | 21 ++++++ .../bua/entitlement-lease-repository.test.ts | 44 +++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts create mode 100644 services/api/src/features/bua/application/entitlement-lease-repository.port.ts create mode 100644 services/api/test/features/bua/entitlement-lease-repository.test.ts 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 new file mode 100644 index 00000000..5043a291 --- /dev/null +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts @@ -0,0 +1,66 @@ +import { + tenantScopeContainsV1, + type EntitlementLeaseV1, +} from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + EntitlementLeaseRepositoryPortV1, + EntitlementLeaseTransactionPortV1, +} from '../application/entitlement-lease-repository.port.js'; + +function leaseScope(lease: EntitlementLeaseV1) { + return lease.tenantScope; +} + +function clone(lease: EntitlementLeaseV1): EntitlementLeaseV1 { + return Object.freeze({ ...lease, tenantScope: Object.freeze({ ...lease.tenantScope }) }); +} + +/** BUA local adapter with immutable lease identity and tenant visibility checks. */ +export class InMemoryEntitlementLeaseRepositoryAdapter implements EntitlementLeaseRepositoryPortV1 { + private leases = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + await Promise.resolve(); + 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)) + throw new Error('BUA_IMMUTABLE_LEASE'); + this.leases.set(lease.leaseId, clone(lease)); + } + + public async findLease( + context: IamTenantContextV1, + leaseId: EntitlementLeaseV1['leaseId'], + ): Promise { + await Promise.resolve(); + const lease = this.leases.get(leaseId); + return lease && (tenantScopeContainsV1(context.tenantScope, leaseScope(lease)) || tenantScopeContainsV1(leaseScope(lease), context.tenantScope)) + ? clone(lease) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.leases); + try { + return await work({ saveLease: this.saveLease.bind(this), findLease: this.findLease.bind(this) }); + } catch (error) { + this.leases = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/bua/application/entitlement-lease-repository.port.ts b/services/api/src/features/bua/application/entitlement-lease-repository.port.ts new file mode 100644 index 00000000..2c55043d --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-lease-repository.port.ts @@ -0,0 +1,21 @@ +import type { EntitlementLeaseV1 } from '@databreeze/domain/entitlements/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ENTITLEMENT_LEASE_REPOSITORY_PORT = Symbol('ENTITLEMENT_LEASE_REPOSITORY_PORT'); + +export interface EntitlementLeaseTransactionPortV1 { + saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise; + findLease( + context: IamTenantContextV1, + leaseId: StableIdentifierV1, + ): Promise; +} + +export interface EntitlementLeaseRepositoryPortV1 extends EntitlementLeaseTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/test/features/bua/entitlement-lease-repository.test.ts b/services/api/test/features/bua/entitlement-lease-repository.test.ts new file mode 100644 index 00000000..de132989 --- /dev/null +++ b/services/api/test/features/bua/entitlement-lease-repository.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createEntitlementLeaseV1, createEntitlementSnapshotV1, createPlanV1 } from '@databreeze/domain/entitlements/v1'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000761'; +const leaseId = '00000000-0000-4000-8000-000000000762'; +const actorId = '00000000-0000-4000-8000-000000000763'; + +function context(scope = { scopeType: 'organization', organizationId }) { + const result = createIamTenantContextV1({ + actorId, + correlationId: '00000000-0000-4000-8000-000000000764', + tenantScope: scope, + idempotencyKey: 'lease-repository', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function lease() { + const plan = createPlanV1({ planCode: 'free', displayNameKey: 'plan.free', features: [], quotas: [{ metric: 'job_count', limit: 1 }] }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const snapshot = createEntitlementSnapshotV1({ snapshotId: '00000000-0000-4000-8000-000000000765', tenantScope: { scopeType: 'organization', organizationId }, plan: plan.value, status: 'ACTIVE', revision: 1, securityEpoch: 1, effectiveAt: '2026-01-01T00:00:00.000Z' }); + assert.equal(snapshot.accepted, true); + if (!snapshot.accepted) throw new Error('invalid snapshot'); + const issued = createEntitlementLeaseV1(snapshot.value, { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, { sign: (payload) => payload }); + assert.equal(issued.accepted, true); + if (!issued.accepted) throw new Error('invalid lease'); + return issued.value; +} + +void test('[BUA-017, BUA-018] in-memory lease persistence is immutable and scoped', async () => { + const repository = new InMemoryEntitlementLeaseRepositoryAdapter(); + await repository.saveLease(context(), lease()); + assert.equal((await repository.findLease(context(), lease().leaseId))?.leaseId, leaseId); + assert.equal(await repository.findLease(context({ scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000799' }), lease().leaseId), undefined); + await assert.rejects(repository.saveLease(context(), { ...lease(), signature: 'changed' }), /BUA_IMMUTABLE_LEASE/u); +}); From 52d160753068077bb8bdf2ceb44a7db91f4cfa7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:13:59 +0700 Subject: [PATCH 47/99] feat(bua): persist entitlement lease schema --- .../migration.sql | 21 +++++++++++++++++++ services/api/prisma/schema/bua.prisma | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql diff --git a/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql b/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql new file mode 100644 index 00000000..0a37e0bc --- /dev/null +++ b/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql @@ -0,0 +1,21 @@ +-- BUA-017/018: persist signed offline leases without provider-specific billing state. +CREATE TABLE "bua"."entitlement_leases" ( + "id" UUID NOT NULL, + "schema_version" INTEGER NOT NULL, + "scope_key" VARCHAR(200) NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "snapshot_revision" INTEGER NOT NULL, + "security_epoch" INTEGER NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "payload" TEXT NOT NULL, + "signature" VARCHAR(2048) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "entitlement_leases_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "entitlement_leases_scope_expiry_idx" +ON "bua"."entitlement_leases"("organization_id", "workspace_id", "expires_at"); diff --git a/services/api/prisma/schema/bua.prisma b/services/api/prisma/schema/bua.prisma index 75d97cdc..ec13e331 100644 --- a/services/api/prisma/schema/bua.prisma +++ b/services/api/prisma/schema/bua.prisma @@ -38,6 +38,27 @@ model EntitlementSnapshotRecord { @@schema("bua") } +/// BUA-017/018: signed offline leases are immutable and bound to a snapshot revision/epoch. +model EntitlementLeaseRecord { + id String @id @db.Uuid + schemaVersion Int @map("schema_version") + scopeKey String @map("scope_key") @db.VarChar(200) + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + snapshotRevision Int @map("snapshot_revision") + securityEpoch Int @map("security_epoch") + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + payload String @db.Text + signature String @db.VarChar(2048) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([organizationId, workspaceId, expiresAt], map: "entitlement_leases_scope_expiry_idx") + @@map("entitlement_leases") + @@schema("bua") +} + model UsageLedgerEntryRecord { id String @id @db.Uuid schemaVersion Int @map("schema_version") From 4d057e4d979dd916aa9712409f25e93c9a56ff98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:14:02 +0700 Subject: [PATCH 48/99] feat(bua): add entitlement lease repository --- ...ma-entitlement-lease-repository.adapter.ts | 191 ++++++++++++++++++ ...risma-entitlement-lease-repository.test.ts | 116 +++++++++++ 2 files changed, 307 insertions(+) create mode 100644 services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts create mode 100644 services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts 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 new file mode 100644 index 00000000..21825d74 --- /dev/null +++ b/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts @@ -0,0 +1,191 @@ +import type { EntitlementLeaseV1 } from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EntitlementDatabaseClientV1 } from './prisma-entitlement-repository.adapter.js'; +import type { + EntitlementLeaseRepositoryPortV1, + EntitlementLeaseTransactionPortV1, +} from '../application/entitlement-lease-repository.port.js'; + +export interface EntitlementLeaseDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly snapshotRevision: number; + readonly securityEpoch: number; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly payload: string; + readonly signature: string; + readonly createdAt: Date; +} + +export interface EntitlementLeaseDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +interface EntitlementLeaseDelegateV1 { + create(input: { + readonly data: EntitlementLeaseDatabaseCreateDataV1; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface EntitlementLeaseDatabaseClientV1 extends EntitlementDatabaseClientV1 { + readonly entitlementLeaseRecord: EntitlementLeaseDelegateV1; +} + +function leaseScope(lease: EntitlementLeaseV1): TenantScopeV1 { + return lease.tenantScope; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + const organizationId = context.tenantScope.organizationId; + if (context.tenantScope.scopeType === 'organization') return { organizationId }; + return { + organizationId, + OR: [{ workspaceId: null }, { workspaceId: context.tenantScope.workspaceId }], + }; +} + +function persistedScope(row: EntitlementLeaseDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + }); + if (!parsed.accepted || parsed.value.scopeType === 'project') + throw new Error('BUA_PERSISTED_LEASE_INVALID'); + return parsed.value; +} + +function persistedLease(row: EntitlementLeaseDatabaseRowV1): EntitlementLeaseV1 { + const id = parseStableIdentifierV1(row.id); + const scope = persistedScope(row); + const issuedAt = parseStrictUtcTimestampV1(row.issuedAt.toISOString()); + const expiresAt = parseStrictUtcTimestampV1(row.expiresAt.toISOString()); + if ( + !id.accepted || + !issuedAt.accepted || + !expiresAt.accepted || + row.schemaVersion !== 1 || + !Number.isSafeInteger(row.snapshotRevision) || + row.snapshotRevision < 1 || + !Number.isSafeInteger(row.securityEpoch) || + row.securityEpoch < 1 || + typeof row.payload !== 'string' || + row.payload.length === 0 || + row.payload.length > 10000 || + typeof row.signature !== 'string' || + row.signature.length === 0 || + row.signature.length > 2048 + ) + throw new Error('BUA_PERSISTED_LEASE_INVALID'); + return Object.freeze({ + schemaVersion: 1, + leaseId: id.value, + tenantScope: scope, + snapshotRevision: row.snapshotRevision, + securityEpoch: row.securityEpoch, + issuedAt: issuedAt.value, + expiresAt: expiresAt.value, + payload: row.payload, + signature: row.signature, + }); +} + +function leaseData(lease: EntitlementLeaseV1): EntitlementLeaseDatabaseCreateDataV1 { + return { + id: lease.leaseId, + schemaVersion: lease.schemaVersion, + scopeKey: tenantScopeKeyV1(lease.tenantScope), + scopeType: lease.tenantScope.scopeType, + organizationId: lease.tenantScope.organizationId, + workspaceId: + lease.tenantScope.scopeType === 'organization' ? null : lease.tenantScope.workspaceId, + snapshotRevision: lease.snapshotRevision, + securityEpoch: lease.securityEpoch, + issuedAt: new Date(lease.issuedAt), + expiresAt: new Date(lease.expiresAt), + payload: lease.payload, + signature: lease.signature, + createdAt: new Date(lease.issuedAt), + }; +} + +function isUniqueConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaEntitlementLeaseTransactionAdapter implements EntitlementLeaseTransactionPortV1 { + public constructor(private readonly client: EntitlementLeaseDatabaseClientV1) {} + + public async saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, leaseScope(lease))) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.entitlementLeaseRecord.findFirst({ + where: { id: lease.leaseId }, + }); + if (existing) { + if (JSON.stringify(persistedLease(existing)) !== JSON.stringify(lease)) + throw new Error('BUA_IMMUTABLE_LEASE'); + return; + } + try { + await this.client.entitlementLeaseRecord.create({ data: leaseData(lease) }); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('BUA_LEASE_CONFLICT'); + throw error; + } + } + + public async findLease( + context: IamTenantContextV1, + leaseId: StableIdentifierV1, + ): Promise { + const row = await this.client.entitlementLeaseRecord.findFirst({ + where: { id: leaseId, ...scopeWhere(context) }, + }); + return row ? persistedLease(row) : undefined; + } +} + +export class PrismaEntitlementLeaseRepositoryAdapter implements EntitlementLeaseRepositoryPortV1 { + public constructor(private readonly client: EntitlementLeaseDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work( + new PrismaEntitlementLeaseTransactionAdapter( + transaction as EntitlementLeaseDatabaseClientV1, + ), + ), + ); + } + + public saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + return new PrismaEntitlementLeaseTransactionAdapter(this.client).saveLease(context, lease); + } + + public findLease(context: IamTenantContextV1, leaseId: StableIdentifierV1) { + return new PrismaEntitlementLeaseTransactionAdapter(this.client).findLease(context, leaseId); + } +} diff --git a/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts new file mode 100644 index 00000000..40c92ecc --- /dev/null +++ b/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, + type EntitlementLeaseV1, +} from '@databreeze/domain/entitlements/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaEntitlementLeaseRepositoryAdapter, + type EntitlementLeaseDatabaseClientV1, +} from '../../../src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000771'; +const leaseId = '00000000-0000-4000-8000-000000000772'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000773', + correlationId: '00000000-0000-4000-8000-000000000774', + tenantScope: { scopeType: 'organization', organizationId }, + idempotencyKey: 'prisma-lease', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function lease(): EntitlementLeaseV1 { + const plan = createPlanV1({ + planCode: 'free', + displayNameKey: 'plan.free', + features: [], + quotas: [{ metric: 'job_count', limit: 1 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const snapshot = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000775', + tenantScope: { scopeType: 'organization', organizationId }, + plan: plan.value, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(snapshot.accepted, true); + if (!snapshot.accepted) throw new Error('invalid snapshot'); + const issued = createEntitlementLeaseV1( + snapshot.value, + { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, + { sign: (payload) => payload }, + ); + assert.equal(issued.accepted, true); + if (!issued.accepted) throw new Error('invalid lease'); + return issued.value; +} + +function delegate(rows: Record[]) { + return { + create({ data }: { readonly data: Record }) { + const row = { ...data }; + rows.push(row); + return Promise.resolve(row); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => + Object.entries(where).every(([key, value]) => + key !== 'OR' + ? row[key] === value + : (where['OR'] as readonly Record[]).some((candidate) => + Object.entries(candidate).every( + ([candidateKey, candidateValue]) => row[candidateKey] === candidateValue, + ), + ), + ), + ) ?? null, + ); + }, + }; +} + +function client(rows: Record[] = []): EntitlementLeaseDatabaseClientV1 { + const database = { + entitlementLeaseRecord: delegate(rows), + } as unknown as EntitlementLeaseDatabaseClientV1; + return { + ...database, + async $transaction( + work: (transaction: EntitlementLeaseDatabaseClientV1) => Promise, + ) { + return work(database); + }, + }; +} + +void test('[BUA-017, BUA-018] Prisma lease adapter stores signed rows and enforces scope', async () => { + const repository = new PrismaEntitlementLeaseRepositoryAdapter(client()); + await repository.saveLease(context(), lease()); + assert.equal( + (await repository.findLease(context(), stable(leaseId)))?.signature, + lease().signature, + ); +}); From 5c25a8193a55f3f195d3d900ad2daf9a45b28b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:17:04 +0700 Subject: [PATCH 49/99] feat(bua): issue and verify entitlement leases --- .../application/entitlement-lease.service.ts | 158 ++++++++++++++++ .../bua/entitlement-lease.service.test.ts | 171 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 services/api/src/features/bua/application/entitlement-lease.service.ts create mode 100644 services/api/test/features/bua/entitlement-lease.service.test.ts diff --git a/services/api/src/features/bua/application/entitlement-lease.service.ts b/services/api/src/features/bua/application/entitlement-lease.service.ts new file mode 100644 index 00000000..a0691137 --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-lease.service.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto'; + +import { + acceptEntitlementLeaseV1, + createEntitlementLeaseV1, + type EntitlementErrorCodeV1, + type EntitlementLeaseV1, + type EntitlementResultV1, + type LeaseSignatureIssuerV1, + type LeaseSignatureVerifierV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EntitlementLeaseRepositoryPortV1 } from './entitlement-lease-repository.port.js'; +import type { EntitlementRepositoryPortV1 } from './entitlement-repository.port.js'; + +export const ENTITLEMENT_LEASE_SERVICE = Symbol('ENTITLEMENT_LEASE_SERVICE'); + +export type EntitlementLeaseClockV1 = () => Date; +export type EntitlementLeaseIdGeneratorV1 = () => string; + +export interface EntitlementLeaseSignerV1 + extends LeaseSignatureIssuerV1, + LeaseSignatureVerifierV1 {} + +export type EntitlementLeaseApplicationCodeV1 = EntitlementErrorCodeV1 | 'UNAVAILABLE'; + +export type EntitlementLeaseApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: EntitlementLeaseApplicationCodeV1 }; + +export interface IssueEntitlementLeaseInputV1 { + readonly snapshotId: unknown; + readonly expiresAt: unknown; +} + +export interface VerifyEntitlementLeaseInputV1 { + readonly leaseId: unknown; + readonly now?: unknown; + readonly snapshotRevision: unknown; + readonly securityEpoch: unknown; +} + +function rejected( + code: EntitlementLeaseApplicationCodeV1, +): EntitlementLeaseApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function clockTimestamp(clock: EntitlementLeaseClockV1): StrictUtcTimestampV1 | undefined { + try { + return timestamp(clock().toISOString()); + } catch { + return undefined; + } +} + +function applicationResult( + result: EntitlementResultV1, +): EntitlementLeaseApplicationResultV1 { + return result.accepted ? result : rejected(result.code); +} + +/** Coordinates immutable entitlement snapshots and signed offline lease persistence. */ +export class EntitlementLeaseService { + public constructor( + private readonly leaseRepository: EntitlementLeaseRepositoryPortV1, + private readonly entitlementRepository: EntitlementRepositoryPortV1, + private readonly signer: EntitlementLeaseSignerV1, + private readonly clock: EntitlementLeaseClockV1 = () => new Date(), + private readonly idGenerator: EntitlementLeaseIdGeneratorV1 = () => randomUUID(), + ) {} + + public async issue( + context: IamTenantContextV1, + input: IssueEntitlementLeaseInputV1, + ): Promise> { + const snapshotId = stableId(input.snapshotId); + const leaseId = stableId(this.idGenerator()); + const issuedAt = clockTimestamp(this.clock); + if (!snapshotId || !leaseId) return rejected('INVALID_IDENTIFIER'); + if (!issuedAt) return rejected('INVALID_TIMESTAMP'); + + const snapshot = await this.entitlementRepository.findSnapshot(context, snapshotId); + if (!snapshot) return rejected('ENTITLEMENT_NOT_FOUND'); + const issued = createEntitlementLeaseV1( + snapshot, + { leaseId, issuedAt, expiresAt: input.expiresAt }, + this.signer, + ); + if (!issued.accepted) return applicationResult(issued); + await this.leaseRepository.withTransaction(context, async (transaction) => { + await transaction.saveLease(context, issued.value); + }); + return issued; + } + + public async verify( + context: IamTenantContextV1, + input: VerifyEntitlementLeaseInputV1, + ): Promise> { + const leaseId = stableId(input.leaseId); + if (!leaseId) return rejected('INVALID_IDENTIFIER'); + const now = input.now === undefined ? clockTimestamp(this.clock) : timestamp(input.now); + if (!now) return rejected('INVALID_TIMESTAMP'); + const lease = await this.leaseRepository.findLease(context, leaseId); + if (!lease) return rejected('ENTITLEMENT_NOT_FOUND'); + return applicationResult( + acceptEntitlementLeaseV1( + lease, + { + now, + tenantScope: context.tenantScope, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + }, + this.signer, + ), + ); + } +} + +/** Safe composition default when key material or persistence is not configured. */ +export class UnavailableEntitlementLeaseService { + public issue( + context: IamTenantContextV1, + input: IssueEntitlementLeaseInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } + + public verify( + context: IamTenantContextV1, + input: VerifyEntitlementLeaseInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } +} diff --git a/services/api/test/features/bua/entitlement-lease.service.test.ts b/services/api/test/features/bua/entitlement-lease.service.test.ts new file mode 100644 index 00000000..ae057ac4 --- /dev/null +++ b/services/api/test/features/bua/entitlement-lease.service.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createPlanV1, + createEntitlementSnapshotV1, + type EntitlementPlanV1, + type EntitlementSnapshotV1, +} from '@databreeze/domain/entitlements/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { EntitlementLeaseService } from '../../../src/features/bua/application/entitlement-lease.service.js'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { InMemoryEntitlementRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000301'; +const workspaceId = '00000000-0000-4000-8000-000000000302'; +const snapshotId = '00000000-0000-4000-8000-000000000303'; +const leaseId = '00000000-0000-4000-8000-000000000304'; +const actorId = '00000000-0000-4000-8000-000000000305'; +const correlationId = '00000000-0000-4000-8000-000000000306'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(workspace = workspaceId) { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + idempotencyKey: 'lease-service', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function plan(): EntitlementPlanV1 { + const result = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 2 }], + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid plan'); + return result.value; +} + +function snapshot(): EntitlementSnapshotV1 { + const result = createEntitlementSnapshotV1({ + snapshotId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + plan: plan(), + status: 'ACTIVE', + revision: 4, + securityEpoch: 2, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid snapshot'); + return result.value; +} + +function signer() { + return { + sign(payload: string) { + return `sig:${payload}`; + }, + verify(payload: string, signature: string) { + return signature === `sig:${payload}`; + }, + }; +} + +async function setup() { + const entitlementRepository = new InMemoryEntitlementRepositoryAdapter(); + await entitlementRepository.saveSnapshot(context(), snapshot()); + const leaseRepository = new InMemoryEntitlementLeaseRepositoryAdapter(); + const service = new EntitlementLeaseService( + leaseRepository, + entitlementRepository, + signer(), + () => new Date('2026-01-01T00:05:00.000Z'), + () => leaseId, + ); + return { service, leaseRepository }; +} + +void test('[BUA-017] issues one bounded lease from a visible immutable snapshot', async () => { + const { service, leaseRepository } = await setup(); + const result = await service.issue(context(), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.snapshotRevision, 4); + assert.equal( + (await leaseRepository.findLease(context(), stable(leaseId)))?.signature, + result.value.signature, + ); +}); + +void test('[BUA-017] rejects a lease for a hidden snapshot without writing it', async () => { + const { service } = await setup(); + const result = await service.issue(context('00000000-0000-4000-8000-000000000399'), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.deepEqual(result, { accepted: false, code: 'ENTITLEMENT_NOT_FOUND' }); +}); + +void test('[BUA-018] verifies signature, scope, revision, epoch, and time through the repository', async () => { + const { service } = await setup(); + const issued = await service.issue(context(), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.equal(issued.accepted, true); + if (!issued.accepted) return; + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: '2026-01-01T00:10:00.000Z', + snapshotRevision: 4, + securityEpoch: 2, + }), + { accepted: true, value: true }, + ); + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: '2026-01-01T00:10:00.000Z', + snapshotRevision: 3, + securityEpoch: 2, + }), + { accepted: false, code: 'LEASE_STALE' }, + ); +}); + +void test('[BUA-018] rejects invalid generated IDs and malformed verification timestamps', async () => { + const entitlementRepository = new InMemoryEntitlementRepositoryAdapter(); + await entitlementRepository.saveSnapshot(context(), snapshot()); + const service = new EntitlementLeaseService( + new InMemoryEntitlementLeaseRepositoryAdapter(), + entitlementRepository, + signer(), + () => new Date('2026-01-01T00:05:00.000Z'), + () => 'not-an-id', + ); + assert.deepEqual( + await service.issue(context(), { snapshotId, expiresAt: '2026-01-01T01:05:00.000Z' }), + { accepted: false, code: 'INVALID_IDENTIFIER' }, + ); + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: 'invalid', + snapshotRevision: 4, + securityEpoch: 2, + }), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); From ecfbb25d44a8a034f8ac9670508d2b605b638692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:20:42 +0700 Subject: [PATCH 50/99] feat(bua): expose entitlement lease api --- services/api/openapi/v1.json | 230 ++++++++++++++++++ .../features/bua/api/entitlement-lease.dto.ts | 30 +++ .../bua/api/entitlement.controller.ts | 74 +++++- .../application/entitlement-problem.error.ts | 2 + services/api/src/features/bua/bua.module.ts | 51 +++- .../bua/entitlement.controller.test.ts | 73 ++++++ 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/bua/api/entitlement-lease.dto.ts create mode 100644 services/api/test/features/bua/entitlement.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 16880f8e..dc0b2ee3 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -9475,6 +9475,225 @@ "tags": ["entitlements"] } }, + "/v1/entitlements/snapshots/{snapshotId}/leases": { + "post": { + "operationId": "EntitlementController.issueLease", + "parameters": [ + { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IssueEntitlementLeaseDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The snapshot or expiry is invalid.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The entitlement snapshot is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Lease signing or persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Issue a signed, bounded offline entitlement lease", + "tags": ["entitlements"] + } + }, + "/v1/entitlements/leases/{leaseId}/verify": { + "get": { + "operationId": "EntitlementController.verifyLease", + "parameters": [ + { "name": "leaseId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "snapshotRevision", + "required": true, + "in": "query", + "schema": { "minimum": 1, "type": "number" } + }, + { + "name": "securityEpoch", + "required": true, + "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", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid"], + "properties": { "valid": { "type": "boolean" } } + } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The lease verification input is invalid or stale.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The lease is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Lease verification is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Verify an offline entitlement lease against the current revision and epoch", + "tags": ["entitlements"] + } + }, "/v1/spreadsheet-audits": { "post": { "operationId": "SpreadsheetAuditController.register", @@ -11064,6 +11283,17 @@ "publishedAt" ] }, + "IssueEntitlementLeaseDto": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "UTC expiry no more than 24 hours after issue" + } + }, + "required": ["expiresAt"] + }, "SpreadsheetAuditSheetDto": { "type": "object", "properties": { diff --git a/services/api/src/features/bua/api/entitlement-lease.dto.ts b/services/api/src/features/bua/api/entitlement-lease.dto.ts new file mode 100644 index 00000000..917036c8 --- /dev/null +++ b/services/api/src/features/bua/api/entitlement-lease.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class IssueEntitlementLeaseDto { + @ApiProperty({ format: 'date-time', description: 'UTC expiry no more than 24 hours after issue' }) + @IsISO8601() + expiresAt!: string; +} + +export class VerifyEntitlementLeaseDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + snapshotRevision!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @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 82ee4876..217f69fe 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, + ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -20,6 +21,12 @@ import { type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; +import { + ENTITLEMENT_LEASE_SERVICE, + type EntitlementLeaseApplicationResultV1, + type EntitlementLeaseService, +} from '../application/entitlement-lease.service.js'; +import { IssueEntitlementLeaseDto, VerifyEntitlementLeaseDto } from './entitlement-lease.dto.js'; const ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA = { type: 'object', @@ -45,8 +52,29 @@ export class EntitlementController { private readonly repository: EntitlementRepositoryPortV1, @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + @Inject(ENTITLEMENT_LEASE_SERVICE) + private readonly leases: EntitlementLeaseService, ) {} + private async executeLease( + work: () => Promise>, + ): Promise { + let result: EntitlementLeaseApplicationResultV1; + try { + result = await work(); + } catch { + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'ENTITLEMENT_NOT_FOUND') + throw new EntitlementProblemError('ENTITLEMENT_NOT_FOUND'); + if (result.code === 'LEASE_INVALID') + throw new EntitlementProblemError('ENTITLEMENT_LEASE_INVALID'); + if (result.code === 'LEASE_STALE') throw new EntitlementProblemError('ENTITLEMENT_LEASE_STALE'); + if (result.code === 'UNAVAILABLE') throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + throw new EntitlementProblemError('ENTITLEMENT_REQUEST_INVALID'); + } + @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) @ApiOkResponse({ schema: ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA }) @@ -82,4 +110,48 @@ export class EntitlementController { throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); } } + + @Post('snapshots/:snapshotId/leases') + @ApiOperation({ summary: 'Issue a signed, bounded offline entitlement lease' }) + @ApiCreatedResponse({ schema: { type: 'object', additionalProperties: true } }) + @ApiBadRequestResponse({ description: 'The snapshot or expiry is invalid.' }) + @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Lease signing or persistence is unavailable.' }) + async issueLease( + @Req() request: unknown, + @Param('snapshotId') snapshotId: string, + @Body() input: IssueEntitlementLeaseDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.executeLease(() => + this.leases.issue(context, { snapshotId, expiresAt: input.expiresAt }), + ); + } + + @Get('leases/:leaseId/verify') + @ApiOperation({ + summary: 'Verify an offline entitlement lease against the current revision and epoch', + }) + @ApiOkResponse({ + schema: { type: 'object', required: ['valid'], properties: { valid: { type: 'boolean' } } }, + }) + @ApiBadRequestResponse({ description: 'The lease verification input is invalid or stale.' }) + @ApiNotFoundResponse({ description: 'The lease is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Lease verification is unavailable.' }) + async verifyLease( + @Req() request: unknown, + @Param('leaseId') leaseId: string, + @Query() input: VerifyEntitlementLeaseDto, + ): Promise<{ readonly valid: true }> { + const context = await this.requestContext.resolve(request); + await this.executeLease(() => + this.leases.verify(context, { + leaseId, + now: input.now, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + }), + ); + return Object.freeze({ valid: true }); + } } diff --git a/services/api/src/features/bua/application/entitlement-problem.error.ts b/services/api/src/features/bua/application/entitlement-problem.error.ts index 0d58eb00..8a17d7eb 100644 --- a/services/api/src/features/bua/application/entitlement-problem.error.ts +++ b/services/api/src/features/bua/application/entitlement-problem.error.ts @@ -1,6 +1,8 @@ export type EntitlementProblemCodeV1 = | 'ENTITLEMENT_NOT_FOUND' | 'ENTITLEMENT_REQUEST_INVALID' + | 'ENTITLEMENT_LEASE_INVALID' + | 'ENTITLEMENT_LEASE_STALE' | 'ENTITLEMENT_UNAVAILABLE'; export class EntitlementProblemError extends Error { diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 5a956f6b..53188840 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -6,6 +6,24 @@ import { type EntitlementDatabaseClientV1, } from './adapter/prisma-entitlement-repository.adapter.js'; import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; +import { + ENTITLEMENT_LEASE_SERVICE, + EntitlementLeaseService, + UnavailableEntitlementLeaseService, + type EntitlementLeaseClockV1, + type EntitlementLeaseIdGeneratorV1, + type EntitlementLeaseService as EntitlementLeaseServicePortV1, + type EntitlementLeaseSignerV1, +} from './application/entitlement-lease.service.js'; +import { + ENTITLEMENT_LEASE_REPOSITORY_PORT, + type EntitlementLeaseRepositoryPortV1, +} from './application/entitlement-lease-repository.port.js'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from './adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { + PrismaEntitlementLeaseRepositoryAdapter, + type EntitlementLeaseDatabaseClientV1, +} from './adapter/prisma-entitlement-lease-repository.adapter.js'; import { ENTITLEMENT_REPOSITORY_PORT, type EntitlementRepositoryPortV1, @@ -23,6 +41,14 @@ export interface BuaModuleOptions { readonly entitlementRepository?: EntitlementRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly entitlementDatabase?: EntitlementDatabaseClientV1; + readonly entitlementLeaseRepository?: EntitlementLeaseRepositoryPortV1; + readonly entitlementLeaseDatabase?: EntitlementLeaseDatabaseClientV1; + readonly entitlementLeaseService?: + | EntitlementLeaseServicePortV1 + | UnavailableEntitlementLeaseService; + readonly entitlementLeaseSigner?: EntitlementLeaseSignerV1; + readonly entitlementLeaseClock?: EntitlementLeaseClockV1; + readonly entitlementLeaseIdGenerator?: EntitlementLeaseIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -35,18 +61,41 @@ export class BuaModule { ? new InMemoryEntitlementRepositoryAdapter() : new PrismaEntitlementRepositoryAdapter(options.entitlementDatabase)); const service = new EntitlementAdmissionService(repository); + const leaseRepository = + options.entitlementLeaseRepository ?? + (options.entitlementLeaseDatabase === undefined + ? new InMemoryEntitlementLeaseRepositoryAdapter() + : new PrismaEntitlementLeaseRepositoryAdapter(options.entitlementLeaseDatabase)); + const leaseService = + options.entitlementLeaseService ?? + (options.entitlementLeaseSigner === undefined + ? new UnavailableEntitlementLeaseService() + : new EntitlementLeaseService( + leaseRepository, + repository, + options.entitlementLeaseSigner, + options.entitlementLeaseClock, + options.entitlementLeaseIdGenerator, + )); return { module: BuaModule, controllers: [EntitlementController], providers: [ { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + { provide: ENTITLEMENT_LEASE_REPOSITORY_PORT, useValue: leaseRepository }, + { provide: ENTITLEMENT_LEASE_SERVICE, useValue: leaseService }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], + exports: [ + ENTITLEMENT_REPOSITORY_PORT, + ENTITLEMENT_ADMISSION_SERVICE, + ENTITLEMENT_LEASE_REPOSITORY_PORT, + ENTITLEMENT_LEASE_SERVICE, + ], }; } } diff --git a/services/api/test/features/bua/entitlement.controller.test.ts b/services/api/test/features/bua/entitlement.controller.test.ts new file mode 100644 index 00000000..e7893780 --- /dev/null +++ b/services/api/test/features/bua/entitlement.controller.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { EntitlementController } from '../../../src/features/bua/api/entitlement.controller.js'; +import { EntitlementProblemError } from '../../../src/features/bua/application/entitlement-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000401'; +const workspaceId = '00000000-0000-4000-8000-000000000402'; +const snapshotId = '00000000-0000-4000-8000-000000000403'; +const leaseId = '00000000-0000-4000-8000-000000000404'; +const actorId = '00000000-0000-4000-8000-000000000405'; +const correlationId = '00000000-0000-4000-8000-000000000406'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'bua-controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const leases = { + issue: () => + Promise.resolve({ + accepted: true as const, + value: { leaseId, signature: 'signed' }, + }), + verify: () => Promise.resolve({ accepted: true as const, value: true as const }), + ...overrides, + }; + const repository = { + findSnapshot: () => Promise.resolve(undefined), + listUsageState: () => Promise.resolve({ entries: [], reservations: [] }), + }; + const requestContext = { resolve: () => Promise.resolve(context()) }; + return new EntitlementController(repository as never, requestContext, leases as never); +} + +void test('[BUA-017, BUA-018] controller exposes lease issue and verification endpoints', async () => { + const instance = controller(); + assert.deepEqual( + await instance.issueLease({}, snapshotId, { expiresAt: '2026-01-01T01:00:00.000Z' }), + { leaseId, signature: 'signed' }, + ); + assert.deepEqual( + await instance.verifyLease({}, leaseId, { snapshotRevision: 4, securityEpoch: 2 }), + { valid: true }, + ); +}); + +void test('[BUA-018] controller maps stale and unavailable lease results', async () => { + await assert.rejects( + controller({ + verify: () => Promise.resolve({ accepted: false as const, code: 'LEASE_STALE' as const }), + }).verifyLease({}, leaseId, { snapshotRevision: 3, securityEpoch: 2 }), + (error: unknown) => + error instanceof EntitlementProblemError && error.code === 'ENTITLEMENT_LEASE_STALE', + ); + await assert.rejects( + controller({ + issue: () => Promise.resolve({ accepted: false as const, code: 'UNAVAILABLE' as const }), + }).issueLease({}, snapshotId, { expiresAt: '2026-01-01T01:00:00.000Z' }), + (error: unknown) => + error instanceof EntitlementProblemError && error.code === 'ENTITLEMENT_UNAVAILABLE', + ); +}); From 89f7bfbfbabe1f8e98b45b4c1144142ba9d342ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:21:30 +0700 Subject: [PATCH 51/99] feat(bua): add provider-neutral lease signing --- .../hmac-entitlement-lease-signer.adapter.ts | 34 +++++++++++++++++++ .../bua/hmac-entitlement-lease-signer.test.ts | 18 ++++++++++ 2 files changed, 52 insertions(+) create mode 100644 services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts create mode 100644 services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts diff --git a/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts b/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts new file mode 100644 index 00000000..0fbc617a --- /dev/null +++ b/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts @@ -0,0 +1,34 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { EntitlementLeaseSignerV1 } from '../application/entitlement-lease.service.js'; + +const HMAC_ALGORITHM = 'sha256'; +const MINIMUM_KEY_BYTES = 32; + +/** Provider-neutral HMAC signer for short-lived entitlement leases. */ +export class HmacEntitlementLeaseSignerAdapter implements EntitlementLeaseSignerV1 { + private readonly key: Uint8Array; + + public constructor(key: Uint8Array | string) { + const normalized = typeof key === 'string' ? Buffer.from(key, 'utf8') : Buffer.from(key); + if (normalized.length < MINIMUM_KEY_BYTES) throw new Error('BUA_LEASE_SIGNING_KEY_TOO_SHORT'); + this.key = normalized; + } + + public sign(payload: string): string { + return createHmac(HMAC_ALGORITHM, this.key).update(payload, 'utf8').digest('base64url'); + } + + public verify(payload: string, signature: string): boolean { + if (signature.length === 0 || signature.length > 2048 || !/^[A-Za-z0-9_-]+$/u.test(signature)) + return false; + let presented: Buffer; + try { + presented = Buffer.from(signature, 'base64url'); + } catch { + return false; + } + const expected = createHmac(HMAC_ALGORITHM, this.key).update(payload, 'utf8').digest(); + return presented.length === expected.length && timingSafeEqual(presented, expected); + } +} diff --git a/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts b/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts new file mode 100644 index 00000000..574bcce2 --- /dev/null +++ b/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HmacEntitlementLeaseSignerAdapter } from '../../../src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.js'; + +const key = 'a'.repeat(32); + +void test('[BUA-018] HMAC lease signatures verify exact payloads and reject tampering', () => { + const signer = new HmacEntitlementLeaseSignerAdapter(key); + const signature = signer.sign('{"lease":1}'); + assert.equal(signer.verify('{"lease":1}', signature), true); + assert.equal(signer.verify('{"lease":2}', signature), false); + assert.equal(signer.verify('{"lease":1}', `${signature}x`), false); +}); + +void test('[BUA-018] HMAC lease signing requires a non-trivial key', () => { + assert.throws(() => new HmacEntitlementLeaseSignerAdapter('short'), /KEY_TOO_SHORT/); +}); From 82b0e22708680fee389be1ef5a6b81e28837ded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:22:54 +0700 Subject: [PATCH 52/99] fix(bua): bind lease acceptance to canonical payload --- packages/domain/src/entitlements/v1.ts | 45 ++++++++++++++++--- .../entitlement-lease-issuance-v1.test.mjs | 39 ++++++++++++++++ packages/domain/test/entitlements-v1.test.mjs | 12 ++++- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/domain/src/entitlements/v1.ts b/packages/domain/src/entitlements/v1.ts index 0dbac13b..c0b98da9 100644 --- a/packages/domain/src/entitlements/v1.ts +++ b/packages/domain/src/entitlements/v1.ts @@ -290,6 +290,7 @@ export function createEntitlementLeaseV1( if ( !Number.isFinite(Date.parse(issuedAt)) || !Number.isFinite(Date.parse(expiresAt)) || + Date.parse(issuedAt) < Date.parse(snapshot.effectiveAt) || Date.parse(expiresAt) <= Date.parse(issuedAt) || Date.parse(expiresAt) - Date.parse(issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 || (snapshot.expiresAt !== undefined && Date.parse(expiresAt) > Date.parse(snapshot.expiresAt)) @@ -580,19 +581,53 @@ export function acceptEntitlementLeaseV1( ): EntitlementResultV1 { const now = timestamp(input.now); const tenantScope = scope(input.tenantScope); + const leaseId = stableId(lease.leaseId); + const leaseScope = scope(lease.tenantScope); + const issuedAt = timestamp(lease.issuedAt); + const expiresAt = timestamp(lease.expiresAt); const snapshotRevision = positiveInteger(input.snapshotRevision); const securityEpoch = positiveInteger(input.securityEpoch); if (!now) return rejected('INVALID_TIMESTAMP'); if (!tenantScope) return rejected('INVALID_SCOPE'); if (!snapshotRevision || !securityEpoch) return rejected('INVALID_STATE'); - if (!sameScope(lease.tenantScope, tenantScope)) return rejected('LEASE_STALE'); + if ( + !leaseId || + !leaseScope || + leaseScope.scopeType === 'project' || + !issuedAt || + !expiresAt || + lease.schemaVersion !== ENTITLEMENT_SCHEMA_VERSION_V1 || + !positiveInteger(lease.snapshotRevision) || + !positiveInteger(lease.securityEpoch) || + !text(lease.payload, 10000) || + !text(lease.signature, 2048) || + Date.parse(expiresAt) <= Date.parse(issuedAt) || + Date.parse(expiresAt) - Date.parse(issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 || + lease.payload !== + canonicalLease({ + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + leaseId, + tenantScope: leaseScope, + snapshotRevision: lease.snapshotRevision, + securityEpoch: lease.securityEpoch, + issuedAt, + expiresAt, + }) + ) + return rejected('LEASE_INVALID'); + if (!sameScope(leaseScope, tenantScope)) return rejected('LEASE_STALE'); if (lease.snapshotRevision !== snapshotRevision || lease.securityEpoch !== securityEpoch) return rejected('LEASE_STALE'); - if (!verifier.verify(lease.payload, lease.signature)) return rejected('LEASE_INVALID'); + let signatureValid = false; + try { + signatureValid = verifier.verify(lease.payload, lease.signature); + } catch { + signatureValid = false; + } + if (!signatureValid) return rejected('LEASE_INVALID'); if ( - Date.parse(now) < Date.parse(lease.issuedAt) || - Date.parse(now) >= Date.parse(lease.expiresAt) || - Date.parse(lease.expiresAt) - Date.parse(lease.issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 + Date.parse(now) < Date.parse(issuedAt) || + Date.parse(now) >= Date.parse(expiresAt) ) return rejected('LEASE_INVALID'); return Object.freeze({ accepted: true, value: true }); diff --git a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs index ec91a4a0..e2eea9b3 100644 --- a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -90,4 +90,43 @@ void test('[BUA-017, BUA-018] suspended snapshots and overlong leases fail close ), { accepted: false, code: 'LEASE_INVALID' }, ); + assert.deepEqual( + createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000756', + issuedAt: '2025-12-31T23:59:59.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); +}); + +void test('[BUA-018] acceptance rejects payloads that do not canonically bind lease fields', () => { + const lease = createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000757', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ); + assert.equal(lease.accepted, true); + if (!lease.accepted) return; + assert.deepEqual( + acceptEntitlementLeaseV1( + { ...lease.value, payload: `${lease.value.payload} ` }, + { + now: '2026-01-01T00:15:00.000Z', + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 2, + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); }); diff --git a/packages/domain/test/entitlements-v1.test.mjs b/packages/domain/test/entitlements-v1.test.mjs index 7c883293..aa979a92 100644 --- a/packages/domain/test/entitlements-v1.test.mjs +++ b/packages/domain/test/entitlements-v1.test.mjs @@ -133,11 +133,19 @@ test('[BUA-017, BUA-018] offline leases require a valid signature, current epoch securityEpoch: 4, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-02T00:00:00.000Z', - payload: 'signed-payload', + payload: JSON.stringify({ + schemaVersion: 1, + leaseId: id('30'), + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 4, + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + }), signature: 'signature', }; const verifier = { - verify: (payload, signature) => payload === 'signed-payload' && signature === 'signature', + verify: (payload, signature) => payload === lease.payload && signature === 'signature', }; assert.deepEqual( acceptEntitlementLeaseV1( From ea59eb12b1d6de088672ec039deb0fbff41dad4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:24:59 +0700 Subject: [PATCH 53/99] feat(audit): define seal attestation repository port --- .../audit-attestation-repository.port.ts | 22 +++++++++ .../aud/application/audit-equality.ts | 26 ++++++++++- .../aud/audit-attestation-contract.test.ts | 46 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/aud/application/audit-attestation-repository.port.ts create mode 100644 services/api/test/features/aud/audit-attestation-contract.test.ts 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 new file mode 100644 index 00000000..2ca4fa8a --- /dev/null +++ b/services/api/src/features/aud/application/audit-attestation-repository.port.ts @@ -0,0 +1,22 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const AUDIT_ATTESTATION_REPOSITORY_PORT = Symbol('AUDIT_ATTESTATION_REPOSITORY_PORT'); + +export interface AuditAttestationTransactionPortV1 { + saveAttestation(context: IamTenantContextV1, attestation: AuditSealAttestationV1): Promise; + findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise; +} + +export interface AuditAttestationRepositoryPortV1 extends AuditAttestationTransactionPortV1 { + listAttestations(context: IamTenantContextV1): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/aud/application/audit-equality.ts b/services/api/src/features/aud/application/audit-equality.ts index a2429f6a..f1ced32b 100644 --- a/services/api/src/features/aud/application/audit-equality.ts +++ b/services/api/src/features/aud/application/audit-equality.ts @@ -1,4 +1,9 @@ -import type { AuditEventV1, AuditSealV1, AuditSummaryV1 } from '@databreeze/domain/audit/v1'; +import type { + AuditEventV1, + AuditSealAttestationV1, + AuditSealV1, + AuditSummaryV1, +} from '@databreeze/domain/audit/v1'; import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { @@ -54,3 +59,22 @@ export function sameAuditSealV1(left: AuditSealV1, right: AuditSealV1): boolean left.sealedAt === right.sealedAt ); } + +export function sameAuditSealAttestationV1( + left: AuditSealAttestationV1, + right: AuditSealAttestationV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.attestationId === right.attestationId && + sameScope(left.tenantScope, right.tenantScope) && + left.firstSequence === right.firstSequence && + left.lastSequence === right.lastSequence && + left.eventCount === right.eventCount && + left.rootDigest === right.rootDigest && + left.sealedAt === right.sealedAt && + left.signerKeyId === right.signerKeyId && + left.payload === right.payload && + left.signature === right.signature + ); +} diff --git a/services/api/test/features/aud/audit-attestation-contract.test.ts b/services/api/test/features/aud/audit-attestation-contract.test.ts new file mode 100644 index 00000000..76bd9dd1 --- /dev/null +++ b/services/api/test/features/aud/audit-attestation-contract.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { sameAuditSealAttestationV1 } from '../../../src/features/aud/application/audit-equality.js'; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +function timestamp(value: string) { + const result = parseStrictUtcTimestampV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid timestamp'); + return result.value; +} + +void test('[AUD-015, AUD-016] attestation equality includes signer binding and signature bytes', () => { + const base: AuditSealAttestationV1 = { + schemaVersion: 1, + attestationId: stable('00000000-0000-4000-8000-000000000801'), + tenantScope: { + scopeType: 'organization' as const, + organizationId: stable('00000000-0000-4000-8000-000000000802'), + }, + firstSequence: 1, + lastSequence: 2, + eventCount: 2, + rootDigest: 'root', + sealedAt: timestamp('2026-01-01T00:00:00.000Z'), + signerKeyId: 'key-1', + payload: 'payload', + signature: 'signature', + }; + assert.equal(sameAuditSealAttestationV1(base, { ...base }), true); + assert.equal(sameAuditSealAttestationV1(base, { ...base, signerKeyId: 'key-2' }), false); + assert.equal(sameAuditSealAttestationV1(base, { ...base, signature: 'tampered' }), false); +}); From 370554600462c7aeddf6db09463bfeb172235c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:27:00 +0700 Subject: [PATCH 54/99] feat(audit): add in-memory seal attestations --- ...ry-audit-attestation-repository.adapter.ts | 94 ++++++++++++++++ .../aud/audit-attestation-repository.test.ts | 101 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts create mode 100644 services/api/test/features/aud/audit-attestation-repository.test.ts 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 new file mode 100644 index 00000000..15b4c9f2 --- /dev/null +++ b/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts @@ -0,0 +1,94 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditAttestationRepositoryPortV1, + AuditAttestationTransactionPortV1, +} from '../application/audit-attestation-repository.port.js'; +import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(attestation: AuditSealAttestationV1): AuditSealAttestationV1 { + return Object.freeze({ + ...attestation, + tenantScope: Object.freeze({ ...attestation.tenantScope }), + }); +} + +/** In-memory independent attestation store with immutable identity and tenant visibility. */ +export class InMemoryAuditAttestationRepositoryAdapter implements AuditAttestationRepositoryPortV1 { + private attestations = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + await Promise.resolve(); + 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)); + } + + public async findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const attestation = this.attestations.get(attestationId); + return attestation && visible(context.tenantScope, attestation.tenantScope) + ? clone(attestation) + : undefined; + } + + public async listAttestations( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.attestations.values()] + .filter((attestation) => visible(context.tenantScope, attestation.tenantScope)) + .sort( + (left, right) => + tenantScopeKeyV1(left.tenantScope).localeCompare(tenantScopeKeyV1(right.tenantScope)) || + left.lastSequence - right.lastSequence || + left.attestationId.localeCompare(right.attestationId), + ) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.attestations); + try { + return await work({ + saveAttestation: this.saveAttestation.bind(this), + findAttestation: this.findAttestation.bind(this), + }); + } catch (error) { + this.attestations = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/test/features/aud/audit-attestation-repository.test.ts b/services/api/test/features/aud/audit-attestation-repository.test.ts new file mode 100644 index 00000000..aec06a2e --- /dev/null +++ b/services/api/test/features/aud/audit-attestation-repository.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAuditSealAttestationV1, + type AuditSealAttestationV1, + type AuditSealV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryAuditAttestationRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000811'; +const workspaceId = '00000000-0000-4000-8000-000000000812'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000813'; +const actorId = '00000000-0000-4000-8000-000000000814'; +const correlationId = '00000000-0000-4000-8000-000000000815'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context(workspace = workspaceId, idempotencyKey = 'attestation') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function attestation(): AuditSealAttestationV1 { + const seal: AuditSealV1 = { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 3, + eventCount: 3, + rootDigest: 'root-digest', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + const created = createAuditSealAttestationV1( + seal, + { attestationId: '00000000-0000-4000-8000-000000000816', signerKeyId: 'key-1' }, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + ); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid attestation'); + return created.value; +} + +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); + 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/, + ); +}); + +void test('[AUD-007, AUD-015] attestation writes roll back transactionally', async () => { + const repository = new InMemoryAuditAttestationRepositoryAdapter(); + const value = attestation(); + await assert.rejects( + repository.withTransaction(context(), async (transaction) => { + await transaction.saveAttestation(context(), value); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.deepEqual(await repository.listAttestations(context()), []); +}); From 304eecff27488741ce7768808110bb8c9c589dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:27:21 +0700 Subject: [PATCH 55/99] feat(audit): persist seal attestation schema --- .../migration.sql | 24 +++++++++++++++++++ services/api/prisma/schema/aud.prisma | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql diff --git a/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql b/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql new file mode 100644 index 00000000..6629d6c8 --- /dev/null +++ b/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql @@ -0,0 +1,24 @@ +-- AUD-015/016: independent seal attestation storage. +CREATE TABLE "aud"."audit_seal_attestations" ( + "id" UUID NOT NULL, + "schema_version" INTEGER NOT NULL, + "scope_key" VARCHAR(200) NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "first_sequence" INTEGER NOT NULL, + "last_sequence" INTEGER NOT NULL, + "event_count" INTEGER NOT NULL, + "root_digest" VARCHAR(512) NOT NULL, + "sealed_at" TIMESTAMPTZ(6) NOT NULL, + "signer_key_id" VARCHAR(200) NOT NULL, + "payload" TEXT NOT NULL, + "signature" VARCHAR(2048) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_seal_attestations_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "audit_attestations_scope_idx" +ON "aud"."audit_seal_attestations"("organization_id", "workspace_id", "project_id", "last_sequence"); diff --git a/services/api/prisma/schema/aud.prisma b/services/api/prisma/schema/aud.prisma index 77838972..2b43170e 100644 --- a/services/api/prisma/schema/aud.prisma +++ b/services/api/prisma/schema/aud.prisma @@ -51,3 +51,27 @@ model AuditSealRecord { @@map("audit_seals") @@schema("aud") } + +/// AUD-015/016: independent signatures bind an immutable seal range and signer key. +model AuditSealAttestationRecord { + id String @id @db.Uuid + schemaVersion Int @map("schema_version") + scopeKey String @map("scope_key") @db.VarChar(200) + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + firstSequence Int @map("first_sequence") + lastSequence Int @map("last_sequence") + eventCount Int @map("event_count") + rootDigest String @map("root_digest") @db.VarChar(512) + sealedAt DateTime @map("sealed_at") @db.Timestamptz(6) + signerKeyId String @map("signer_key_id") @db.VarChar(200) + payload String @db.Text + signature String @db.VarChar(2048) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([organizationId, workspaceId, projectId, lastSequence], map: "audit_attestations_scope_idx") + @@map("audit_seal_attestations") + @@schema("aud") +} From 28343201ad8d4b20abb91bdb1288cc9be468b338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:28:56 +0700 Subject: [PATCH 56/99] feat(audit): add Prisma seal attestation adapter --- ...ma-audit-attestation-repository.adapter.ts | 253 ++++++++++++++++++ ...risma-audit-attestation-repository.test.ts | 131 +++++++++ 2 files changed, 384 insertions(+) create mode 100644 services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts create mode 100644 services/api/test/features/aud/prisma-audit-attestation-repository.test.ts 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 new file mode 100644 index 00000000..c193f725 --- /dev/null +++ b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts @@ -0,0 +1,253 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditAttestationRepositoryPortV1, + AuditAttestationTransactionPortV1, +} from '../application/audit-attestation-repository.port.js'; +import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; + +export interface AuditAttestationDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: Date; + readonly signerKeyId: string; + readonly payload: string; + readonly signature: string; + readonly createdAt: Date; +} + +interface AuditAttestationDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +interface AuditAttestationDelegateV1 { + create(input: { + readonly data: AuditAttestationDatabaseCreateDataV1; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface AuditAttestationDatabaseClientV1 { + readonly auditSealAttestationRecord: AuditAttestationDelegateV1; + $transaction( + work: (transaction: AuditAttestationDatabaseClientV1) => Promise, + ): Promise; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function persistedScope(row: AuditAttestationDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('AUD_PERSISTED_ATTESTATION_SCOPE_INVALID'); + return parsed.value; +} + +function persistedAttestation(row: AuditAttestationDatabaseRowV1): AuditSealAttestationV1 { + const attestationId = parseStableIdentifierV1(row.id); + const scope = persistedScope(row); + const sealedAt = parseStrictUtcTimestampV1(row.sealedAt.toISOString()); + if ( + row.schemaVersion !== 1 || + !attestationId.accepted || + !sealedAt.accepted || + !positiveInteger(row.firstSequence) || + !positiveInteger(row.lastSequence) || + row.lastSequence < row.firstSequence || + !positiveInteger(row.eventCount) || + !text(row.rootDigest, 512) || + !text(row.signerKeyId, 200) || + !text(row.payload, 10000) || + !text(row.signature, 2048) + ) + throw new Error('AUD_PERSISTED_ATTESTATION_INVALID'); + return Object.freeze({ + schemaVersion: 1, + attestationId: attestationId.value, + tenantScope: scope, + firstSequence: row.firstSequence, + lastSequence: row.lastSequence, + eventCount: row.eventCount, + rootDigest: row.rootDigest, + sealedAt: sealedAt.value, + signerKeyId: row.signerKeyId, + payload: row.payload, + signature: row.signature, + }); +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + if (context.tenantScope.scopeType === 'organization') + return { organizationId: context.tenantScope.organizationId }; + if (context.tenantScope.scopeType === 'workspace') { + return { + organizationId: context.tenantScope.organizationId, + OR: [ + { scopeType: 'organization' }, + { scopeType: 'workspace', workspaceId: context.tenantScope.workspaceId }, + ], + }; + } + return { + organizationId: context.tenantScope.organizationId, + OR: [ + { scopeType: 'organization' }, + { scopeType: 'workspace', workspaceId: context.tenantScope.workspaceId }, + { scopeType: 'project', projectId: context.tenantScope.projectId }, + ], + }; +} + +function attestationData( + attestation: AuditSealAttestationV1, +): AuditAttestationDatabaseCreateDataV1 { + return { + ...databaseScope(attestation.tenantScope), + id: attestation.attestationId, + schemaVersion: attestation.schemaVersion, + scopeKey: tenantScopeKeyV1(attestation.tenantScope), + firstSequence: attestation.firstSequence, + lastSequence: attestation.lastSequence, + eventCount: attestation.eventCount, + rootDigest: attestation.rootDigest, + sealedAt: new Date(attestation.sealedAt), + signerKeyId: attestation.signerKeyId, + payload: attestation.payload, + signature: attestation.signature, + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaAuditAttestationTransactionAdapter implements AuditAttestationTransactionPortV1 { + public constructor(private readonly client: AuditAttestationDatabaseClientV1) {} + + public async saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + 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; + } + await this.client.auditSealAttestationRecord.create({ data: attestationData(attestation) }); + } + + public async findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + const row = await this.client.auditSealAttestationRecord.findFirst({ + where: { id: attestationId, ...scopeWhere(context) }, + }); + if (row === null) return undefined; + const attestation = persistedAttestation(row); + return visible(context.tenantScope, attestation.tenantScope) ? attestation : undefined; + } +} + +export class PrismaAuditAttestationRepositoryAdapter implements AuditAttestationRepositoryPortV1 { + public constructor(private readonly client: AuditAttestationDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaAuditAttestationTransactionAdapter(transaction)), + ); + } + + public saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + return new PrismaAuditAttestationTransactionAdapter(this.client).saveAttestation( + context, + attestation, + ); + } + + public findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + return new PrismaAuditAttestationTransactionAdapter(this.client).findAttestation( + context, + attestationId, + ); + } + + public async listAttestations( + context: IamTenantContextV1, + ): Promise { + const rows = await this.client.auditSealAttestationRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + }); + return rows + .map(persistedAttestation) + .filter((attestation) => visible(context.tenantScope, attestation.tenantScope)) + .sort( + (left, right) => + tenantScopeKeyV1(left.tenantScope).localeCompare(tenantScopeKeyV1(right.tenantScope)) || + left.lastSequence - right.lastSequence || + left.attestationId.localeCompare(right.attestationId), + ); + } +} 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 new file mode 100644 index 00000000..a88f04ef --- /dev/null +++ b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAuditSealAttestationV1, + type AuditSealAttestationV1, + type AuditSealV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaAuditAttestationRepositoryAdapter, + type AuditAttestationDatabaseClientV1, +} from '../../../src/features/aud/adapter/prisma-audit-attestation-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000821'; +const workspaceId = '00000000-0000-4000-8000-000000000822'; +const actorId = '00000000-0000-4000-8000-000000000823'; +const correlationId = '00000000-0000-4000-8000-000000000824'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'prisma-attestation', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function attestation(): AuditSealAttestationV1 { + const seal: AuditSealV1 = { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 2, + eventCount: 2, + rootDigest: 'root', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + const created = createAuditSealAttestationV1( + seal, + { attestationId: '00000000-0000-4000-8000-000000000825', signerKeyId: 'key-1' }, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + ); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid attestation'); + return created.value; +} + +function delegate(rows: Record[]) { + const matches = ( + row: Record, + where: Readonly>, + ): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) + return value.some((candidate) => + matches(row, candidate as Readonly>), + ); + return row[key] === value; + }); + return { + create({ data }: { readonly data: Record }) { + const row = { ...data }; + rows.push(row); + return Promise.resolve(row); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve(rows.find((row) => matches(row, where)) ?? null); + }, + findMany({ where }: { readonly where: Readonly> }) { + return Promise.resolve(rows.filter((row) => matches(row, where))); + }, + }; +} + +function client(rows: Record[] = []): AuditAttestationDatabaseClientV1 { + const database = { + auditSealAttestationRecord: delegate(rows), + } as unknown as AuditAttestationDatabaseClientV1; + return { + ...database, + async $transaction( + work: (transaction: AuditAttestationDatabaseClientV1) => Promise, + ) { + return work(database); + }, + }; +} + +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); + 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/, + ); +}); From fb9d5c274b35eae09d65838ffffd259e08c87818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:30:53 +0700 Subject: [PATCH 57/99] feat(audit): issue and verify seal attestations --- .../application/audit-attestation.service.ts | 153 ++++++++++++++++++ .../aud/application/audit-repository.port.ts | 1 + .../aud/audit-attestation.service.test.ts | 120 ++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 services/api/src/features/aud/application/audit-attestation.service.ts create mode 100644 services/api/test/features/aud/audit-attestation.service.test.ts diff --git a/services/api/src/features/aud/application/audit-attestation.service.ts b/services/api/src/features/aud/application/audit-attestation.service.ts new file mode 100644 index 00000000..e149307a --- /dev/null +++ b/services/api/src/features/aud/application/audit-attestation.service.ts @@ -0,0 +1,153 @@ +import { randomUUID } from 'node:crypto'; + +import { + createAuditSealAttestationV1, + verifyAuditSealAttestationV1, + type AuditErrorCodeV1, + type AuditSealAttestationSignerV1, + type AuditSealAttestationV1, + type AuditResultV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + tenantScopeKeyV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { AuditAttestationRepositoryPortV1 } from './audit-attestation-repository.port.js'; +import type { AuditRepositoryPortV1 } from './audit-repository.port.js'; + +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 AuditAttestationApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: AuditAttestationApplicationCodeV1 }; + +export interface CreateAuditAttestationInputV1 { + readonly attestationId?: unknown; + readonly signerKeyId: unknown; + readonly firstSequence: unknown; + readonly lastSequence: unknown; + readonly rootDigest: unknown; +} + +export interface VerifyAuditAttestationInputV1 { + readonly attestationId: unknown; +} + +function rejected( + code: AuditAttestationApplicationCodeV1, +): AuditAttestationApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function applicationResult( + result: AuditResultV1, +): AuditAttestationApplicationResultV1 { + return result.accepted ? result : rejected(result.code); +} + +/** Attests only a persisted exact-scope seal and keeps the signature in a separate store. */ +export class AuditAttestationService { + public constructor( + private readonly auditRepository: AuditRepositoryPortV1, + private readonly attestationRepository: AuditAttestationRepositoryPortV1, + private readonly signer: AuditSealAttestationSignerV1, + private readonly idGenerator: AuditAttestationIdGeneratorV1 = () => randomUUID(), + ) {} + + public async create( + context: IamTenantContextV1, + input: CreateAuditAttestationInputV1, + ): Promise> { + const attestationId = stableId(input.attestationId ?? this.idGenerator()); + const firstSequence = positiveInteger(input.firstSequence); + const lastSequence = positiveInteger(input.lastSequence); + const rootDigest = text(input.rootDigest, 512); + if (!attestationId) return rejected('INVALID_IDENTIFIER'); + 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, + ); + if (!seal) return rejected('NOT_FOUND'); + const created = createAuditSealAttestationV1( + seal, + { attestationId, signerKeyId: input.signerKeyId }, + this.signer, + ); + if (!created.accepted) return applicationResult(created); + await this.attestationRepository.withTransaction(context, async (transaction) => { + await transaction.saveAttestation(context, created.value); + }); + return created; + } + + public async verify( + context: IamTenantContextV1, + input: VerifyAuditAttestationInputV1, + ): Promise> { + const attestationId = stableId(input.attestationId); + 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, + ); + if (!seal) return rejected('NOT_FOUND'); + return applicationResult(verifyAuditSealAttestationV1(attestation, seal, this.signer)); + } +} + +export class UnavailableAuditAttestationService { + public create( + context: IamTenantContextV1, + input: CreateAuditAttestationInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } + + public verify( + context: IamTenantContextV1, + input: VerifyAuditAttestationInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } +} 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 2bb92343..9c7e7890 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -39,6 +39,7 @@ export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise>; + listSeals(context: IamTenantContextV1): Promise; withTransaction( context: IamTenantContextV1, work: (transaction: AuditTransactionPortV1) => Promise, diff --git a/services/api/test/features/aud/audit-attestation.service.test.ts b/services/api/test/features/aud/audit-attestation.service.test.ts new file mode 100644 index 00000000..39449071 --- /dev/null +++ b/services/api/test/features/aud/audit-attestation.service.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { type AuditSealV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryAuditAttestationRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.js'; +import { AuditAttestationService } from '../../../src/features/aud/application/audit-attestation.service.js'; +import { InMemoryAuditRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000831'; +const workspaceId = '00000000-0000-4000-8000-000000000832'; +const actorId = '00000000-0000-4000-8000-000000000833'; +const correlationId = '00000000-0000-4000-8000-000000000834'; +const attestationId = '00000000-0000-4000-8000-000000000836'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context(idempotencyKey = 'attestation-service') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function seal(): AuditSealV1 { + return { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 3, + eventCount: 3, + rootDigest: 'root-digest', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; +} + +async function setup() { + const auditRepository = new InMemoryAuditRepositoryAdapter(); + await auditRepository.saveSeal(context(), seal()); + const attestationRepository = new InMemoryAuditAttestationRepositoryAdapter(); + const service = new AuditAttestationService( + auditRepository, + attestationRepository, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + () => attestationId, + ); + return { service, attestationRepository }; +} + +void test('[AUD-015, AUD-016] service signs only a persisted exact-scope seal and verifies it', async () => { + const { service, attestationRepository } = await setup(); + const created = await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root-digest', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual(await service.verify(context(), { attestationId }), { + accepted: true, + value: true, + }); + assert.deepEqual( + await attestationRepository.findAttestation(context(), stable(attestationId)), + created.value, + ); +}); + +void test('[AUD-015] service rejects missing seals and malformed selectors before signing', async () => { + const { service } = await setup(); + assert.deepEqual( + await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 2, + lastSequence: 1, + rootDigest: 'root-digest', + }), + { accepted: false, code: 'INVALID_SEQUENCE' }, + ); + assert.deepEqual( + await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'missing', + }), + { accepted: false, code: 'NOT_FOUND' }, + ); +}); From 4b40c9bd310d1bb0b60c96a2e311c5ca6c085559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:32:00 +0700 Subject: [PATCH 58/99] feat(audit): compose attestation providers --- services/api/src/features/aud/aud.module.ts | 48 ++++++++++++++++++- .../api/test/features/aud/aud.module.test.ts | 35 ++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 services/api/test/features/aud/aud.module.test.ts diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 906e60f1..5d7c8861 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -1,6 +1,23 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { AuditLedgerService } from './application/audit-ledger.service.js'; +import { + AUDIT_ATTESTATION_SERVICE, + AuditAttestationService, + UnavailableAuditAttestationService, + type AuditAttestationIdGeneratorV1, + type AuditAttestationService as AuditAttestationServicePortV1, +} from './application/audit-attestation.service.js'; +import { + AUDIT_ATTESTATION_REPOSITORY_PORT, + type AuditAttestationRepositoryPortV1, +} from './application/audit-attestation-repository.port.js'; +import type { AuditSealAttestationSignerV1 } from '@databreeze/domain/audit/v1'; +import { InMemoryAuditAttestationRepositoryAdapter } from './adapter/in-memory-audit-attestation-repository.adapter.js'; +import { + PrismaAuditAttestationRepositoryAdapter, + type AuditAttestationDatabaseClientV1, +} from './adapter/prisma-audit-attestation-repository.adapter.js'; import { AUDIT_REPOSITORY_PORT, type AuditRepositoryPortV1, @@ -24,6 +41,13 @@ export interface AudModuleOptions { readonly auditRepository?: AuditRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly auditDatabase?: AuditDatabaseClientV1; + readonly auditAttestationRepository?: AuditAttestationRepositoryPortV1; + readonly auditAttestationDatabase?: AuditAttestationDatabaseClientV1; + readonly auditAttestationService?: + | AuditAttestationServicePortV1 + | UnavailableAuditAttestationService; + readonly auditAttestationSigner?: AuditSealAttestationSignerV1; + readonly auditAttestationIdGenerator?: AuditAttestationIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -37,18 +61,40 @@ export class AudModule { ? new InMemoryAuditRepositoryAdapter() : new PrismaAuditRepositoryAdapter(options.auditDatabase, digest)); const service = new AuditLedgerService(repository, digest); + const attestationRepository = + options.auditAttestationRepository ?? + (options.auditAttestationDatabase === undefined + ? new InMemoryAuditAttestationRepositoryAdapter() + : new PrismaAuditAttestationRepositoryAdapter(options.auditAttestationDatabase)); + const attestationService = + options.auditAttestationService ?? + (options.auditAttestationSigner === undefined + ? new UnavailableAuditAttestationService() + : new AuditAttestationService( + repository, + attestationRepository, + options.auditAttestationSigner, + options.auditAttestationIdGenerator, + )); return { module: AudModule, controllers: [AuditController], providers: [ { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + { provide: AUDIT_ATTESTATION_REPOSITORY_PORT, useValue: attestationRepository }, + { provide: AUDIT_ATTESTATION_SERVICE, useValue: attestationService }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], + exports: [ + AUDIT_REPOSITORY_PORT, + AUDIT_LEDGER_SERVICE, + AUDIT_ATTESTATION_REPOSITORY_PORT, + AUDIT_ATTESTATION_SERVICE, + ], }; } } diff --git a/services/api/test/features/aud/aud.module.test.ts b/services/api/test/features/aud/aud.module.test.ts new file mode 100644 index 00000000..4e41bd2a --- /dev/null +++ b/services/api/test/features/aud/aud.module.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AudModule } from '../../../src/features/aud/aud.module.js'; +import { AUDIT_ATTESTATION_REPOSITORY_PORT } from '../../../src/features/aud/application/audit-attestation-repository.port.js'; +import { AUDIT_ATTESTATION_SERVICE } from '../../../src/features/aud/application/audit-attestation.service.js'; +import { AUDIT_LEDGER_SERVICE } from '../../../src/features/aud/aud.module.js'; + +void test('[AUD-015, AUD-016] module composition keeps attestations behind replaceable ports', () => { + const dynamic = AudModule.register({ + auditAttestationSigner: { + sign: (payload) => payload, + verify: (payload, signature) => payload === signature, + }, + }); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === AUDIT_ATTESTATION_REPOSITORY_PORT, + ), + true, + ); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === AUDIT_ATTESTATION_SERVICE, + ), + true, + ); + assert.equal(dynamic.exports?.includes(AUDIT_LEDGER_SERVICE), true); +}); From a100a7523ac542b4794ad68150cfd9a92640fca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:34:28 +0700 Subject: [PATCH 59/99] feat(audit): expose attestation api --- services/api/openapi/v1.json | 229 ++++++++++++++++++ .../aud/api/audit-attestation.controller.ts | 83 +++++++ .../features/aud/api/audit-attestation.dto.ts | 42 ++++ .../aud/application/audit-problem.error.ts | 9 +- services/api/src/features/aud/aud.module.ts | 3 +- .../platform/http/problem-details.filter.ts | 25 +- .../aud/audit-attestation.controller.test.ts | 78 ++++++ 7 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 services/api/src/features/aud/api/audit-attestation.controller.ts create mode 100644 services/api/src/features/aud/api/audit-attestation.dto.ts create mode 100644 services/api/test/features/aud/audit-attestation.controller.test.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index dc0b2ee3..67792751 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -9282,6 +9282,220 @@ "tags": ["audit"] } }, + "/v1/audit/attestations": { + "post": { + "operationId": "AuditAttestationController.create", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateAuditAttestationDto" } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The requested seal is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Audit attestation signing or persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an independent signature for an exact audit seal", + "tags": ["audit"] + } + }, + "/v1/audit/attestations/{attestationId}/verify": { + "get": { + "operationId": "AuditAttestationController.verify", + "parameters": [ + { + "name": "attestationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid"], + "properties": { "valid": { "type": "boolean" } } + } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The attestation or its referenced seal is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Audit attestation verification is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Verify an independent audit seal attestation", + "tags": ["audit"] + } + }, "/v1/entitlements/snapshots/{snapshotId}": { "get": { "operationId": "EntitlementController.snapshot", @@ -11283,6 +11497,21 @@ "publishedAt" ] }, + "CreateAuditAttestationDto": { + "type": "object", + "properties": { + "attestationId": { + "type": "string", + "format": "uuid", + "description": "Server-generated when omitted" + }, + "signerKeyId": { "type": "string", "minLength": 1, "maxLength": 200 }, + "firstSequence": { "type": "number", "minimum": 1 }, + "lastSequence": { "type": "number", "minimum": 1 }, + "rootDigest": { "type": "string", "minLength": 1, "maxLength": 512 } + }, + "required": ["signerKeyId", "firstSequence", "lastSequence", "rootDigest"] + }, "IssueEntitlementLeaseDto": { "type": "object", "properties": { diff --git a/services/api/src/features/aud/api/audit-attestation.controller.ts b/services/api/src/features/aud/api/audit-attestation.controller.ts new file mode 100644 index 00000000..f6420631 --- /dev/null +++ b/services/api/src/features/aud/api/audit-attestation.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + AUDIT_ATTESTATION_SERVICE, + type AuditAttestationApplicationResultV1, + type AuditAttestationService, +} from '../application/audit-attestation.service.js'; +import { AuditProblemError } from '../application/audit-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { CreateAuditAttestationDto } from './audit-attestation.dto.js'; + +@ApiTags('audit') +@ApiBearerAuth() +@Controller('v1/audit') +export class AuditAttestationController { + public constructor( + @Inject(AUDIT_ATTESTATION_SERVICE) + private readonly attestations: AuditAttestationService, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private async execute( + work: () => Promise>, + ): Promise { + let result: AuditAttestationApplicationResultV1; + try { + result = await work(); + } catch { + throw new AuditProblemError('AUDIT_ATTESTATION_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'NOT_FOUND') throw new AuditProblemError('AUDIT_ATTESTATION_NOT_FOUND'); + if (result.code === 'UNAVAILABLE') throw new AuditProblemError('AUDIT_ATTESTATION_UNAVAILABLE'); + throw new AuditProblemError('AUDIT_ATTESTATION_REQUEST_INVALID'); + } + + @Post('attestations') + @HttpCode(201) + @ApiOperation({ summary: 'Create an independent signature for an exact audit seal' }) + @ApiBody({ type: CreateAuditAttestationDto }) + @ApiCreatedResponse({ schema: { type: 'object', additionalProperties: true } }) + @ApiNotFoundResponse({ description: 'The requested seal is not visible.' }) + @ApiServiceUnavailableResponse({ + description: 'Audit attestation signing or persistence is unavailable.', + }) + async create( + @Req() request: unknown, + @Body() input: CreateAuditAttestationDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => this.attestations.create(context, input)); + } + + @Get('attestations/:attestationId/verify') + @ApiOperation({ summary: 'Verify an independent audit seal attestation' }) + @ApiOkResponse({ + schema: { type: 'object', required: ['valid'], properties: { valid: { type: 'boolean' } } }, + }) + @ApiNotFoundResponse({ description: 'The attestation or its referenced seal is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Audit attestation verification is unavailable.' }) + async verify( + @Req() request: unknown, + @Param('attestationId') attestationId: string, + ): Promise<{ readonly valid: true }> { + const context = await this.requestContext.resolve(request); + await this.execute(() => this.attestations.verify(context, { attestationId })); + return Object.freeze({ valid: true }); + } +} diff --git a/services/api/src/features/aud/api/audit-attestation.dto.ts b/services/api/src/features/aud/api/audit-attestation.dto.ts new file mode 100644 index 00000000..9ad2111d --- /dev/null +++ b/services/api/src/features/aud/api/audit-attestation.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateAuditAttestationDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Server-generated when omitted' }) + @IsOptional() + @IsUUID() + attestationId?: string; + + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + signerKeyId!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + firstSequence!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + lastSequence!: number; + + @ApiProperty({ minLength: 1, maxLength: 512 }) + @IsString() + @MinLength(1) + @MaxLength(512) + rootDigest!: string; +} diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts index 61b181f7..c749c3c4 100644 --- a/services/api/src/features/aud/application/audit-problem.error.ts +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -1,5 +1,12 @@ export class AuditProblemError extends Error { - public constructor(readonly code: 'AUDIT_UNAVAILABLE' | 'AUDIT_INTEGRITY_INVALID') { + public constructor( + readonly code: + | 'AUDIT_UNAVAILABLE' + | 'AUDIT_INTEGRITY_INVALID' + | 'AUDIT_ATTESTATION_NOT_FOUND' + | 'AUDIT_ATTESTATION_REQUEST_INVALID' + | 'AUDIT_ATTESTATION_UNAVAILABLE', + ) { super(code); this.name = 'AuditProblemError'; } diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 5d7c8861..488eda24 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -29,6 +29,7 @@ import { } from './adapter/prisma-audit-repository.adapter.js'; import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; import { AuditController } from './api/audit.controller.js'; +import { AuditAttestationController } from './api/audit-attestation.controller.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -78,7 +79,7 @@ export class AudModule { )); return { module: AudModule, - controllers: [AuditController], + controllers: [AuditController, AuditAttestationController], providers: [ { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, { provide: AUDIT_LEDGER_SERVICE, useValue: service }, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index d520348f..61ab19b9 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -156,15 +156,34 @@ function describe(error: unknown, correlationId: string): ProblemInput { }; } if (error instanceof AuditProblemError) { + const attestationUnavailable = error.code === 'AUDIT_ATTESTATION_UNAVAILABLE'; + const attestationNotFound = error.code === 'AUDIT_ATTESTATION_NOT_FOUND'; + const attestationInvalid = error.code === 'AUDIT_ATTESTATION_REQUEST_INVALID'; const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { code: error.code, correlationId, messageKey: integrityInvalid ? 'api.error.audit_integrity_invalid' - : 'api.error.audit_unavailable', - retryable: !integrityInvalid, - status: integrityInvalid ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.SERVICE_UNAVAILABLE, + : attestationUnavailable + ? 'api.error.audit_attestation_unavailable' + : attestationNotFound + ? 'api.error.audit_attestation_not_found' + : attestationInvalid + ? 'api.error.audit_attestation_invalid' + : 'api.error.audit_unavailable', + retryable: + attestationUnavailable || + (!integrityInvalid && !attestationNotFound && !attestationInvalid), + status: integrityInvalid + ? HttpStatus.INTERNAL_SERVER_ERROR + : attestationUnavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : attestationNotFound + ? HttpStatus.NOT_FOUND + : attestationInvalid + ? HttpStatus.BAD_REQUEST + : HttpStatus.SERVICE_UNAVAILABLE, }; } if (error instanceof ArtifactExportProblemError) { diff --git a/services/api/test/features/aud/audit-attestation.controller.test.ts b/services/api/test/features/aud/audit-attestation.controller.test.ts new file mode 100644 index 00000000..00eecb5a --- /dev/null +++ b/services/api/test/features/aud/audit-attestation.controller.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AuditAttestationController } from '../../../src/features/aud/api/audit-attestation.controller.js'; +import { AuditProblemError } from '../../../src/features/aud/application/audit-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000841'; +const workspaceId = '00000000-0000-4000-8000-000000000842'; +const attestationId = '00000000-0000-4000-8000-000000000843'; +const actorId = '00000000-0000-4000-8000-000000000844'; +const correlationId = '00000000-0000-4000-8000-000000000845'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'attestation-controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const service = { + create: () => Promise.resolve({ accepted: true as const, value: { attestationId } }), + verify: () => Promise.resolve({ accepted: true as const, value: true as const }), + ...overrides, + }; + return new AuditAttestationController(service as never, { + resolve: () => Promise.resolve(context()), + }); +} + +void test('[AUD-015, AUD-016] controller exposes create and verify operations', async () => { + const instance = controller(); + assert.deepEqual( + await instance.create( + {}, + { + signerKeyId: 'key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root', + }, + ), + { attestationId }, + ); + assert.deepEqual(await instance.verify({}, attestationId), { valid: true }); +}); + +void test('[AUD-015] controller maps not-found and unavailable results', async () => { + await assert.rejects( + controller({ + verify: () => Promise.resolve({ accepted: false as const, code: 'NOT_FOUND' as const }), + }).verify({}, attestationId), + (error: unknown) => + error instanceof AuditProblemError && error.code === 'AUDIT_ATTESTATION_NOT_FOUND', + ); + await assert.rejects( + controller({ + create: () => Promise.resolve({ accepted: false as const, code: 'UNAVAILABLE' as const }), + }).create( + {}, + { + signerKeyId: 'key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root', + }, + ), + (error: unknown) => + error instanceof AuditProblemError && error.code === 'AUDIT_ATTESTATION_UNAVAILABLE', + ); +}); From cb0dd2769dcf33185aa1f9ca25df79caff968e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:35:32 +0700 Subject: [PATCH 60/99] fix(bua): validate entitlement snapshot plans --- packages/domain/src/entitlements/v1.ts | 9 +++++++- .../entitlement-lease-issuance-v1.test.mjs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/entitlements/v1.ts b/packages/domain/src/entitlements/v1.ts index c0b98da9..1f3a9d54 100644 --- a/packages/domain/src/entitlements/v1.ts +++ b/packages/domain/src/entitlements/v1.ts @@ -233,13 +233,20 @@ export function createEntitlementSnapshotV1(input: { (plan as Partial).providerIndependent !== true ) return rejected('INVALID_PLAN'); + const normalizedPlan = createPlanV1({ + planCode: (plan as Partial).planCode, + displayNameKey: (plan as Partial).displayNameKey, + features: (plan as Partial).features, + quotas: (plan as Partial).quotas, + }); + if (!normalizedPlan.accepted) return rejected('INVALID_PLAN'); if (!validSnapshotStatus(input.status)) return rejected('INVALID_STATE'); if (!revision || !securityEpoch) return rejected('INVALID_STATE'); if (!effectiveAt || (input.expiresAt !== undefined && !expiresAt)) return rejected('INVALID_TIMESTAMP'); if (expiresAt && Date.parse(expiresAt) <= Date.parse(effectiveAt)) return rejected('INVALID_TIMESTAMP'); - const typedPlan = plan as EntitlementPlanV1; + const typedPlan = normalizedPlan.value; return Object.freeze({ accepted: true, value: Object.freeze({ diff --git a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs index e2eea9b3..49121ee9 100644 --- a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -130,3 +130,26 @@ void test('[BUA-018] acceptance rejects payloads that do not canonically bind le { accepted: false, code: 'LEASE_INVALID' }, ); }); + +void test('[BUA-001] snapshots reject malformed plan projections instead of trusting caller fields', () => { + const plan = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 20 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) return; + assert.deepEqual( + createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000758', + tenantScope: scope, + plan: { ...plan.value, quotas: [{ metric: 'unknown', limit: 20 }] }, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }), + { accepted: false, code: 'INVALID_PLAN' }, + ); +}); From e4ad3546c43933d0705a8652c1a0576bdd479cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:36:46 +0700 Subject: [PATCH 61/99] feat(bua): compose HMAC lease signing --- services/api/src/features/bua/bua.module.ts | 12 ++++++++++-- .../api/test/features/bua/bua.module.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 services/api/test/features/bua/bua.module.test.ts diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 53188840..fc72f9d3 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -24,6 +24,7 @@ import { PrismaEntitlementLeaseRepositoryAdapter, type EntitlementLeaseDatabaseClientV1, } from './adapter/prisma-entitlement-lease-repository.adapter.js'; +import { HmacEntitlementLeaseSignerAdapter } from './adapter/hmac-entitlement-lease-signer.adapter.js'; import { ENTITLEMENT_REPOSITORY_PORT, type EntitlementRepositoryPortV1, @@ -47,6 +48,8 @@ export interface BuaModuleOptions { | EntitlementLeaseServicePortV1 | UnavailableEntitlementLeaseService; readonly entitlementLeaseSigner?: EntitlementLeaseSignerV1; + /** Secret-manager supplied key; direct signer injection remains available for HSM/KMS adapters. */ + readonly entitlementLeaseSigningKey?: Uint8Array | string; readonly entitlementLeaseClock?: EntitlementLeaseClockV1; readonly entitlementLeaseIdGenerator?: EntitlementLeaseIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; @@ -66,14 +69,19 @@ export class BuaModule { (options.entitlementLeaseDatabase === undefined ? new InMemoryEntitlementLeaseRepositoryAdapter() : new PrismaEntitlementLeaseRepositoryAdapter(options.entitlementLeaseDatabase)); + const leaseSigner = + options.entitlementLeaseSigner ?? + (options.entitlementLeaseSigningKey === undefined + ? undefined + : new HmacEntitlementLeaseSignerAdapter(options.entitlementLeaseSigningKey)); const leaseService = options.entitlementLeaseService ?? - (options.entitlementLeaseSigner === undefined + (leaseSigner === undefined ? new UnavailableEntitlementLeaseService() : new EntitlementLeaseService( leaseRepository, repository, - options.entitlementLeaseSigner, + leaseSigner, options.entitlementLeaseClock, options.entitlementLeaseIdGenerator, )); diff --git a/services/api/test/features/bua/bua.module.test.ts b/services/api/test/features/bua/bua.module.test.ts new file mode 100644 index 00000000..8b1da4fb --- /dev/null +++ b/services/api/test/features/bua/bua.module.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BuaModule } from '../../../src/features/bua/bua.module.js'; +import { ENTITLEMENT_LEASE_SERVICE } from '../../../src/features/bua/application/entitlement-lease.service.js'; + +void test('[BUA-017, BUA-018] module composes lease service from secret-manager key material', () => { + const dynamic = BuaModule.register({ entitlementLeaseSigningKey: 'a'.repeat(32) }); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === ENTITLEMENT_LEASE_SERVICE, + ), + true, + ); +}); From d9a838621e8d07cd312d8de8412470d714685076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:38:27 +0700 Subject: [PATCH 62/99] docs(traceability): record IAM AUD BUA security slice --- .../iam-bua-security-slice-2026-08-03.md | 52 +++++++++ docs/plans/requirement-traceability.json | 103 +++++++++++------- 2 files changed, 118 insertions(+), 37 deletions(-) create mode 100644 docs/operations/iam-bua-security-slice-2026-08-03.md diff --git a/docs/operations/iam-bua-security-slice-2026-08-03.md b/docs/operations/iam-bua-security-slice-2026-08-03.md new file mode 100644 index 00000000..2d817032 --- /dev/null +++ b/docs/operations/iam-bua-security-slice-2026-08-03.md @@ -0,0 +1,52 @@ +# IAM, audit, and entitlement security slice — 2026-08-03 + +## Scope + +This evidence record covers the 30-commit `feat/iam-security-completion` batch based on +`origin/dev`. It is implementation evidence only. It does not claim that Plan 020 or any +P0/P1 release gate is complete. + +## Delivered + +- IAM service-account identities now use bounded permissions, digest-only secrets, one-time + secret issuance, rotation, permanent revocation, last-use monotonicity, tenant-scoped + repositories, Prisma persistence, and versioned lifecycle HTTP contracts. +- AUD action vocabulary includes service-account lifecycle actions. Audit seal attestations + are canonical, independently signed, immutable, tenant-scoped, transaction-aware, and + available through in-memory and Prisma adapters with API verification. +- BUA entitlement snapshots validate their complete provider-independent plan projection. + Signed offline leases are bounded to 24 hours and snapshot expiry, bind revision and + security epoch, persist immutably, verify canonical payloads, and use a replaceable HMAC + signer or injected HSM/KMS-compatible signer. +- BUA and AUD module composition defaults to unavailable signing when key material is absent; + no secret is generated, logged, or committed by the repository. + +## Verification + +- Domain build and 148 domain tests pass, including canonical lease acceptance, malformed plan + rejection, attestation binding, tenant ancestry, and signature tampering cases. +- Focused API TypeScript compilation, ESLint, Prisma validation, OpenAPI generation/check, + Redocly validation, and focused IAM/AUD/BUA tests pass. +- Prisma migrations are ordered and add only `bua.entitlement_leases` and + `aud.audit_seal_attestations`; no migration was applied to a live environment. +- Traceability entries for IAM-013, AUD-015, AUD-016, BUA-017, and BUA-018 remain `partial` + and `not-verified`. They point to the concrete code, tests, and this evidence record. + +## Security and rollback notes + +- Lease payloads are canonicalized before signature verification; malformed, stale, expired, + overlong, wrong-scope, and tampered leases fail closed. +- Attestation storage never broadens a caller scope and rejects immutable-identity changes. +- HMAC keys must be at least 32 bytes and should be supplied by a secret manager. HMAC is a + portable default, not a replacement for a production KMS/HSM policy. +- Every commit on the feature branch is independently reversible. The migration commits must + be reverted only with a reviewed down-migration/restore procedure; no destructive rollback + was executed here. + +## Remaining gates + +Full audit export/legal-hold/retention administration, atomic cross-module audit coordination, +offline authorization snapshots, entitlement reconciliation/usage exports, real PostgreSQL +integration, backup restoration, security assessment, and release evidence remain outstanding. +The feature PR targets `dev` without CodeRabbit; CodeRabbit remains reserved for the later +`dev` to `main` promotion PR and is invoked once there. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index 8adc4881..4c67742a 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -733,15 +733,20 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.service.test.ts", + "services/api/test/features/aud/prisma-audit-attestation-repository.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], "status": "partial", "coverage": "partial", @@ -758,12 +763,18 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/api/audit-attestation.controller.ts", + "services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.controller.test.ts", + "services/api/test/features/aud/audit-attestation-repository.test.ts" ], "releaseEvidence": [ "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" @@ -1045,8 +1056,8 @@ "security-and-tenant-gate", "release-manager-approval" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1085,15 +1096,20 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.service.test.ts", + "services/api/test/features/aud/prisma-audit-attestation-repository.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], "status": "partial", "coverage": "partial", @@ -1110,20 +1126,24 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/api/audit-attestation.controller.ts", + "services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.controller.test.ts", + "services/api/test/features/aud/audit-attestation-repository.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1763,20 +1783,23 @@ "codePaths": [ "packages/domain/src/entitlements/v1.ts", "services/api/src/features/bua/", - "services/api/prisma/schema/bua.prisma" + "services/api/prisma/schema/bua.prisma", + "services/api/src/features/bua/application/entitlement-lease.service.ts", + "services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts", + "services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql" ], "testPaths": [ "packages/domain/test/entitlements-v1.test.mjs", "services/api/test/features/bua/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/bua/entitlement-lease.service.test.ts", + "services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1790,20 +1813,23 @@ "codePaths": [ "packages/domain/src/entitlements/v1.ts", "services/api/src/features/bua/", - "services/api/prisma/schema/bua.prisma" + "services/api/prisma/schema/bua.prisma", + "services/api/src/features/bua/api/entitlement.controller.ts", + "services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts", + "services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql" ], "testPaths": [ "packages/domain/test/entitlements-v1.test.mjs", "services/api/test/features/bua/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/bua/entitlement.controller.test.ts", + "services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -9379,23 +9405,26 @@ "packages/domain/src/authorization/v1.ts", "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", + "packages/domain/src/service-account/v1.ts", + "packages/domain/src/audit/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", "services/api/test/features/iam/", + "services/api/test/features/iam/service-account.service.test.ts", + "services/api/test/features/iam/prisma-service-account-repository.test.ts", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" From c9ebd1fc947d294939056cff33a9709f09fd8178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:39:12 +0700 Subject: [PATCH 63/99] fix(traceability): keep audit ownership precise --- docs/plans/requirement-traceability.json | 25 +++++++----------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index 4c67742a..181d039e 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -733,20 +733,15 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma", - "services/api/src/features/aud/application/audit-attestation.service.ts", - "services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts", - "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts", - "services/api/test/features/aud/audit-attestation.service.test.ts", - "services/api/test/features/aud/prisma-audit-attestation-repository.test.ts" + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/iam-bua-security-slice-2026-08-03.md" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" ], "status": "partial", "coverage": "partial", @@ -763,18 +758,12 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma", - "services/api/src/features/aud/application/audit-attestation.service.ts", - "services/api/src/features/aud/api/audit-attestation.controller.ts", - "services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts", - "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" + "services/api/prisma/schema/aud.prisma" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts", - "services/api/test/features/aud/audit-attestation.controller.test.ts", - "services/api/test/features/aud/audit-attestation-repository.test.ts" + "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" @@ -1056,8 +1045,8 @@ "security-and-tenant-gate", "release-manager-approval" ], - "status": "partial", - "coverage": "partial", + "status": "planned", + "coverage": "planned", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" From 99141b948370921c8ce10acd4b02d735a3cbfa32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:40:52 +0700 Subject: [PATCH 64/99] chore: format IAM and entitlement security slice --- packages/domain/src/entitlements/v1.ts | 11 +- packages/domain/src/service-account/v1.ts | 30 ++-- .../test/audit-seal-attestation-v1.test.mjs | 10 +- .../audit-service-account-actions-v1.test.mjs | 4 +- .../domain/test/service-account-v1.test.mjs | 4 +- ...ry-entitlement-lease-repository.adapter.ts | 14 +- ...mory-service-account-repository.adapter.ts | 13 +- ...isma-service-account-repository.adapter.ts | 5 +- .../iam/api/service-account.controller.ts | 27 ++-- .../features/iam/api/service-account.dto.ts | 10 +- .../application/service-account.service.ts | 130 ++++++++++-------- .../bua/entitlement-lease-repository.test.ts | 45 +++++- .../prisma-service-account-repository.test.ts | 50 +++++-- .../iam/service-account-composition.test.ts | 13 +- .../iam/service-account-repository.test.ts | 30 +++- .../service-account-secret.adapter.test.ts | 5 +- .../iam/service-account.controller.test.ts | 37 ++--- .../iam/service-account.service.test.ts | 40 ++++-- 18 files changed, 318 insertions(+), 160 deletions(-) diff --git a/packages/domain/src/entitlements/v1.ts b/packages/domain/src/entitlements/v1.ts index 1f3a9d54..cdf7cb8f 100644 --- a/packages/domain/src/entitlements/v1.ts +++ b/packages/domain/src/entitlements/v1.ts @@ -288,7 +288,11 @@ export function createEntitlementLeaseV1( const issuedAt = timestamp(input.issuedAt); const expiresAt = timestamp(input.expiresAt); const snapshotScope: TenantScopeV1 = snapshot.workspaceId - ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization', organizationId: snapshot.organizationId }; if (!leaseId) return rejected('INVALID_IDENTIFIER'); if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); @@ -632,10 +636,7 @@ export function acceptEntitlementLeaseV1( signatureValid = false; } if (!signatureValid) return rejected('LEASE_INVALID'); - if ( - Date.parse(now) < Date.parse(issuedAt) || - Date.parse(now) >= Date.parse(expiresAt) - ) + if (Date.parse(now) < Date.parse(issuedAt) || Date.parse(now) >= Date.parse(expiresAt)) return rejected('LEASE_INVALID'); return Object.freeze({ accepted: true, value: true }); } diff --git a/packages/domain/src/service-account/v1.ts b/packages/domain/src/service-account/v1.ts index 74fd33b2..40ae1d92 100644 --- a/packages/domain/src/service-account/v1.ts +++ b/packages/domain/src/service-account/v1.ts @@ -81,10 +81,7 @@ function positiveInteger(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; } -function lifetimeWithin( - issuedAt: StrictUtcTimestampV1, - expiresAt: StrictUtcTimestampV1, -): boolean { +function lifetimeWithin(issuedAt: StrictUtcTimestampV1, expiresAt: StrictUtcTimestampV1): boolean { const issued = Date.parse(issuedAt); const expires = Date.parse(expiresAt); return ( @@ -96,9 +93,15 @@ function lifetimeWithin( } function permissions(input: unknown): readonly PermissionV1[] | undefined { - if (!Array.isArray(input) || input.length === 0 || input.length > SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1) + if ( + !Array.isArray(input) || + input.length === 0 || + input.length > SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1 + ) return undefined; - const values = input.filter((permission): permission is PermissionV1 => isPermissionV1(permission)); + const values = input.filter((permission): permission is PermissionV1 => + isPermissionV1(permission), + ); if (values.length !== input.length) return undefined; return Object.freeze([...new Set(values)]); } @@ -138,8 +141,7 @@ export function createServiceAccountV1(input: { if (!permissionValues) return rejected('INVALID_PERMISSION'); if (!secretDigest) return rejected('INVALID_DIGEST'); if (!secretIssuedAt || !createdAt) return rejected('INVALID_TIMESTAMP'); - if (input.secretExpiresAt !== undefined && !secretExpiresAt) - return rejected('INVALID_TIMESTAMP'); + if (input.secretExpiresAt !== undefined && !secretExpiresAt) return rejected('INVALID_TIMESTAMP'); if (!validSecretWindow(secretIssuedAt, secretExpiresAt)) return rejected('INVALID_LIFETIME'); return accepted( Object.freeze({ @@ -175,11 +177,14 @@ export function rotateServiceAccountSecretV1( const expiresAt = input.expiresAt === undefined ? undefined : timestamp(input.expiresAt); const expectedRevision = positiveInteger(input.expectedRevision); if (!secretDigest) return rejected('INVALID_DIGEST'); - if (!issuedAt || (input.expiresAt !== undefined && !expiresAt)) return rejected('INVALID_TIMESTAMP'); - if (!expectedRevision || expectedRevision !== current.revision) return rejected('REVISION_CONFLICT'); + if (!issuedAt || (input.expiresAt !== undefined && !expiresAt)) + return rejected('INVALID_TIMESTAMP'); + if (!expectedRevision || expectedRevision !== current.revision) + return rejected('REVISION_CONFLICT'); if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); if (!validSecretWindow(issuedAt, expiresAt)) return rejected('INVALID_LIFETIME'); - if (Date.parse(issuedAt) < Date.parse(current.secretIssuedAt)) return rejected('INVALID_TIMESTAMP'); + if (Date.parse(issuedAt) < Date.parse(current.secretIssuedAt)) + return rejected('INVALID_TIMESTAMP'); if (expiresAt === undefined) { const { secretExpiresAt: _previousExpiry, ...withoutExpiry } = current; return accepted( @@ -235,7 +240,8 @@ export function revokeServiceAccountV1( const revokedAt = timestamp(revokedAtInput); const expectedRevision = positiveInteger(expectedRevisionInput); if (!revokedAt) return rejected('INVALID_TIMESTAMP'); - if (!expectedRevision || expectedRevision !== current.revision) return rejected('REVISION_CONFLICT'); + if (!expectedRevision || expectedRevision !== current.revision) + return rejected('REVISION_CONFLICT'); if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); if (Date.parse(revokedAt) < Date.parse(current.createdAt)) return rejected('INVALID_TIMESTAMP'); return accepted( diff --git a/packages/domain/test/audit-seal-attestation-v1.test.mjs b/packages/domain/test/audit-seal-attestation-v1.test.mjs index b2fe1ed2..ddb9d190 100644 --- a/packages/domain/test/audit-seal-attestation-v1.test.mjs +++ b/packages/domain/test/audit-seal-attestation-v1.test.mjs @@ -38,7 +38,10 @@ void test('[AUD-015, AUD-016] attestations bind an immutable seal range and sign const sealResult = createAuditSealV1([event()], scope, '2026-01-01T00:01:00.000Z', digest); assert.equal(sealResult.accepted, true); if (!sealResult.accepted) return; - const signer = { sign: (payload) => `sig:${payload}`, verify: (payload, signature) => signature === `sig:${payload}` }; + const signer = { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }; const attestation = createAuditSealAttestationV1( sealResult.value, { attestationId: '00000000-0000-4000-8000-000000000746', signerKeyId: 'audit-key-1' }, @@ -52,7 +55,10 @@ void test('[AUD-015, AUD-016] attestations bind an immutable seal range and sign }); assert.deepEqual( verifyAuditSealAttestationV1( - { ...attestation.value, tenantScope: { ...scope, organizationId: '00000000-0000-4000-8000-000000000747' } }, + { + ...attestation.value, + tenantScope: { ...scope, organizationId: '00000000-0000-4000-8000-000000000747' }, + }, sealResult.value, signer, ), diff --git a/packages/domain/test/audit-service-account-actions-v1.test.mjs b/packages/domain/test/audit-service-account-actions-v1.test.mjs index b54d0169..5a1d3d59 100644 --- a/packages/domain/test/audit-service-account-actions-v1.test.mjs +++ b/packages/domain/test/audit-service-account-actions-v1.test.mjs @@ -5,8 +5,8 @@ import * as audit from '../dist/audit/v1.js'; void test('[IAM-013, AUD-002] service-account lifecycle actions are part of the closed audit vocabulary', () => { assert.deepEqual( - ['service_account.created', 'service_account.rotated', 'service_account.revoked'].map((action) => - audit.AUDIT_ACTIONS_V1.includes(action), + ['service_account.created', 'service_account.rotated', 'service_account.revoked'].map( + (action) => audit.AUDIT_ACTIONS_V1.includes(action), ), [true, true, true], ); diff --git a/packages/domain/test/service-account-v1.test.mjs b/packages/domain/test/service-account-v1.test.mjs index b8302e8c..83fb06ac 100644 --- a/packages/domain/test/service-account-v1.test.mjs +++ b/packages/domain/test/service-account-v1.test.mjs @@ -82,9 +82,7 @@ void test('[IAM-013] secret rotation requires the current revision and increment }); void test('[IAM-013] last-use is monotonic and unusable secrets fail closed', () => { - const created = createServiceAccountV1( - input({ secretExpiresAt: '2026-08-03T01:00:00.000Z' }), - ); + const created = createServiceAccountV1(input({ secretExpiresAt: '2026-08-03T01:00:00.000Z' })); assert.equal(created.accepted, true); if (!created.accepted) return; const used = markServiceAccountUsedV1(created.value, '2026-08-03T00:10:00.000Z'); 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 5043a291..252553d1 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 @@ -1,7 +1,4 @@ -import { - tenantScopeContainsV1, - type EntitlementLeaseV1, -} from '@databreeze/domain/v1'; +import { tenantScopeContainsV1, type EntitlementLeaseV1 } from '@databreeze/domain/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { @@ -38,7 +35,9 @@ export class InMemoryEntitlementLeaseRepositoryAdapter implements EntitlementLea ): Promise { await Promise.resolve(); const lease = this.leases.get(leaseId); - return lease && (tenantScopeContainsV1(context.tenantScope, leaseScope(lease)) || tenantScopeContainsV1(leaseScope(lease), context.tenantScope)) + return lease && + (tenantScopeContainsV1(context.tenantScope, leaseScope(lease)) || + tenantScopeContainsV1(leaseScope(lease), context.tenantScope)) ? clone(lease) : undefined; } @@ -55,7 +54,10 @@ export class InMemoryEntitlementLeaseRepositoryAdapter implements EntitlementLea await previous; const before = new Map(this.leases); try { - return await work({ saveLease: this.saveLease.bind(this), findLease: this.findLease.bind(this) }); + return await work({ + saveLease: this.saveLease.bind(this), + findLease: this.findLease.bind(this), + }); } catch (error) { this.leases = before; throw error; 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 1fb19b1e..55bd76eb 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,7 +1,4 @@ -import { - tenantScopeContainsV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeContainsV1, 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'; @@ -22,7 +19,10 @@ function accountScope(account: ServiceAccountV1): TenantScopeV1 { function visibleInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { const scope = accountScope(account); - return tenantScopeContainsV1(context.tenantScope, scope) || tenantScopeContainsV1(scope, context.tenantScope); + return ( + tenantScopeContainsV1(context.tenantScope, scope) || + tenantScopeContainsV1(scope, context.tenantScope) + ); } function writableInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { @@ -76,7 +76,8 @@ export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRe if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); const existing = this.accounts.get(account.id); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(account)) throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); + if (JSON.stringify(existing) !== JSON.stringify(account)) + throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); return; } const duplicateDigest = [...this.accounts.values()].find( 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 4d8d7945..d652a262 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 @@ -250,7 +250,10 @@ export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepo } public saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1) { - return new PrismaServiceAccountTransactionAdapter(this.client).saveServiceAccount(context, account); + return new PrismaServiceAccountTransactionAdapter(this.client).saveServiceAccount( + context, + account, + ); } public findServiceAccount(context: IamTenantContextV1, serviceAccountId: StableIdentifierV1) { 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 8cd41695..26994555 100644 --- a/services/api/src/features/iam/api/service-account.controller.ts +++ b/services/api/src/features/iam/api/service-account.controller.ts @@ -41,18 +41,21 @@ export class ServiceAccountController { throw new ServiceAccountProblemError('SERVICE_ACCOUNT_NOT_FOUND'); if (result.code === 'CONFLICT') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_CONFLICT'); - if (result.code === 'REVOKED') - throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REVOKED'); - if (result.code === 'EXPIRED') - throw new ServiceAccountProblemError('SERVICE_ACCOUNT_EXPIRED'); + if (result.code === 'REVOKED') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REVOKED'); + if (result.code === 'EXPIRED') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_EXPIRED'); if (result.code === 'UNAVAILABLE') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_UNAVAILABLE'); throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REQUEST_REJECTED'); } @Get('organizations/:organizationId/service-accounts') - @ApiOperation({ summary: 'List content-free service-account identities in an organization scope' }) - async list(@Req() request: unknown, @Param('organizationId') organizationId: string): Promise { + @ApiOperation({ + summary: 'List content-free service-account identities in an organization scope', + }) + async list( + @Req() request: unknown, + @Param('organizationId') organizationId: string, + ): Promise { const context = await this.requestContext.resolve(request); const parsed = parseStableIdentifierV1(organizationId); if (!parsed.accepted || parsed.value !== context.tenantScope.organizationId) @@ -62,7 +65,9 @@ export class ServiceAccountController { @Post('service-accounts') @HttpCode(201) - @ApiOperation({ summary: 'Create an action-scoped service account and return its one-time secret' }) + @ApiOperation({ + summary: 'Create an action-scoped service account and return its one-time secret', + }) @ApiBody({ type: CreateServiceAccountDto }) async create( @Req() request: unknown, @@ -84,7 +89,9 @@ export class ServiceAccountController { @Body() input: ServiceAccountRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.execute(() => this.serviceAccounts.rotate(context, serviceAccountId, input.expectedRevision)); + return this.execute(() => + this.serviceAccounts.rotate(context, serviceAccountId, input.expectedRevision), + ); } @Post('service-accounts/:serviceAccountId/revoke') @@ -97,6 +104,8 @@ export class ServiceAccountController { @Body() input: ServiceAccountRevisionDto, ): Promise { const context = await this.requestContext.resolve(request); - return this.execute(() => this.serviceAccounts.revoke(context, serviceAccountId, input.expectedRevision)); + return this.execute(() => + this.serviceAccounts.revoke(context, serviceAccountId, input.expectedRevision), + ); } } diff --git a/services/api/src/features/iam/api/service-account.dto.ts b/services/api/src/features/iam/api/service-account.dto.ts index 538fb8b8..0f0fd655 100644 --- a/services/api/src/features/iam/api/service-account.dto.ts +++ b/services/api/src/features/iam/api/service-account.dto.ts @@ -21,7 +21,10 @@ export class CreateServiceAccountDto { @MaxLength(200) name!: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Optional workspace narrowing for the identity' }) + @ApiPropertyOptional({ + format: 'uuid', + description: 'Optional workspace narrowing for the identity', + }) @IsOptional() @IsUUID() workspaceId?: string; @@ -33,7 +36,10 @@ export class CreateServiceAccountDto { @IsString({ each: true }) permissions!: string[]; - @ApiPropertyOptional({ format: 'date-time', description: 'Optional expiry, at most 365 days after issue' }) + @ApiPropertyOptional({ + format: 'date-time', + description: 'Optional expiry, at most 365 days after issue', + }) @IsOptional() @IsISO8601() secretExpiresAt?: string; 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 6a319069..26009f35 100644 --- a/services/api/src/features/iam/application/service-account.service.ts +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -107,13 +107,22 @@ function mapRepositoryError(error: unknown): ServiceAccountApplicationCodeV1 { const message = error instanceof Error ? error.message : ''; if (message === 'SCOPE_DENIED') return 'SCOPE_DENIED'; if (message === 'SERVICE_ACCOUNT_NOT_FOUND') return 'NOT_FOUND'; - if (message === 'REVISION_CONFLICT' || message === 'INVALID_REVISION' || message.endsWith('CONFLICT')) + if ( + message === 'REVISION_CONFLICT' || + message === 'INVALID_REVISION' || + message.endsWith('CONFLICT') + ) return 'CONFLICT'; return 'UNAVAILABLE'; } function digestSecret(input: unknown): string | undefined { - if (typeof input !== 'string' || input.length === 0 || input.length > 512 || /\p{Cc}/u.test(input)) + if ( + typeof input !== 'string' || + input.length === 0 || + input.length > 512 || + /\p{Cc}/u.test(input) + ) return undefined; return createHash('sha256').update(input, 'utf8').digest('hex'); } @@ -182,13 +191,16 @@ export class ServiceAccountService { context: IamTenantContextV1, input: CreateServiceAccountInputV1, ): Promise> { - const workspaceId = - input.workspaceId === undefined ? undefined : identifier(input.workspaceId); + const workspaceId = input.workspaceId === undefined ? undefined : identifier(input.workspaceId); if (input.workspaceId !== undefined && workspaceId === undefined) return rejected('INVALID_IDENTIFIER'); const targetScope = scopeForAccount(context, workspaceId); if (!targetScope) return rejected('SCOPE_DENIED'); - const authorization = await this.authorize(context, targetScope, PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE); + const authorization = await this.authorize( + context, + targetScope, + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + ); if (authorization !== 'ALLOWED') return rejected(authorization); if (!serviceAccountPermissions(input.permissions)) return rejected('INVALID_INPUT'); let now: string; @@ -279,37 +291,41 @@ export class ServiceAccountService { expectedRevisionInput < 1 ) return rejected('CONFLICT'); - return this.repository.withTransaction(context, async (transaction) => { - const current = await transaction.findServiceAccount(context, serviceAccountId); - if (!current) return rejected('NOT_FOUND'); - const authorization = await this.authorize( - context, - accountScope(current), - PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, - ); - if (authorization !== 'ALLOWED') return rejected(authorization); - let now: string; - let secret: ServiceAccountSecretIssueV1; - try { - now = this.clock().toISOString(); - secret = this.secretIssuer.issue(); - } catch { - return rejected('UNAVAILABLE'); - } - const rotated = rotateServiceAccountSecretV1(current, { - secretDigest: secret.digest, - issuedAt: now, - ...(secretExpiresAt === undefined ? {} : { expiresAt: secretExpiresAt }), - expectedRevision: expectedRevisionInput, - }); - if (!rotated.accepted) return rejected(mapDomainCode(rotated.code)); - try { - await transaction.replaceServiceAccount(context, rotated.value, current.revision); - return accepted(Object.freeze({ account: safeView(rotated.value), secret: secret.secret })); - } catch (error) { - return rejected(mapRepositoryError(error)); - } - }).catch((error) => rejected(mapRepositoryError(error))); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + let now: string; + let secret: ServiceAccountSecretIssueV1; + try { + now = this.clock().toISOString(); + secret = this.secretIssuer.issue(); + } catch { + return rejected('UNAVAILABLE'); + } + const rotated = rotateServiceAccountSecretV1(current, { + secretDigest: secret.digest, + issuedAt: now, + ...(secretExpiresAt === undefined ? {} : { expiresAt: secretExpiresAt }), + expectedRevision: expectedRevisionInput, + }); + if (!rotated.accepted) return rejected(mapDomainCode(rotated.code)); + try { + await transaction.replaceServiceAccount(context, rotated.value, current.revision); + return accepted( + Object.freeze({ account: safeView(rotated.value), secret: secret.secret }), + ); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); } public async revoke( @@ -325,26 +341,28 @@ export class ServiceAccountService { expectedRevisionInput < 1 ) return rejected('CONFLICT'); - return this.repository.withTransaction(context, async (transaction) => { - const current = await transaction.findServiceAccount(context, serviceAccountId); - if (!current) return rejected('NOT_FOUND'); - const authorization = await this.authorize( - context, - accountScope(current), - PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, - ); - if (authorization !== 'ALLOWED') return rejected(authorization); - const now = this.now(); - if (!now) return rejected('UNAVAILABLE'); - const revoked = revokeServiceAccountV1(current, now, expectedRevisionInput); - if (!revoked.accepted) return rejected(mapDomainCode(revoked.code)); - try { - await transaction.replaceServiceAccount(context, revoked.value, current.revision); - return accepted(safeView(revoked.value)); - } catch (error) { - return rejected(mapRepositoryError(error)); - } - }).catch((error) => rejected(mapRepositoryError(error))); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); + const revoked = revokeServiceAccountV1(current, now, expectedRevisionInput); + if (!revoked.accepted) return rejected(mapDomainCode(revoked.code)); + try { + await transaction.replaceServiceAccount(context, revoked.value, current.revision); + return accepted(safeView(revoked.value)); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); } public validateSecret( diff --git a/services/api/test/features/bua/entitlement-lease-repository.test.ts b/services/api/test/features/bua/entitlement-lease-repository.test.ts index de132989..9a50695e 100644 --- a/services/api/test/features/bua/entitlement-lease-repository.test.ts +++ b/services/api/test/features/bua/entitlement-lease-repository.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createEntitlementLeaseV1, createEntitlementSnapshotV1, createPlanV1 } from '@databreeze/domain/entitlements/v1'; +import { + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, +} from '@databreeze/domain/entitlements/v1'; import { InMemoryEntitlementLeaseRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; @@ -23,13 +27,30 @@ function context(scope = { scopeType: 'organization', organizationId }) { } function lease() { - const plan = createPlanV1({ planCode: 'free', displayNameKey: 'plan.free', features: [], quotas: [{ metric: 'job_count', limit: 1 }] }); + const plan = createPlanV1({ + planCode: 'free', + displayNameKey: 'plan.free', + features: [], + quotas: [{ metric: 'job_count', limit: 1 }], + }); assert.equal(plan.accepted, true); if (!plan.accepted) throw new Error('invalid plan'); - const snapshot = createEntitlementSnapshotV1({ snapshotId: '00000000-0000-4000-8000-000000000765', tenantScope: { scopeType: 'organization', organizationId }, plan: plan.value, status: 'ACTIVE', revision: 1, securityEpoch: 1, effectiveAt: '2026-01-01T00:00:00.000Z' }); + const snapshot = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000765', + tenantScope: { scopeType: 'organization', organizationId }, + plan: plan.value, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); assert.equal(snapshot.accepted, true); if (!snapshot.accepted) throw new Error('invalid snapshot'); - const issued = createEntitlementLeaseV1(snapshot.value, { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, { sign: (payload) => payload }); + const issued = createEntitlementLeaseV1( + snapshot.value, + { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, + { sign: (payload) => payload }, + ); assert.equal(issued.accepted, true); if (!issued.accepted) throw new Error('invalid lease'); return issued.value; @@ -39,6 +60,18 @@ void test('[BUA-017, BUA-018] in-memory lease persistence is immutable and scope const repository = new InMemoryEntitlementLeaseRepositoryAdapter(); await repository.saveLease(context(), lease()); assert.equal((await repository.findLease(context(), lease().leaseId))?.leaseId, leaseId); - assert.equal(await repository.findLease(context({ scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000799' }), lease().leaseId), undefined); - await assert.rejects(repository.saveLease(context(), { ...lease(), signature: 'changed' }), /BUA_IMMUTABLE_LEASE/u); + assert.equal( + await repository.findLease( + context({ + scopeType: 'organization', + organizationId: '00000000-0000-4000-8000-000000000799', + }), + lease().leaseId, + ), + undefined, + ); + await assert.rejects( + repository.saveLease(context(), { ...lease(), signature: 'changed' }), + /BUA_IMMUTABLE_LEASE/u, + ); }); 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 5fff1b1f..0e8690cd 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 @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createServiceAccountV1, type ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import { + createServiceAccountV1, + type ServiceAccountV1, +} from '@databreeze/domain/service-account/v1'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import { @@ -148,26 +151,50 @@ function client( void test('[IAM-013] Prisma service-account adapter persists and filters workspace scope', async () => { const rows: Record[] = []; const repository = new PrismaServiceAccountRepositoryAdapter(client(rows)); - await repository.saveServiceAccount(context({ scopeType: 'organization', organizationId }), account()); + await repository.saveServiceAccount( + context({ scopeType: 'organization', organizationId }), + account(), + ); assert.equal( - (await repository.findServiceAccount(context({ scopeType: 'workspace', organizationId, workspaceId }), stable(accountId)))?.name, + ( + await repository.findServiceAccount( + context({ scopeType: 'workspace', organizationId, workspaceId }), + stable(accountId), + ) + )?.name, 'Import worker', ); assert.equal( - (await repository.findServiceAccount(context({ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }), stable(accountId))), + await repository.findServiceAccount( + context({ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }), + stable(accountId), + ), undefined, ); assert.equal( - (await repository.findServiceAccountByDigest(context({ scopeType: 'organization', organizationId }), 'a'.repeat(64)))?.id, + ( + await repository.findServiceAccountByDigest( + context({ scopeType: 'organization', organizationId }), + 'a'.repeat(64), + ) + )?.id, stable(accountId), ); - assert.equal((await repository.listServiceAccounts(context({ scopeType: 'organization', organizationId }))).length, 1); + assert.equal( + (await repository.listServiceAccounts(context({ scopeType: 'organization', organizationId }))) + .length, + 1, + ); }); 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 }); - await repository.replaceServiceAccount(context({ scopeType: 'organization', organizationId }), next, 1); + await repository.replaceServiceAccount( + context({ scopeType: 'organization', organizationId }), + next, + 1, + ); await assert.rejects( new PrismaServiceAccountRepositoryAdapter(client([rowFor()], true)).replaceServiceAccount( context({ scopeType: 'organization', organizationId }), @@ -181,11 +208,12 @@ void test('[IAM-013] Prisma service-account adapter uses optimistic revisions an void test('[IAM-013] Prisma service-account adapter fails closed on malformed persisted state', async () => { const malformed = rowFor(); malformed['secretDigest'] = 'not-a-digest'; - const repository = new PrismaServiceAccountRepositoryAdapter( - client([malformed]), - ); + const repository = new PrismaServiceAccountRepositoryAdapter(client([malformed])); await assert.rejects( - repository.findServiceAccount(context({ scopeType: 'organization', organizationId }), stable(accountId)), + repository.findServiceAccount( + context({ scopeType: 'organization', organizationId }), + stable(accountId), + ), /IAM_PERSISTED_SERVICE_ACCOUNT_INVALID/u, ); }); 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 f25a5e47..5f14c5e2 100644 --- a/services/api/test/features/iam/service-account-composition.test.ts +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -3,10 +3,11 @@ import test from 'node:test'; import { ServiceAccountController } from '../../../src/features/iam/api/service-account.controller.js'; import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { SERVICE_ACCOUNT_REPOSITORY_PORT } from '../../../src/features/iam/application/service-account-repository.port.js'; import { - SERVICE_ACCOUNT_REPOSITORY_PORT, -} from '../../../src/features/iam/application/service-account-repository.port.js'; -import { SERVICE_ACCOUNT_SERVICE, ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; + SERVICE_ACCOUNT_SERVICE, + ServiceAccountService, +} from '../../../src/features/iam/application/service-account.service.js'; import { IamModule } from '../../../src/features/iam/iam.module.js'; void test('[IAM-013] IAM composition registers a replaceable service-account repository and lifecycle service', () => { @@ -21,7 +22,11 @@ void test('[IAM-013] IAM composition registers a replaceable service-account rep assert.ok(registered.exports?.includes(SERVICE_ACCOUNT_SERVICE)); assert.ok( registered.providers?.some( - (provider) => typeof provider === 'object' && provider !== null && 'provide' in provider && provider.provide === SERVICE_ACCOUNT_SERVICE, + (provider) => + typeof provider === 'object' && + provider !== null && + 'provide' in provider && + provider.provide === SERVICE_ACCOUNT_SERVICE, ), ); }); 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 75389f09..2d4dbe8b 100644 --- a/services/api/test/features/iam/service-account-repository.test.ts +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createServiceAccountV1 } from '@databreeze/domain/service-account/v1'; -import { parseStableIdentifierV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; @@ -70,7 +73,10 @@ void test('[IAM-013] service account repository preserves tenant scope and immut undefined, ); assert.equal( - (await repository.findServiceAccount(context({ scopeType: 'organization', organizationId: otherOrganizationId }), stableAccountId)), + await repository.findServiceAccount( + context({ scopeType: 'organization', organizationId: otherOrganizationId }), + stableAccountId, + ), undefined, ); assert.equal((await repository.listServiceAccounts(organizationContext)).length, 1); @@ -81,11 +87,22 @@ void test('[IAM-013] workspace scope is visible to its parent and child context const organizationContext = context({ scopeType: 'organization', organizationId }, 'parent'); await repository.saveServiceAccount(organizationContext, account()); assert.equal( - (await repository.listServiceAccounts(context({ scopeType: 'workspace', organizationId, workspaceId }, 'child'))).length, + ( + await repository.listServiceAccounts( + context({ scopeType: 'workspace', organizationId, workspaceId }, 'child'), + ) + ).length, 1, ); assert.equal( - (await repository.listServiceAccounts(context({ scopeType: 'workspace', organizationId, workspaceId: otherOrganizationId }, 'sibling'))).length, + ( + await repository.listServiceAccounts( + context( + { scopeType: 'workspace', organizationId, workspaceId: otherOrganizationId }, + 'sibling', + ), + ) + ).length, 0, ); }); @@ -106,5 +123,8 @@ void test('[IAM-013] replacement is revision guarded and transactions roll back }), /ROLLBACK/, ); - assert.equal((await repository.findServiceAccount(organizationContext, stableAccountId))?.name, 'Import worker'); + assert.equal( + (await repository.findServiceAccount(organizationContext, stableAccountId))?.name, + 'Import worker', + ); }); diff --git a/services/api/test/features/iam/service-account-secret.adapter.test.ts b/services/api/test/features/iam/service-account-secret.adapter.test.ts index 42157895..1e64d961 100644 --- a/services/api/test/features/iam/service-account-secret.adapter.test.ts +++ b/services/api/test/features/iam/service-account-secret.adapter.test.ts @@ -9,10 +9,7 @@ void test('[IAM-013] random service-account secrets are high-entropy and digesta const issuer = new RandomServiceAccountSecretIssuer(() => Buffer.alloc(32, 7)); const issued = issuer.issue(); assert.match(issued.secret, /^dbsa_[A-Za-z0-9_-]{43}$/u); - assert.equal( - issued.digest, - createHash('sha256').update(issued.secret, 'utf8').digest('hex'), - ); + assert.equal(issued.digest, createHash('sha256').update(issued.secret, 'utf8').digest('hex')); assert.equal(issued.digest.length, 64); }); 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 5c9ad82f..8e0ef183 100644 --- a/services/api/test/features/iam/service-account.controller.test.ts +++ b/services/api/test/features/iam/service-account.controller.test.ts @@ -26,14 +26,16 @@ function context() { function controller(overrides: Record = {}) { const service = { list: () => Promise.resolve({ accepted: true as const, value: [{ id: serviceAccountId }] }), - create: () => Promise.resolve({ - accepted: true as const, - value: { account: { id: serviceAccountId }, secret: 'one-time' }, - }), - rotate: () => Promise.resolve({ - accepted: true as const, - value: { account: { id: serviceAccountId }, secret: 'successor' }, - }), + create: () => + Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'one-time' }, + }), + rotate: () => + Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'successor' }, + }), revoke: () => Promise.resolve({ accepted: true as const, value: { id: serviceAccountId } }), ...overrides, }; @@ -44,10 +46,13 @@ function controller(overrides: Record = {}) { void test('[IAM-013] controller exposes safe list/create/rotate/revoke results', async () => { 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'], - }), { account: { id: serviceAccountId }, secret: 'one-time' }); + assert.deepEqual( + await instance.create({}, 'request-key', { + name: 'Import worker', + permissions: ['artifact.record.read'], + }), + { account: { id: serviceAccountId }, secret: 'one-time' }, + ); assert.deepEqual(await instance.rotate({}, serviceAccountId, { expectedRevision: 1 }), { account: { id: serviceAccountId }, secret: 'successor', @@ -67,11 +72,9 @@ void test('[IAM-013] controller rejects a path outside the authenticated organiz void test('[IAM-013] controller maps lifecycle failures to stable problem codes', async () => { await assert.rejects( - controller({ revoke: () => Promise.resolve({ accepted: false as const, code: 'CONFLICT' as const }) }).revoke( - {}, - serviceAccountId, - { expectedRevision: 1 }, - ), + controller({ + revoke: () => Promise.resolve({ accepted: false as const, code: 'CONFLICT' as const }), + }).revoke({}, serviceAccountId, { expectedRevision: 1 }), (error: unknown) => error instanceof ServiceAccountProblemError && error.code === 'SERVICE_ACCOUNT_CONFLICT', ); 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 25d0d4b0..0184d118 100644 --- a/services/api/test/features/iam/service-account.service.test.ts +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -6,7 +6,12 @@ import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/ import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; import { ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; -import { parseStableIdentifierV1, parseTenantScopeV1, type StableIdentifierV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + parseTenantScopeV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; const organizationId = '00000000-0000-4000-8000-000000000711'; const workspaceId = '00000000-0000-4000-8000-000000000712'; @@ -41,7 +46,10 @@ function context(scope: unknown, key = 'service-account-service') { return result.value; } -function membership(roleId = 'owner', scope: unknown = { scopeType: 'organization', organizationId }) { +function membership( + roleId = 'owner', + scope: unknown = { scopeType: 'organization', organizationId }, +) { return { id: stable('00000000-0000-4000-8000-000000000717'), principalId: stable(actorId), @@ -72,10 +80,13 @@ function service() { void test('[IAM-013] authorized creation returns a one-time secret but never the persisted digest', async () => { const accountService = service(); - const result = await accountService.create(context({ scopeType: 'organization', organizationId }), { - name: 'Import worker', - permissions: ['artifact.record.read'], - }); + const result = await accountService.create( + context({ scopeType: 'organization', organizationId }), + { + name: 'Import worker', + permissions: ['artifact.record.read'], + }, + ); assert.equal(result.accepted, true); if (!result.accepted) return; assert.equal(result.value.secret, 'dbsa_first'); @@ -138,7 +149,10 @@ void test('[IAM-013] rotation is revision guarded and revocation is permanent', void test('[IAM-013] credential authentication is digest-bound, updates last use, and fails closed', async () => { const accountService = service(); - const organizationContext = context({ scopeType: 'organization', organizationId }, 'authenticate'); + const organizationContext = context( + { scopeType: 'organization', organizationId }, + 'authenticate', + ); const created = await accountService.create(organizationContext, { name: 'Auth worker', permissions: ['artifact.record.read'], @@ -154,11 +168,19 @@ void test('[IAM-013] credential authentication is digest-bound, updates last use assert.equal(authenticated.value.id, accountId); assert.equal(authenticated.value.lastUsedAt, '2026-01-01T00:01:00.000Z'); assert.deepEqual( - await accountService.authenticate(organizationContext, 'wrong-secret', '2026-01-01T00:02:00.000Z'), + await accountService.authenticate( + organizationContext, + 'wrong-secret', + '2026-01-01T00:02:00.000Z', + ), { accepted: false, code: 'INVALID_CREDENTIALS' }, ); assert.deepEqual( - await accountService.authenticate(organizationContext, 'dbsa_first', '2026-01-01T00:00:30.000Z'), + await accountService.authenticate( + organizationContext, + 'dbsa_first', + '2026-01-01T00:00:30.000Z', + ), { accepted: false, code: 'INVALID_CREDENTIALS' }, ); }); From c2442ad60f2babe5ccc6dce2daf13cb8408baf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:42:21 +0700 Subject: [PATCH 65/99] fix(iam): satisfy service account lint guard --- packages/domain/src/service-account/v1.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/domain/src/service-account/v1.ts b/packages/domain/src/service-account/v1.ts index 40ae1d92..bfe9fb59 100644 --- a/packages/domain/src/service-account/v1.ts +++ b/packages/domain/src/service-account/v1.ts @@ -187,6 +187,7 @@ export function rotateServiceAccountSecretV1( return rejected('INVALID_TIMESTAMP'); if (expiresAt === undefined) { const { secretExpiresAt: _previousExpiry, ...withoutExpiry } = current; + void _previousExpiry; return accepted( Object.freeze({ ...withoutExpiry, From 20ceddd02fbde719519355137ca2761e9b5a3583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 02:51:03 +0700 Subject: [PATCH 66/99] test(api): track security migration and route contracts --- services/api/test/openapi.test.ts | 8 ++++++++ services/api/test/prisma-foundation.test.mjs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 2d5c2efb..d94a81f1 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -124,6 +124,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/inbox', '/v1/artifacts/inbox/{inboxItemId}', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/audit/attestations', + '/v1/audit/attestations/{attestationId}/verify', '/v1/audit/events', '/v1/audit/seals', '/v1/auth/me', @@ -173,7 +175,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/grants', '/v1/devices/{deviceId}/key', '/v1/devices/{deviceId}/revoke', + '/v1/entitlements/leases/{leaseId}/verify', '/v1/entitlements/snapshots/{snapshotId}', + '/v1/entitlements/snapshots/{snapshotId}/leases', '/v1/entitlements/usage', '/v1/invitations', '/v1/invitations/accept', @@ -184,6 +188,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/memberships/{membershipId}/transition', '/v1/organizations/{organizationId}', '/v1/organizations/{organizationId}/devices', + '/v1/organizations/{organizationId}/service-accounts', '/v1/organizations/{organizationId}/workspaces', '/v1/projects/{projectId}', '/v1/protected-document-unlocks', @@ -196,6 +201,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', '/v1/reference-entities/{entityId}/versions/{versionId}', + '/v1/service-accounts', + '/v1/service-accounts/{serviceAccountId}/revoke', + '/v1/service-accounts/{serviceAccountId}/rotate', '/v1/spreadsheet-audits', '/v1/spreadsheet-audits/{auditId}', '/v1/system/compatibility', diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index f9d65061..816b3536 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -87,6 +87,9 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); 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 "bua"\."entitlement_leases"/); + assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_seal_attestations"/); const migrationsDirectory = path.join(apiDirectory, 'prisma', 'migrations'); const inventory = (await readdir(migrationsDirectory)).sort(); @@ -129,6 +132,9 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803030000_iam_membership_scope_uniqueness', '20260803040000_iam_invitation_tokens', '20260803050000_iam_recovery_challenges', + '20260803060000_iam_service_accounts', + '20260803070000_bua_entitlement_leases', + '20260803080000_aud_seal_attestations', 'migration_lock.toml', ]); const migration = await readFile( From df4d0a2296b65e857e212b350b5a2aa471f32b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 03:00:00 +0700 Subject: [PATCH 67/99] fix(deps): pin patched fast-uri release --- pnpm-lock.yaml | 11 ++++++----- pnpm-workspace.yaml | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43cd5809..26526faf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@fastify/ajv-compiler>fast-uri': 3.1.5 find-my-way: 9.7.0 js-yaml: 5.2.2 react-router: 8.3.0 @@ -1896,8 +1897,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-uri@4.1.2: resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} @@ -3309,7 +3310,7 @@ snapshots: dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.4 + fast-uri: 3.1.5 '@fastify/cors@11.2.0': dependencies: @@ -4195,7 +4196,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4579,7 +4580,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fast-uri@4.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 35d4c1a0..e13b0d5b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ engineStrict: true pmOnFail: download overrides: + '@fastify/ajv-compiler>fast-uri': 3.1.5 find-my-way: 9.7.0 js-yaml: 5.2.2 react-router: 8.3.0 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 68/99] 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 69/99] 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 70/99] 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 71/99] 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 72/99] 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 73/99] 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 74/99] 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 75/99] 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 76/99] 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 77/99] 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 78/99] 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 79/99] 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 80/99] 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 81/99] 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 82/99] 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 83/99] 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 84/99] 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 85/99] 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 86/99] 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 87/99] 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 88/99] 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 89/99] 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 90/99] 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 91/99] 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 92/99] 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 93/99] 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 94/99] 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 95/99] 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 96/99] 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 97/99] 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 98/99] 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 99/99] 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; },