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..181d039e 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -1085,15 +1085,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 +1115,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 +1772,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 +1802,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 +9394,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" 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/audit/v1.ts b/packages/domain/src/audit/v1.ts index ba3a4ce6..be01131c 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', @@ -99,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' @@ -185,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)) @@ -353,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/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 { + 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'); + 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 = normalizedPlan.value; + 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(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)) + ) + 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; @@ -465,20 +592,51 @@ 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 (lease.snapshotRevision !== snapshotRevision || lease.securityEpoch !== securityEpoch) - return rejected('LEASE_STALE'); - if (!verifier.verify(lease.payload, lease.signature)) 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 + !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'); + 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(issuedAt) || Date.parse(now) >= Date.parse(expiresAt)) + return rejected('LEASE_INVALID'); return Object.freeze({ accepted: true, value: true }); } diff --git a/packages/domain/src/permissions/v1.ts b/packages/domain/src/permissions/v1.ts index 6ee72030..e5b181bd 100644 --- a/packages/domain/src/permissions/v1.ts +++ b/packages/domain/src/permissions/v1.ts @@ -27,6 +27,9 @@ export const PERMISSIONS_V1 = Object.freeze({ BILLING_ACCOUNT_MANAGE: 'billing.account.manage', DEVICE_IDENTITY_READ: 'device.identity.read', DEVICE_IDENTITY_REVOKE: 'device.identity.revoke', + SERVICE_ACCOUNT_READ: 'service.account.read', + SERVICE_ACCOUNT_MANAGE: 'service.account.manage', + SERVICE_ACCOUNT_REVOKE: 'service.account.revoke', } as const); export type PermissionV1 = (typeof PERMISSIONS_V1)[keyof typeof PERMISSIONS_V1]; @@ -49,6 +52,7 @@ export const RESOURCE_TYPES_V1 = Object.freeze([ 'artifact', 'billing-account', 'device', + 'service-account', 'job', 'organization', 'project', @@ -147,6 +151,9 @@ export const PERMISSION_APPLICABILITY_V1: Readonly< 'billing.account.manage': immutableApplicability('billing-account', ['api', 'web']), 'device.identity.read': immutableApplicability('device', ['api', 'web']), 'device.identity.revoke': immutableApplicability('device', ['api', 'web']), + 'service.account.read': immutableApplicability('service-account', ['api', 'web']), + 'service.account.manage': immutableApplicability('service-account', ['api', 'web']), + 'service.account.revoke': immutableApplicability('service-account', ['api', 'web']), }); export const INITIAL_ROLE_IDS_V1 = Object.freeze([ @@ -190,6 +197,9 @@ const adminPermissions = [ PERMISSIONS_V1.JOB_EXECUTION_READ, PERMISSIONS_V1.DEVICE_IDENTITY_READ, PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, + PERMISSIONS_V1.SERVICE_ACCOUNT_READ, + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, ] as const; const ownerPermissionSet = new Set([ diff --git a/packages/domain/src/service-account/v1.ts b/packages/domain/src/service-account/v1.ts new file mode 100644 index 00000000..bfe9fb59 --- /dev/null +++ b/packages/domain/src/service-account/v1.ts @@ -0,0 +1,268 @@ +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; + void _previousExpiry; + 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/audit-seal-attestation-v1.test.mjs b/packages/domain/test/audit-seal-attestation-v1.test.mjs new file mode 100644 index 00000000..ddb9d190 --- /dev/null +++ b/packages/domain/test/audit-seal-attestation-v1.test.mjs @@ -0,0 +1,67 @@ +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' }, + ); +}); 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..5a1d3d59 --- /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], + ); +}); 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/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs new file mode 100644 index 00000000..49121ee9 --- /dev/null +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -0,0 +1,155 @@ +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' }, + ); + 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' }, + ); +}); + +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' }, + ); +}); 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( 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', 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..83fb06ac --- /dev/null +++ b/packages/domain/test/service-account-v1.test.mjs @@ -0,0 +1,113 @@ +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', + }); +}); 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 diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 80a4bcb4..67792751 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3397,10 +3397,16 @@ "tags": ["identity"] } }, - "/v1/artifacts/inbox": { - "post": { - "operationId": "InboxController.create", + "/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", @@ -3409,14 +3415,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -3467,11 +3467,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register a content-free artifact intake item", - "tags": ["artifacts"] - }, - "get": { - "operationId": "InboxController.list", + "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", @@ -3481,8 +3483,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateServiceAccountDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3533,20 +3543,18 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free artifact intake items visible to the caller", - "tags": ["artifacts"] + "summary": "Create an action-scoped service account and return its one-time secret", + "tags": ["service-accounts"] } }, - "/v1/artifacts/inbox/{inboxItemId}": { - "patch": { - "operationId": "InboxController.updateMetadata", + "/v1/service-accounts/{serviceAccountId}/rotate": { + "post": { + "operationId": "ServiceAccountController.rotate", "parameters": [ - { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, { - "name": "If-Match", - "in": "header", - "description": "Expected inbox revision, for example 3 or \"3\".", - "required": false, + "name": "serviceAccountId", + "required": true, + "in": "path", "schema": { "type": "string" } }, { @@ -3561,7 +3569,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateInboxMetadataDto" } + "schema": { "$ref": "#/components/schemas/ServiceAccountRevisionDto" } } } }, @@ -3617,16 +3625,20 @@ } }, "security": [{ "bearer": [] }], - "summary": "Update revisioned, content-free inbox triage metadata", - "tags": ["artifacts"] + "summary": "Rotate a service-account secret and return the successor once", + "tags": ["service-accounts"] } }, - "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { + "/v1/service-accounts/{serviceAccountId}/revoke": { "post": { - "operationId": "EvidenceGrantController.issue", + "operationId": "ServiceAccountController.revoke", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "serviceAccountId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -3639,12 +3651,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } + "schema": { "$ref": "#/components/schemas/ServiceAccountRevisionDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -3695,15 +3707,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a short-lived exact-evidence access grant", - "tags": ["artifacts"] + "summary": "Permanently revoke a service-account identity", + "tags": ["service-accounts"] } }, - "/v1/artifacts/evidence-grants/{grantId}": { - "delete": { - "operationId": "EvidenceGrantController.revoke", + "/v1/artifacts/inbox": { + "post": { + "operationId": "InboxController.create", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3712,8 +3723,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3764,15 +3781,12 @@ } }, "security": [{ "bearer": [] }], - "summary": "Revoke an evidence access grant", + "summary": "Register a content-free artifact intake item", "tags": ["artifacts"] - } - }, - "/v1/artifact-versions/{versionId}": { + }, "get": { - "operationId": "ArtifactReadController.get", + "operationId": "InboxController.list", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3833,15 +3847,22 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read immutable artifact-version metadata and placements", + "summary": "List content-free artifact intake items visible to the caller", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/evidence": { - "get": { - "operationId": "ArtifactReadController.evidence", + "/v1/artifacts/inbox/{inboxItemId}": { + "patch": { + "operationId": "InboxController.updateMetadata", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "If-Match", + "in": "header", + "description": "Expected inbox revision, for example 3 or \"3\".", + "required": false, + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -3850,6 +3871,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateInboxMetadataDto" } + } + } + }, "responses": { "200": { "description": "", @@ -3902,13 +3931,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List typed evidence references for one immutable version", + "summary": "Update revisioned, content-free inbox triage metadata", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve": { - "get": { - "operationId": "ArtifactReadController.resolveEvidence", + "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { + "post": { + "operationId": "EvidenceGrantController.issue", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, @@ -3920,8 +3949,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3972,15 +4009,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Resolve one exact evidence reference to a safe opaque action", + "summary": "Issue a short-lived exact-evidence access grant", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/lineage": { - "get": { - "operationId": "ArtifactLineageController.forDerived", + "/v1/artifacts/evidence-grants/{grantId}": { + "delete": { + "operationId": "EvidenceGrantController.revoke", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4041,13 +4078,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read lineage for an exact derived artifact version", + "summary": "Revoke an evidence access grant", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/derived-lineage": { + "/v1/artifact-versions/{versionId}": { "get": { - "operationId": "ArtifactLineageController.forSource", + "operationId": "ArtifactReadController.get", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -4110,16 +4147,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List derived versions that use an exact source version", + "summary": "Read immutable artifact-version metadata and placements", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/placements/{placementId}": { - "patch": { - "operationId": "ContentPlacementController.update", + "/v1/artifact-versions/{versionId}/evidence": { + "get": { + "operationId": "ArtifactReadController.evidence", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "placementId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4128,14 +4164,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateContentPlacementDto" } - } - } - }, "responses": { "200": { "description": "", @@ -4188,15 +4216,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Update verified placement availability with a revision precondition", + "summary": "List typed evidence references for one immutable version", "tags": ["artifacts"] } }, - "/v1/artifact-deletion-requests/{requestId}": { + "/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve": { "get": { - "operationId": "ArtifactRetentionController.find", + "operationId": "ArtifactReadController.resolveEvidence", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4257,13 +4286,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one governed artifact deletion request", + "summary": "Resolve one exact evidence reference to a safe opaque action", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/deletion-requests": { - "post": { - "operationId": "ArtifactRetentionController.request", + "/v1/artifact-versions/{versionId}/lineage": { + "get": { + "operationId": "ArtifactLineageController.forDerived", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -4274,16 +4303,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactDeletionRequestDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4334,15 +4355,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Request governed deletion of an exact artifact version", + "summary": "Read lineage for an exact derived artifact version", "tags": ["artifacts"] } }, - "/v1/artifact-deletion-requests/{requestId}/authorize": { - "post": { - "operationId": "ArtifactRetentionController.authorize", + "/v1/artifact-versions/{versionId}/derived-lineage": { + "get": { + "operationId": "ArtifactLineageController.forSource", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4351,16 +4372,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AuthorizeArtifactDeletionRequestDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4411,14 +4424,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Authorize an eligible deletion request after MFA step-up", + "summary": "List derived versions that use an exact source version", "tags": ["artifacts"] } }, - "/v1/artifacts/exports": { - "post": { - "operationId": "ArtifactExportController.create", + "/v1/artifact-versions/{versionId}/placements/{placementId}": { + "patch": { + "operationId": "ContentPlacementController.update", "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "placementId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4431,12 +4446,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactExportDto" } + "schema": { "$ref": "#/components/schemas/UpdateContentPlacementDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4487,15 +4502,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable artifact verification manifest", + "summary": "Update verified placement availability with a revision precondition", "tags": ["artifacts"] } }, - "/v1/artifacts/exports/{manifestId}": { + "/v1/artifact-deletion-requests/{requestId}": { "get": { - "operationId": "ArtifactExportController.get", + "operationId": "ArtifactRetentionController.find", "parameters": [ - { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4556,15 +4571,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an immutable artifact verification manifest", + "summary": "Read one governed artifact deletion request", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { + "/v1/artifact-versions/{versionId}/deletion-requests": { "post": { - "operationId": "ArtifactUploadController.issuePartTransfer", + "operationId": "ArtifactRetentionController.request", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4577,7 +4592,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/IssueArtifactUploadTransferDto" } + "schema": { "$ref": "#/components/schemas/CreateArtifactDeletionRequestDto" } } } }, @@ -4633,14 +4648,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue one opaque upload-part transfer grant", + "summary": "Request governed deletion of an exact artifact version", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions": { + "/v1/artifact-deletion-requests/{requestId}/authorize": { "post": { - "operationId": "ArtifactUploadController.create", + "operationId": "ArtifactRetentionController.authorize", "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4653,7 +4669,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactUploadSessionDto" } + "schema": { "$ref": "#/components/schemas/AuthorizeArtifactDeletionRequestDto" } } } }, @@ -4709,15 +4725,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a bounded resumable artifact upload session", + "summary": "Authorize an eligible deletion request after MFA step-up", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}": { - "get": { - "operationId": "ArtifactUploadController.find", + "/v1/artifacts/exports": { + "post": { + "operationId": "ArtifactExportController.create", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4726,8 +4741,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateArtifactExportDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -4778,15 +4801,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read upload session metadata and completed part digests", + "summary": "Create an immutable artifact verification manifest", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/parts": { - "post": { - "operationId": "ArtifactUploadController.part", + "/v1/artifacts/exports/{manifestId}": { + "get": { + "operationId": "ArtifactExportController.get", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4795,16 +4818,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RecordArtifactUploadPartDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4855,13 +4870,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record one verified upload part digest", + "summary": "Read an immutable artifact verification manifest", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/complete": { + "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { "post": { - "operationId": "ArtifactUploadController.complete", + "operationId": "ArtifactUploadController.issuePartTransfer", "parameters": [ { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -4876,7 +4891,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CompleteArtifactUploadDto" } + "schema": { "$ref": "#/components/schemas/IssueArtifactUploadTransferDto" } } } }, @@ -4932,15 +4947,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Finalize an upload after all part digests and the assembled hash match", + "summary": "Issue one opaque upload-part transfer grant", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/abort": { + "/v1/artifact-upload-sessions": { "post": { - "operationId": "ArtifactUploadController.abort", + "operationId": "ArtifactUploadController.create", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4953,7 +4967,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/AbortArtifactUploadDto" } + "schema": { "$ref": "#/components/schemas/CreateArtifactUploadSessionDto" } } } }, @@ -5009,15 +5023,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Abort an open upload session", + "summary": "Create a bounded resumable artifact upload session", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/admit": { - "post": { - "operationId": "ArtifactAdmissionController.admit", + "/v1/artifact-upload-sessions/{sessionId}": { + "get": { + "operationId": "ArtifactUploadController.find", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5026,14 +5040,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AdmitArtifactDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -5084,14 +5092,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Admit an exact artifact version after digest, media, size, and scan checks", + "summary": "Read upload session metadata and completed part digests", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks": { + "/v1/artifact-upload-sessions/{sessionId}/parts": { "post": { - "operationId": "ProtectedDocumentUnlockController.create", + "operationId": "ArtifactUploadController.part", "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5104,7 +5113,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateProtectedDocumentUnlockDto" } + "schema": { "$ref": "#/components/schemas/RecordArtifactUploadPartDto" } } } }, @@ -5160,15 +5169,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a secret-free protected-document unlock request", + "summary": "Record one verified upload part digest", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks/{requestId}": { - "get": { - "operationId": "ProtectedDocumentUnlockController.find", + "/v1/artifact-upload-sessions/{sessionId}/complete": { + "post": { + "operationId": "ArtifactUploadController.complete", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5177,8 +5186,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CompleteArtifactUploadDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5229,15 +5246,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read protected-document unlock state without credentials", + "summary": "Finalize an upload after all part digests and the assembled hash match", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks/{requestId}/handle": { + "/v1/artifact-upload-sessions/{sessionId}/abort": { "post": { - "operationId": "ProtectedDocumentUnlockController.issueHandle", + "operationId": "ArtifactUploadController.abort", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5246,6 +5263,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AbortArtifactUploadDto" } + } + } + }, "responses": { "201": { "description": "", @@ -5298,15 +5323,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a one-shot local secret-input handle", + "summary": "Abort an open upload session", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks/{requestId}/outcome": { + "/v1/artifact-versions/{versionId}/admit": { "post": { - "operationId": "ProtectedDocumentUnlockController.recordOutcome", + "operationId": "ArtifactAdmissionController.admit", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5318,9 +5343,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RecordProtectedDocumentUnlockOutcomeDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/AdmitArtifactDto" } } } }, "responses": { @@ -5375,15 +5398,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a local unlock outcome using an opaque handle", + "summary": "Admit an exact artifact version after digest, media, size, and scan checks", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks/{requestId}/expire": { + "/v1/protected-document-unlocks": { "post": { - "operationId": "ProtectedDocumentUnlockController.expire", + "operationId": "ProtectedDocumentUnlockController.create", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5396,7 +5418,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ExpireProtectedDocumentUnlockDto" } + "schema": { "$ref": "#/components/schemas/CreateProtectedDocumentUnlockDto" } } } }, @@ -5452,14 +5474,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Expire an open unlock request and release local handles", + "summary": "Create a secret-free protected-document unlock request", "tags": ["artifacts"] } }, - "/v1/datasets": { - "post": { - "operationId": "GovernedDatasetController.create", + "/v1/protected-document-unlocks/{requestId}": { + "get": { + "operationId": "ProtectedDocumentUnlockController.find", "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5468,16 +5491,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -5528,15 +5543,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable governed dataset definition draft", - "tags": ["datasets"] + "summary": "Read protected-document unlock state without credentials", + "tags": ["artifacts"] } }, - "/v1/datasets/{datasetId}/versions": { - "get": { - "operationId": "GovernedDatasetController.list", + "/v1/protected-document-unlocks/{requestId}/handle": { + "post": { + "operationId": "ProtectedDocumentUnlockController.issueHandle", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5546,7 +5561,7 @@ } ], "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5597,16 +5612,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List governed dataset versions visible to the caller", - "tags": ["datasets"] + "summary": "Issue a one-shot local secret-input handle", + "tags": ["artifacts"] } }, - "/v1/datasets/{datasetId}/versions/{versionId}": { - "get": { - "operationId": "GovernedDatasetController.getVersion", + "/v1/protected-document-unlocks/{requestId}/outcome": { + "post": { + "operationId": "ProtectedDocumentUnlockController.recordOutcome", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5615,8 +5629,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecordProtectedDocumentUnlockOutcomeDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5667,16 +5689,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one exact immutable governed dataset definition", - "tags": ["datasets"] + "summary": "Record a local unlock outcome using an opaque handle", + "tags": ["artifacts"] } }, - "/v1/datasets/{datasetId}/versions/{versionId}/publish": { + "/v1/protected-document-unlocks/{requestId}/expire": { "post": { - "operationId": "GovernedDatasetController.publish", + "operationId": "ProtectedDocumentUnlockController.expire", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5689,12 +5710,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishGovernedDatasetDto" } + "schema": { "$ref": "#/components/schemas/ExpireProtectedDocumentUnlockDto" } } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5745,27 +5766,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a governed dataset definition as a new immutable version", - "tags": ["datasets"] + "summary": "Expire an open unlock request and release local handles", + "tags": ["artifacts"] } }, - "/v1/datasets/{datasetId}/compatibility": { - "get": { - "operationId": "GovernedDatasetController.compare", + "/v1/datasets": { + "post": { + "operationId": "GovernedDatasetController.create", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { - "name": "previousVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, - { - "name": "nextVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -5774,8 +5782,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5826,13 +5842,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Classify compatibility between two exact schema versions", + "summary": "Create an immutable governed dataset definition draft", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/mappings": { - "post": { - "operationId": "MappingController.create", + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -5843,14 +5859,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -5901,13 +5911,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable mapping definition draft", + "summary": "List governed dataset versions visible to the caller", "tags": ["datasets"] - }, + } + }, + "/v1/datasets/{datasetId}/versions/{versionId}": { "get": { - "operationId": "MappingController.list", + "operationId": "GovernedDatasetController.getVersion", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5968,13 +5981,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable mapping versions", + "summary": "Read one exact immutable governed dataset definition", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { + "/v1/datasets/{datasetId}/versions/{versionId}/publish": { "post": { - "operationId": "MappingController.publish", + "operationId": "GovernedDatasetController.publish", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, @@ -5990,7 +6003,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } + "schema": { "$ref": "#/components/schemas/PublishGovernedDatasetDto" } } } }, @@ -6046,15 +6059,27 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a mapping definition as a new immutable version", + "summary": "Publish a governed dataset definition as a new immutable version", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/rules": { - "post": { - "operationId": "RuleSetController.create", + "/v1/datasets/{datasetId}/compatibility": { + "get": { + "operationId": "GovernedDatasetController.compare", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "previousVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "nextVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -6063,14 +6088,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6121,11 +6140,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable quality rule-set draft", + "summary": "Classify compatibility between two exact schema versions", "tags": ["datasets"] - }, - "get": { - "operationId": "RuleSetController.list", + } + }, + "/v1/datasets/{datasetId}/mappings": { + "post": { + "operationId": "MappingController.create", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -6136,8 +6157,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -6188,16 +6215,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable quality rule-set versions", + "summary": "Create an immutable mapping definition draft", "tags": ["datasets"] - } - }, - "/v1/datasets/{datasetId}/rules/{versionId}/publish": { - "post": { - "operationId": "RuleSetController.publish", + }, + "get": { + "operationId": "MappingController.list", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6206,14 +6230,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } - } - } - }, "responses": { "200": { "description": "", @@ -6266,14 +6282,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a quality rule set as a new immutable version", + "summary": "List immutable mapping versions", "tags": ["datasets"] } }, - "/v1/reference-entities": { + "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { "post": { - "operationId": "ReferenceEntityController.create", + "operationId": "MappingController.publish", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6286,12 +6304,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6342,14 +6360,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable business-party version", - "tags": ["reference-entities"] + "summary": "Publish a mapping definition as a new immutable version", + "tags": ["datasets"] } }, - "/v1/reference-entities/merge": { + "/v1/datasets/{datasetId}/rules": { "post": { - "operationId": "ReferenceEntityController.merge", + "operationId": "RuleSetController.create", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6361,9 +6380,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } } }, "responses": { @@ -6418,15 +6435,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record an explicit business-party merge resolution", - "tags": ["reference-entities"] - } - }, - "/v1/reference-entities/{entityId}/versions": { + "summary": "Create an immutable quality rule-set draft", + "tags": ["datasets"] + }, "get": { - "operationId": "ReferenceEntityController.list", + "operationId": "RuleSetController.list", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6487,15 +6502,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable business-party versions", - "tags": ["reference-entities"] + "summary": "List immutable quality rule-set versions", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/versions/{versionId}": { - "get": { - "operationId": "ReferenceEntityController.getVersion", + "/v1/datasets/{datasetId}/rules/{versionId}/publish": { + "post": { + "operationId": "RuleSetController.publish", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", @@ -6505,6 +6520,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } + } + } + }, "responses": { "200": { "description": "", @@ -6557,15 +6580,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one exact immutable business-party version", - "tags": ["reference-entities"] + "summary": "Publish a quality rule set as a new immutable version", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/resolutions": { - "get": { - "operationId": "ReferenceEntityController.resolutions", + "/v1/reference-entities": { + "post": { + "operationId": "ReferenceEntityController.create", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6574,8 +6596,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -6626,13 +6656,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable merge and resolution history", + "summary": "Create an immutable business-party version", "tags": ["reference-entities"] } }, - "/v1/dataset-versions": { + "/v1/reference-entities/merge": { "post": { - "operationId": "DatasetVersionController.register", + "operationId": "ReferenceEntityController.merge", "parameters": [ { "name": "X-Correlation-Id", @@ -6646,7 +6676,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } } } }, @@ -6702,13 +6732,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable dataset result manifest", - "tags": ["datasets"] - }, + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/versions": { "get": { - "operationId": "DatasetVersionController.list", + "operationId": "ReferenceEntityController.list", "parameters": [ - { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6769,14 +6801,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List exact dataset result manifests for one governed dataset", - "tags": ["datasets"] + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] } }, - "/v1/dataset-versions/{versionId}": { + "/v1/reference-entities/{entityId}/versions/{versionId}": { "get": { - "operationId": "DatasetVersionController.get", + "operationId": "ReferenceEntityController.getVersion", "parameters": [ + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", @@ -6838,14 +6871,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset result manifest", - "tags": ["datasets"] + "summary": "Read one exact immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/dataset-quality-results": { - "post": { - "operationId": "DatasetQualityController.register", + "/v1/reference-entities/{entityId}/resolutions": { + "get": { + "operationId": "ReferenceEntityController.resolutions", "parameters": [ + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6854,16 +6888,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetQualityResultDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6914,18 +6940,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable, value-free dataset quality result", - "tags": ["datasets"] - }, - "get": { - "operationId": "DatasetQualityController.list", + "summary": "List immutable merge and resolution history", + "tags": ["reference-entities"] + } + }, + "/v1/dataset-versions": { + "post": { + "operationId": "DatasetVersionController.register", "parameters": [ - { - "name": "datasetVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -6934,8 +6956,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -6986,15 +7016,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List quality results for one exact dataset version", + "summary": "Register an immutable dataset result manifest", "tags": ["datasets"] - } - }, - "/v1/dataset-quality-results/{resultId}": { + }, "get": { - "operationId": "DatasetQualityController.get", + "operationId": "DatasetVersionController.list", "parameters": [ - { "name": "resultId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7055,15 +7083,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset quality result", + "summary": "List exact dataset result manifests for one governed dataset", "tags": ["datasets"] } }, - "/v1/dataset-profiles": { - "post": { - "operationId": "DatasetProfileController.register", - "parameters": [ - { + "/v1/dataset-versions/{versionId}": { + "get": { + "operationId": "DatasetVersionController.get", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "X-Correlation-Id", "in": "header", "required": false, @@ -7071,16 +7100,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetProfileDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -7131,18 +7152,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable, value-free dataset profile disclosure", + "summary": "Read an exact immutable dataset result manifest", "tags": ["datasets"] - }, - "get": { - "operationId": "DatasetProfileController.list", + } + }, + "/v1/dataset-quality-results": { + "post": { + "operationId": "DatasetQualityController.register", "parameters": [ - { - "name": "datasetVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -7151,8 +7168,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetQualityResultDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -7203,13 +7228,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "List profile disclosures for one exact dataset version", + "summary": "Register an immutable, value-free dataset quality result", "tags": ["datasets"] - } - }, - "/v1/dataset-profiles/page": { + }, "get": { - "operationId": "DatasetProfileController.page", + "operationId": "DatasetQualityController.list", "parameters": [ { "name": "datasetVersionId", @@ -7217,8 +7240,6 @@ "in": "query", "schema": { "type": "string" } }, - { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, - { "name": "cursor", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7279,15 +7300,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List dataset profiles with a stable scoped cursor", + "summary": "List quality results for one exact dataset version", "tags": ["datasets"] } }, - "/v1/dataset-profiles/{profileId}": { + "/v1/dataset-quality-results/{resultId}": { "get": { - "operationId": "DatasetProfileController.get", + "operationId": "DatasetQualityController.get", "parameters": [ - { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "resultId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7348,13 +7369,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset profile disclosure", + "summary": "Read an exact immutable dataset quality result", "tags": ["datasets"] } }, - "/v1/dataset-exports": { + "/v1/dataset-profiles": { "post": { - "operationId": "DatasetExportController.create", + "operationId": "DatasetProfileController.register", "parameters": [ { "name": "X-Correlation-Id", @@ -7368,7 +7389,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDatasetExportManifestDto" } + "schema": { "$ref": "#/components/schemas/RegisterDatasetProfileDto" } } } }, @@ -7424,15 +7445,18 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a governed dataset export verification manifest", + "summary": "Register an immutable, value-free dataset profile disclosure", "tags": ["datasets"] - } - }, - "/v1/dataset-exports/{manifestId}": { + }, "get": { - "operationId": "DatasetExportController.find", + "operationId": "DatasetProfileController.list", "parameters": [ - { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -7493,14 +7517,22 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an immutable governed dataset export manifest", + "summary": "List profile disclosures for one exact dataset version", "tags": ["datasets"] } }, - "/v1/devices/sync/operations": { - "post": { - "operationId": "DeviceSyncController.enqueue", + "/v1/dataset-profiles/page": { + "get": { + "operationId": "DatasetProfileController.page", "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "cursor", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7509,14 +7541,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } - } - } - }, "responses": { "200": { "description": "", @@ -7569,12 +7593,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Enqueue an opaque, tenant-scoped synchronization operation", - "tags": ["devices"] - }, + "summary": "List dataset profiles with a stable scoped cursor", + "tags": ["datasets"] + } + }, + "/v1/dataset-profiles/{profileId}": { "get": { - "operationId": "DeviceSyncController.list", + "operationId": "DatasetProfileController.get", "parameters": [ + { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7635,13 +7662,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List synchronization operation status without source content", - "tags": ["devices"] + "summary": "Read an exact immutable dataset profile disclosure", + "tags": ["datasets"] } }, - "/v1/devices/sync/pull": { + "/v1/dataset-exports": { "post": { - "operationId": "DeviceSyncController.pull", + "operationId": "DatasetExportController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -7654,11 +7681,13 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDatasetExportManifestDto" } + } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -7709,14 +7738,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pull an opaque, signed, cursor-bound synchronization batch", - "tags": ["devices"] + "summary": "Create a governed dataset export verification manifest", + "tags": ["datasets"] } }, - "/v1/devices/sync/push": { - "post": { - "operationId": "DeviceSyncController.push", + "/v1/dataset-exports/{manifestId}": { + "get": { + "operationId": "DatasetExportController.find", "parameters": [ + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7725,12 +7755,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } - } - }, "responses": { "200": { "description": "", @@ -7783,15 +7807,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Push a signed, dependency-ordered synchronization batch", - "tags": ["devices"] + "summary": "Read an immutable governed dataset export manifest", + "tags": ["datasets"] } }, - "/v1/devices/sync/operations/{operationId}/transition": { + "/v1/devices/sync/operations": { "post": { - "operationId": "DeviceSyncController.transition", + "operationId": "DeviceSyncController.enqueue", "parameters": [ - { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7804,7 +7827,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } } } }, @@ -7860,13 +7883,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "Advance one synchronization operation with an expected revision", + "summary": "Enqueue an opaque, tenant-scoped synchronization operation", "tags": ["devices"] - } - }, - "/v1/devices/sync/conflicts": { - "post": { - "operationId": "DeviceSyncController.createConflict", + }, + "get": { + "operationId": "DeviceSyncController.list", "parameters": [ { "name": "X-Correlation-Id", @@ -7876,14 +7897,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } - } - } - }, "responses": { "200": { "description": "", @@ -7936,11 +7949,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a conflict and stop the affected operation", + "summary": "List synchronization operation status without source content", "tags": ["devices"] - }, - "get": { - "operationId": "DeviceSyncController.listConflicts", + } + }, + "/v1/devices/sync/pull": { + "post": { + "operationId": "DeviceSyncController.pull", "parameters": [ { "name": "X-Correlation-Id", @@ -7950,6 +7965,12 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } + } + }, "responses": { "200": { "description": "", @@ -8002,13 +8023,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List explicit synchronization conflicts", + "summary": "Pull an opaque, signed, cursor-bound synchronization batch", "tags": ["devices"] } }, - "/v1/devices/sync/packages": { + "/v1/devices/sync/push": { "post": { - "operationId": "DeviceSyncController.issuePackage", + "operationId": "DeviceSyncController.push", "parameters": [ { "name": "X-Correlation-Id", @@ -8021,9 +8042,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } } }, "responses": { @@ -8078,14 +8097,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a digest-only strict-Local package manifest", + "summary": "Push a signed, dependency-ordered synchronization batch", "tags": ["devices"] } }, - "/v1/devices/sync/packages/receipts": { + "/v1/devices/sync/operations/{operationId}/transition": { "post": { - "operationId": "DeviceSyncController.receipt", + "operationId": "DeviceSyncController.transition", "parameters": [ + { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -8098,7 +8118,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } + "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } } } }, @@ -8154,15 +8174,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a content-safe strict-Local transfer receipt", + "summary": "Advance one synchronization operation with an expected revision", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities": { + "/v1/devices/sync/conflicts": { "post": { - "operationId": "DeviceCapabilityController.report", + "operationId": "DeviceSyncController.createConflict", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -8175,7 +8194,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } } } }, @@ -8231,13 +8250,12 @@ } }, "security": [{ "bearer": [] }], - "summary": "Report one content-free device capability", + "summary": "Record a conflict and stop the affected operation", "tags": ["devices"] }, "get": { - "operationId": "DeviceCapabilityController.listCapabilities", + "operationId": "DeviceSyncController.listConflicts", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -8298,21 +8316,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free capabilities for one device", + "summary": "List explicit synchronization conflicts", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { + "/v1/devices/sync/packages": { "post": { - "operationId": "DeviceCapabilityController.pause", + "operationId": "DeviceSyncController.issuePackage", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, - { - "name": "capabilityId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -8325,7 +8336,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } + "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } } } }, @@ -8381,13 +8392,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pause one device capability with an optimistic revision", + "summary": "Issue a digest-only strict-Local package manifest", "tags": ["devices"] } }, - "/v1/devices/grants": { + "/v1/devices/sync/packages/receipts": { "post": { - "operationId": "DeviceCapabilityController.issueGrant", + "operationId": "DeviceSyncController.receipt", "parameters": [ { "name": "X-Correlation-Id", @@ -8400,7 +8411,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/IssueDeviceGrantDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } + } } }, "responses": { @@ -8455,13 +8468,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a typed, workspace-scoped device grant", + "summary": "Record a content-safe strict-Local transfer receipt", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/grants": { - "get": { - "operationId": "DeviceCapabilityController.listGrants", + "/v1/devices/{deviceId}/capabilities": { + "post": { + "operationId": "DeviceCapabilityController.report", "parameters": [ { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -8472,6 +8485,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } + } + } + }, "responses": { "200": { "description": "", @@ -8524,15 +8545,88 @@ } }, "security": [{ "bearer": [] }], - "summary": "List typed grants for one device in the current workspace", + "summary": "Report one content-free device capability", + "tags": ["devices"] + }, + "get": { + "operationId": "DeviceCapabilityController.listCapabilities", + "parameters": [ + { "name": "deviceId", "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 capabilities for one device", "tags": ["devices"] } }, - "/v1/devices/grants/{grantId}/revoke": { + "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { "post": { - "operationId": "DeviceCapabilityController.revokeGrant", + "operationId": "DeviceCapabilityController.pause", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "capabilityId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -8545,7 +8639,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceGrantRevisionDto" } + "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } } } }, @@ -8601,13 +8695,596 @@ } }, "security": [{ "bearer": [] }], - "summary": "Revoke one typed device grant with an optimistic revision", + "summary": "Pause one device capability with an optimistic revision", + "tags": ["devices"] + } + }, + "/v1/devices/grants": { + "post": { + "operationId": "DeviceCapabilityController.issueGrant", + "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/IssueDeviceGrantDto" } } + } + }, + "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": "Issue a typed, workspace-scoped device grant", + "tags": ["devices"] + } + }, + "/v1/devices/{deviceId}/grants": { + "get": { + "operationId": "DeviceCapabilityController.listGrants", + "parameters": [ + { "name": "deviceId", "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 typed grants for one device in the current workspace", "tags": ["devices"] } }, - "/v1/data-mode-policies": { + "/v1/devices/grants/{grantId}/revoke": { + "post": { + "operationId": "DeviceCapabilityController.revokeGrant", + "parameters": [ + { "name": "grantId", "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/DeviceGrantRevisionDto" } + } + } + }, + "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": "Revoke one typed device grant with an optimistic revision", + "tags": ["devices"] + } + }, + "/v1/data-mode-policies": { + "post": { + "operationId": "DataModePolicyController.publish", + "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/PublishDataModePolicyDto" } + } + } + }, + "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": "Publish an immutable workspace data-mode policy version", + "tags": ["devices"] + } + }, + "/v1/data-mode-policies/{policyId}": { + "get": { + "operationId": "DataModePolicyController.list", + "parameters": [ + { "name": "policyId", "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 immutable versions of one workspace data-mode policy", + "tags": ["devices"] + } + }, + "/v1/audit/events": { + "get": { + "operationId": "AuditController.events", + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "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": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, + "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" } + } + } + }, + "503": { + "description": "Audit 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": "List immutable audit events visible to the caller", + "tags": ["audit"] + } + }, + "/v1/audit/seals": { + "get": { + "operationId": "AuditController.seals", + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "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": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, + "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" } + } + } + }, + "503": { + "description": "Audit 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": "List verified audit seals visible to the caller", + "tags": ["audit"] + } + }, + "/v1/audit/attestations": { "post": { - "operationId": "DataModePolicyController.publish", + "operationId": "AuditAttestationController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -8621,13 +9298,16 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDataModePolicyDto" } + "schema": { "$ref": "#/components/schemas/CreateAuditAttestationDto" } } } }, "responses": { - "200": { + "201": { "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8657,6 +9337,19 @@ } } }, + "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": { @@ -8674,18 +9367,36 @@ "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": "Publish an immutable workspace data-mode policy version", - "tags": ["devices"] + "summary": "Create an independent signature for an exact audit seal", + "tags": ["audit"] } }, - "/v1/data-mode-policies/{policyId}": { + "/v1/audit/attestations/{attestationId}/verify": { "get": { - "operationId": "DataModePolicyController.list", + "operationId": "AuditAttestationController.verify", "parameters": [ - { "name": "policyId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "attestationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -8697,6 +9408,15 @@ "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.", @@ -8726,6 +9446,19 @@ } } }, + "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": { @@ -8743,29 +9476,31 @@ "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": "List immutable versions of one workspace data-mode policy", - "tags": ["devices"] + "summary": "Verify an independent audit seal attestation", + "tags": ["audit"] } }, - "/v1/audit/events": { + "/v1/entitlements/snapshots/{snapshotId}": { "get": { - "operationId": "AuditController.events", + "operationId": "EntitlementController.snapshot", "parameters": [ - { - "name": "limit", - "required": false, - "in": "query", - "schema": { "minimum": 1, "maximum": 100, "type": "number" } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { "maxLength": 512, "type": "string" } - }, + { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -8778,20 +9513,7 @@ "200": { "description": "", "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["items"], - "properties": { - "items": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - }, - "nextCursor": { "type": "string", "maxLength": 512 } - }, - "additionalProperties": false - } - } + "application/json": { "schema": { "type": "object", "additionalProperties": true } } }, "headers": { "X-Correlation-Id": { @@ -8805,12 +9527,20 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } + "description": "The snapshot identifier 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.", @@ -8841,7 +9571,7 @@ } }, "503": { - "description": "Audit persistence is unavailable.", + "description": "Entitlement persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8855,26 +9585,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable audit events visible to the caller", - "tags": ["audit"] + "summary": "Read one immutable entitlement snapshot in the caller scope", + "tags": ["entitlements"] } }, - "/v1/audit/seals": { + "/v1/entitlements/usage": { "get": { - "operationId": "AuditController.seals", + "operationId": "EntitlementController.usage", "parameters": [ - { - "name": "limit", - "required": false, - "in": "query", - "schema": { "minimum": 1, "maximum": 100, "type": "number" } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { "maxLength": 512, "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -8890,13 +9608,16 @@ "application/json": { "schema": { "type": "object", - "required": ["items"], + "required": ["entries", "reservations"], "properties": { - "items": { + "entries": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, - "nextCursor": { "type": "string", "maxLength": 512 } + "reservations": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } }, "additionalProperties": false } @@ -8950,7 +9671,7 @@ } }, "503": { - "description": "Audit persistence is unavailable.", + "description": "Usage persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8964,13 +9685,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List verified audit seals visible to the caller", - "tags": ["audit"] + "summary": "Read the append-only usage ledger state in the caller scope", + "tags": ["entitlements"] } }, - "/v1/entitlements/snapshots/{snapshotId}": { - "get": { - "operationId": "EntitlementController.snapshot", + "/v1/entitlements/snapshots/{snapshotId}/leases": { + "post": { + "operationId": "EntitlementController.issueLease", "parameters": [ { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -8981,8 +9702,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IssueEntitlementLeaseDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } @@ -8999,7 +9728,7 @@ } }, "400": { - "description": "The snapshot identifier is invalid.", + "description": "The snapshot or expiry is invalid.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -9043,7 +9772,7 @@ } }, "503": { - "description": "Entitlement persistence is unavailable.", + "description": "Lease signing or persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -9057,14 +9786,34 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one immutable entitlement snapshot in the caller scope", + "summary": "Issue a signed, bounded offline entitlement lease", "tags": ["entitlements"] } }, - "/v1/entitlements/usage": { + "/v1/entitlements/leases/{leaseId}/verify": { "get": { - "operationId": "EntitlementController.usage", + "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", @@ -9080,18 +9829,8 @@ "application/json": { "schema": { "type": "object", - "required": ["entries", "reservations"], - "properties": { - "entries": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - }, - "reservations": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - } - }, - "additionalProperties": false + "required": ["valid"], + "properties": { "valid": { "type": "boolean" } } } } }, @@ -9107,12 +9846,20 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } + "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.", @@ -9143,7 +9890,7 @@ } }, "503": { - "description": "Usage persistence is unavailable.", + "description": "Lease verification is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -9157,7 +9904,7 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read the append-only usage ledger state in the caller scope", + "summary": "Verify an offline entitlement lease against the current revision and epoch", "tags": ["entitlements"] } }, @@ -9813,6 +10560,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": { @@ -10722,6 +11497,32 @@ "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": { + "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/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/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/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") +} 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") 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") +} 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/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/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-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-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-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/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/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/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 906e60f1..488eda24 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, @@ -12,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, @@ -24,6 +42,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 +62,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], + controllers: [AuditController, AuditAttestationController], 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/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/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..252553d1 --- /dev/null +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts @@ -0,0 +1,68 @@ +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/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/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-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/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/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..fc72f9d3 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -6,6 +6,25 @@ 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 { HmacEntitlementLeaseSignerAdapter } from './adapter/hmac-entitlement-lease-signer.adapter.js'; import { ENTITLEMENT_REPOSITORY_PORT, type EntitlementRepositoryPortV1, @@ -23,6 +42,16 @@ 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; + /** 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; } @@ -35,18 +64,46 @@ 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 leaseSigner = + options.entitlementLeaseSigner ?? + (options.entitlementLeaseSigningKey === undefined + ? undefined + : new HmacEntitlementLeaseSignerAdapter(options.entitlementLeaseSigningKey)); + const leaseService = + options.entitlementLeaseService ?? + (leaseSigner === undefined + ? new UnavailableEntitlementLeaseService() + : new EntitlementLeaseService( + leaseRepository, + repository, + leaseSigner, + 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/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..55bd76eb --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -0,0 +1,134 @@ +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 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 { + 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), + findServiceAccountByDigest: this.findServiceAccountByDigest.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/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..d652a262 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -0,0 +1,288 @@ +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 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 { + 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 findServiceAccountByDigest(context: IamTenantContextV1, secretDigest: string) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccountByDigest( + context, + secretDigest, + ); + } + + 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/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/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..26994555 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.controller.ts @@ -0,0 +1,111 @@ +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..0f0fd655 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.dto.ts @@ -0,0 +1,54 @@ +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/application/service-account-repository.port.ts b/services/api/src/features/iam/application/service-account-repository.port.ts new file mode 100644 index 00000000..c7fc438c --- /dev/null +++ b/services/api/src/features/iam/application/service-account-repository.port.ts @@ -0,0 +1,31 @@ +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; + findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): 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/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..26009f35 --- /dev/null +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -0,0 +1,458 @@ +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; + +import { + createServiceAccountV1, + isServiceAccountSecretUsableV1, + markServiceAccountUsedV1, + 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 ServiceAccountPrincipalV1 = ServiceAccountSafeViewV1; + +export type ServiceAccountApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_INPUT' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'INVALID_CREDENTIALS' + | '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 unavailable(): ServiceAccountApplicationResultV1 { + return rejected('UNAVAILABLE'); +} + +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 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; +} + +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)); + } + } + + /** 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, + 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'; + } + } +} + +/** 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 9f6ca8a4..c6dfd3bf 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,24 @@ 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, + UnavailableServiceAccountService, + 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 +250,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 +444,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 + ? new UnavailableServiceAccountService() + : 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 +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); + exports.unshift(SERVICE_ACCOUNT_SERVICE); return { module: IamModule, controllers: [ @@ -454,6 +497,7 @@ export class IamModule { RegistrationController, RecoveryController, IamBootstrapController, + ServiceAccountController, ], providers: [ { @@ -604,6 +648,14 @@ export class IamModule { provide: DEVICE_IDENTITY_SERVICE, useValue: deviceIdentityService, }, + { + provide: SERVICE_ACCOUNT_REPOSITORY_PORT, + useValue: serviceAccountRepository, + }, + { + provide: SERVICE_ACCOUNT_SERVICE, + useValue: serviceAccountService, + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), 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/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); +}); 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); +}); 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()), []); +}); 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', + ); +}); 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' }, + ); +}); 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/, + ); +}); 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, + ); +}); 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..9a50695e --- /dev/null +++ b/services/api/test/features/bua/entitlement-lease-repository.test.ts @@ -0,0 +1,77 @@ +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, + ); +}); 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' }, + ); +}); 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', + ); +}); 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/); +}); 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, + ); +}); 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..0e8690cd --- /dev/null +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -0,0 +1,219 @@ +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.findServiceAccountByDigest( + context({ scopeType: 'organization', organizationId }), + 'a'.repeat(64), + ) + )?.id, + stable(accountId), + ); + 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, + ); +}); 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..5f14c5e2 --- /dev/null +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -0,0 +1,32 @@ +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-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts new file mode 100644 index 00000000..2d4dbe8b --- /dev/null +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -0,0 +1,130 @@ +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.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, + ); + 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', + ); +}); 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..1e64d961 --- /dev/null +++ b/services/api/test/features/iam/service-account-secret.adapter.test.ts @@ -0,0 +1,19 @@ +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/); +}); 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..8e0ef183 --- /dev/null +++ b/services/api/test/features/iam/service-account.controller.test.ts @@ -0,0 +1,81 @@ +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', + ); +}); 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..0184d118 --- /dev/null +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -0,0 +1,186 @@ +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'; +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 digest = (secret: string) => createHash('sha256').update(secret, 'utf8').digest('hex'); + const secrets = [ + { secret: 'dbsa_first', digest: digest('dbsa_first') }, + { secret: 'dbsa_second', digest: digest('dbsa_second') }, + ]; + 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); +}); + +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' }, + ); +}); 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(