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 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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 14/36] 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 15/36] 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 16/36] 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 17/36] 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 18/36] 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 19/36] 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 20/36] 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 21/36] 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 22/36] 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 23/36] 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 24/36] 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 25/36] 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 26/36] 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 27/36] 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 28/36] 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 29/36] 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 30/36] 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 31/36] 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 32/36] 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 33/36] 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 34/36] 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 35/36] 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 36/36] 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