diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 3e3ba59c..c4a6ff9e 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -53,8 +53,11 @@ import { SessionRequestTenantContextAdapter, UnavailableWorkspaceAuthorizationEpochResolverAdapter, } from './platform/http/session-tenant-context.adapter.js'; +import { IamHierarchyDashboardProjectResolverAdapter } from './platform/http/iam-hierarchy-dashboard-project-resolver.adapter.js'; import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js'; import { PrismaIamRepositoryAdapter } from './features/iam/adapter/prisma-iam-repository.adapter.js'; +import { PrismaIamHierarchyRepositoryAdapter } from './features/iam/adapter/prisma-iam-hierarchy-repository.adapter.js'; +import type { IdentityBootstrapDatabaseClientV1 } from './features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; import { PrismaAgentGrantRepositoryAdapter } from './features/iam/adapter/prisma-agent-grant-repository.adapter.js'; import { InMemoryAgentGrantRepositoryAdapter } from './features/iam/adapter/in-memory-agent-grant-repository.adapter.js'; import { PrismaServiceAccountRepositoryAdapter } from './features/iam/adapter/prisma-service-account-repository.adapter.js'; @@ -206,6 +209,24 @@ export class AppModule { (options.iamDatabase === undefined ? undefined : new PrismaIamRepositoryAdapter(options.iamDatabase)); + const hierarchyRepository = + options.hierarchyRepository ?? + (options.hierarchyDatabase === undefined + ? undefined + : new PrismaIamHierarchyRepositoryAdapter( + options.hierarchyDatabase, + {}, + options.identityBootstrapPolicyProvisionerFactory === undefined + ? undefined + : (transaction: unknown) => + options.identityBootstrapPolicyProvisionerFactory!( + transaction as IdentityBootstrapDatabaseClientV1, + ), + )); + const dashboardProjectResolver = + hierarchyRepository === undefined + ? undefined + : new IamHierarchyDashboardProjectResolverAdapter(hierarchyRepository); const iaeAuthorization = options.iaeAuthorization ?? (iamRepository === undefined @@ -293,9 +314,12 @@ export class AppModule { : {}), }, workspaceEpochResolver, - options.dashboardProjectId === undefined - ? {} - : { dashboardProjectId: options.dashboardProjectId }, + { + ...(options.dashboardProjectId === undefined + ? {} + : { dashboardProjectId: options.dashboardProjectId }), + ...(dashboardProjectResolver === undefined ? {} : { dashboardProjectResolver }), + }, ) : undefined); const platformOperatorAuthority = @@ -667,6 +691,7 @@ export class AppModule { ...(landingFeedbackIpAdmission === undefined ? {} : { landingFeedbackIpAdmission }), ...(landingFeedbackAdmissionDigest === undefined ? {} : { landingFeedbackAdmissionDigest }), ...(iamRepository === undefined ? {} : { iamRepository }), + ...(hierarchyRepository === undefined ? {} : { hierarchyRepository }), ...(iaeAuthorization === undefined ? {} : { iaeAuthorization }), ...(iaeOriginalViewPort === undefined ? {} : { iaeOriginalViewPort }), accessPresetService, diff --git a/services/api/src/platform/http/dashboard-project-resolver.port.ts b/services/api/src/platform/http/dashboard-project-resolver.port.ts new file mode 100644 index 00000000..73ef3928 --- /dev/null +++ b/services/api/src/platform/http/dashboard-project-resolver.port.ts @@ -0,0 +1,8 @@ +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; + +/** Resolve a server-owned dashboard project inside the authenticated tenant scope. */ +export interface DashboardProjectResolverPortV1 { + resolveDashboardProject(context: IamTenantContextV1): Promise; +} diff --git a/services/api/src/platform/http/iam-hierarchy-dashboard-project-resolver.adapter.ts b/services/api/src/platform/http/iam-hierarchy-dashboard-project-resolver.adapter.ts new file mode 100644 index 00000000..80060eca --- /dev/null +++ b/services/api/src/platform/http/iam-hierarchy-dashboard-project-resolver.adapter.ts @@ -0,0 +1,26 @@ +import type { IamHierarchyRepositoryPortV1 } from '../../features/iam/application/hierarchy-repository.port.js'; +import type { IamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; +import type { DashboardProjectResolverPortV1 } from './dashboard-project-resolver.port.js'; + +/** + * IAM-002 / DDA-013: project dashboard routes may use the only active project + * belonging to the authenticated workspace. Ambiguous or foreign rows fail closed. + */ +export class IamHierarchyDashboardProjectResolverAdapter implements DashboardProjectResolverPortV1 { + public constructor(private readonly hierarchy: IamHierarchyRepositoryPortV1) {} + + public async resolveDashboardProject(context: IamTenantContextV1) { + if (context.tenantScope.scopeType !== 'workspace') return undefined; + + const { organizationId, workspaceId } = context.tenantScope; + const projects = await this.hierarchy.listProjects(context, workspaceId); + const activeProjects = projects.filter( + (project) => + project.status === 'ACTIVE' && + project.organizationId === organizationId && + project.workspaceId === workspaceId, + ); + + return activeProjects.length === 1 ? activeProjects[0]?.id : undefined; + } +} diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 5a845685..9edcef67 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -8,6 +8,7 @@ import { type RequestTenantContextPortV1, } from './request-tenant-context.port.js'; import { getRequestContext } from './request-context.js'; +import type { DashboardProjectResolverPortV1 } from './dashboard-project-resolver.port.js'; type HeaderValueV1 = string | readonly string[] | undefined; const SAFE_METHODS_V1 = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -96,6 +97,8 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP * dashboard routes; ordinary workspace APIs keep their workspace scope. */ readonly dashboardProjectId?: string; + /** Production projection resolved inside the authenticated workspace. */ + readonly dashboardProjectResolver?: DashboardProjectResolverPortV1; } = {}, ) {} @@ -145,20 +148,33 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP } catch { throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); } - const projectId = this.options.dashboardProjectId; const requestUrl = typeof input.url === 'string' ? input.url : ''; - const dashboardRequest = - /^\/(?:v1|v3)\/dda\/dashboards(?:\/|$)/u.test(requestUrl) && projectId !== undefined; + const dashboardRequest = /^\/(?:v1|v3)\/dda\/dashboards(?:\/|$)/u.test(requestUrl); + let projectId = this.options.dashboardProjectId; + if ( + dashboardRequest && + projectId === undefined && + this.options.dashboardProjectResolver !== undefined + ) { + try { + projectId = await this.options.dashboardProjectResolver.resolveDashboardProject( + context.value, + ); + } catch { + throw new RequestTenantContextProblemError('AUTHENTICATION_UNAVAILABLE'); + } + } const resolved = createIamTenantContextV1({ ...context.value, - tenantScope: dashboardRequest - ? { - scopeType: 'project', - organizationId: principal.organizationId, - workspaceId: principal.workspaceId, - projectId, - } - : context.value.tenantScope, + tenantScope: + dashboardRequest && projectId !== undefined + ? { + scopeType: 'project', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + projectId, + } + : context.value.tenantScope, workspaceAuthorizationEpoch, }); if (!resolved.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); diff --git a/services/api/test/platform/http/iam-hierarchy-dashboard-project-resolver.test.ts b/services/api/test/platform/http/iam-hierarchy-dashboard-project-resolver.test.ts new file mode 100644 index 00000000..5fbc93cb --- /dev/null +++ b/services/api/test/platform/http/iam-hierarchy-dashboard-project-resolver.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createProjectIdentityV1, type ProjectIdentityV1 } from '@databreeze/domain/identity/v1'; + +import { IamHierarchyDashboardProjectResolverAdapter } from '../../../src/platform/http/iam-hierarchy-dashboard-project-resolver.adapter.js'; +import type { IamHierarchyRepositoryPortV1 } from '../../../src/features/iam/application/hierarchy-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + actor: '00000000-0000-4000-8000-000000000101', + organization: '00000000-0000-4000-8000-000000000102', + workspace: '00000000-0000-4000-8000-000000000103', + project: '00000000-0000-4000-8000-000000000104', + archivedProject: '00000000-0000-4000-8000-000000000105', + secondProject: '00000000-0000-4000-8000-000000000106', + otherOrganization: '00000000-0000-4000-8000-000000000107', + otherWorkspace: '00000000-0000-4000-8000-000000000108', + correlation: '00000000-0000-4000-8000-000000000109', +}; + +const parsedContext = createIamTenantContextV1({ + tenantScope: { + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: ids.workspace, + }, + actorId: ids.actor, + correlationId: ids.correlation, + idempotencyKey: 'dashboard-project-resolution', + authorizationEpoch: 1, + workspaceAuthorizationEpoch: 1, +}); +assert.equal(parsedContext.accepted, true); +if (!parsedContext.accepted) throw new Error('invalid tenant context fixture'); +const context = parsedContext.value; + +function project( + id: string, + status: 'ACTIVE' | 'ARCHIVED', + organizationId = ids.organization, + workspaceId = ids.workspace, +): ProjectIdentityV1 { + const parsed = createProjectIdentityV1({ + id, + organizationId, + workspaceId, + kind: 'INTERNAL', + name: `Project ${id}`, + status, + createdAt: '2026-08-21T00:00:00.000Z', + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid project fixture'); + return parsed.value; +} + +function repository(projects: readonly ProjectIdentityV1[]): IamHierarchyRepositoryPortV1 { + return { + listProjects: () => Promise.resolve(projects), + } as unknown as IamHierarchyRepositoryPortV1; +} + +void test('[IAM-002, DDA-013] resolves the only active project in the exact authenticated workspace', async () => { + const resolver = new IamHierarchyDashboardProjectResolverAdapter( + repository([ + project(ids.archivedProject, 'ARCHIVED'), + project(ids.project, 'ACTIVE'), + project(ids.secondProject, 'ACTIVE', ids.otherOrganization, ids.workspace), + project(ids.secondProject, 'ACTIVE', ids.organization, ids.otherWorkspace), + ]), + ); + + assert.equal(await resolver.resolveDashboardProject(context), ids.project); +}); + +void test('[IAM-002] refuses an ambiguous active-project scope instead of selecting arbitrarily', async () => { + const resolver = new IamHierarchyDashboardProjectResolverAdapter( + repository([project(ids.project, 'ACTIVE'), project(ids.secondProject, 'ACTIVE')]), + ); + + assert.equal(await resolver.resolveDashboardProject(context), undefined); +}); diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index b53b7a05..376a36a7 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + import { SessionRequestTenantContextAdapter } from '../../../src/platform/http/session-tenant-context.adapter.js'; const principal = { @@ -12,6 +14,10 @@ const principal = { mfaReenrollmentRequired: false, }; const correlationId = '00000000-0000-4000-8000-000000000010'; +const parsedDashboardProjectId = parseStableIdentifierV1('00000000-0000-4000-8000-000000000004'); +assert.equal(parsedDashboardProjectId.accepted, true); +if (!parsedDashboardProjectId.accepted) throw new Error('invalid dashboard project fixture'); +const dashboardProjectId = parsedDashboardProjectId.value; function workspaceEpoch(value = principal.securityEpoch) { return { @@ -92,6 +98,73 @@ void test('derives the configured local project only for dashboard routes', asyn }); }); +void test('[IAM-002, DDA-013] resolves the authenticated workspace project only for dashboard routes', async () => { + const resolvedScopes: unknown[] = []; + const adapter = new SessionRequestTenantContextAdapter( + { findPrincipalByAccessToken: () => Promise.resolve(principal) }, + workspaceEpoch(), + { + dashboardProjectResolver: { + resolveDashboardProject: (context) => { + resolvedScopes.push(context.tenantScope); + return Promise.resolve(dashboardProjectId); + }, + }, + }, + ); + + const dashboard = await adapter.resolve({ + method: 'GET', + url: '/v3/dda/dashboards/workspace-history', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }); + assert.deepEqual(dashboard.tenantScope, { + scopeType: 'project', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + projectId: '00000000-0000-4000-8000-000000000004', + }); + assert.deepEqual(resolvedScopes, [ + { + scopeType: 'workspace', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + }, + ]); + + const data = await adapter.resolve({ + method: 'GET', + url: '/v1/datasets', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }); + assert.equal(data.tenantScope.scopeType, 'workspace'); + assert.equal(resolvedScopes.length, 1); +}); + +void test('[IAM-002] fails closed when dashboard project resolution is unavailable', async () => { + const adapter = new SessionRequestTenantContextAdapter( + { findPrincipalByAccessToken: () => Promise.resolve(principal) }, + workspaceEpoch(), + { + dashboardProjectResolver: { + resolveDashboardProject: () => Promise.reject(new Error('database unavailable')), + }, + }, + ); + + await assert.rejects( + adapter.resolve({ + method: 'GET', + url: '/v3/dda/dashboards/workspace-history', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_UNAVAILABLE'); + return true; + }, + ); +}); + void test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { const adapter = new SessionRequestTenantContextAdapter( { diff --git a/services/api/test/platform/iam-dataset-restriction-root-composition.test.ts b/services/api/test/platform/iam-dataset-restriction-root-composition.test.ts index 3dfef6cb..b2024280 100644 --- a/services/api/test/platform/iam-dataset-restriction-root-composition.test.ts +++ b/services/api/test/platform/iam-dataset-restriction-root-composition.test.ts @@ -44,6 +44,7 @@ const ids = { foreignVersion: '00000000-0000-4000-8000-000000000b0a', retiredVersion: '00000000-0000-4000-8000-000000000b0b', correlation: '00000000-0000-4000-8000-000000000b0c', + project: '00000000-0000-4000-8000-000000000b0e', }; function stable(value: string): StableIdentifierV1 { @@ -264,6 +265,77 @@ void test('[IAM-024, DSM-018] production-shaped root selects durable IAM, DSM, a assert.ok(provider(iam, REQUEST_TENANT_CONTEXT) instanceof SessionRequestTenantContextAdapter); }); +void test('[IAM-002, DDA-013] production root resolves dashboard project from the exact hierarchy database scope', async () => { + const projectQueries: unknown[] = []; + const hierarchyDatabase = { + organizationIdentity: {}, + workspaceIdentity: {}, + projectIdentity: { + findMany: (input: unknown) => { + projectQueries.push(input); + return Promise.resolve([ + { + id: ids.project, + organizationId: ids.organization, + workspaceId: ids.workspace, + kind: 'INTERNAL', + name: 'Personal project', + status: 'ACTIVE', + createdAt: new Date('2026-08-21T00:00:00.000Z'), + }, + ]); + }, + }, + $transaction: () => Promise.reject(new Error('transaction not used by project resolution')), + }; + const root = AppModule.register({ + runtimeMode: 'production', + allowInMemoryAdapters: false, + sessions: { + findPrincipalByAccessToken: () => + Promise.resolve({ + userId: ids.owner, + organizationId: ids.organization, + workspaceId: ids.workspace, + securityEpoch: 7, + mfaRequired: false, + mfaReenrollmentRequired: false, + }), + } as never, + agentGrantRepository: new InMemoryAgentGrantRepositoryAdapter(), + hierarchyDatabase: hierarchyDatabase as never, + ddaDatabase: {} as never, + approvalDatabase: {} as never, + }); + const iam = imported(root, IamModule); + const requestContext = provider( + iam, + REQUEST_TENANT_CONTEXT, + ) as SessionRequestTenantContextAdapter; + + const resolved = await requestContext.resolve({ + method: 'GET', + url: '/v3/dda/dashboards/workspace-history', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }); + + assert.deepEqual(resolved.tenantScope, { + scopeType: 'project', + organizationId: stable(ids.organization), + workspaceId: stable(ids.workspace), + projectId: stable(ids.project), + }); + assert.deepEqual(projectQueries, [ + { + where: { + organizationId: stable(ids.organization), + workspaceId: stable(ids.workspace), + }, + orderBy: { id: 'asc' }, + }, + ]); +}); + void test('[IAM-024] root session context is stale immediately after restriction mutation and re-resolves current epoch', async () => { const iamRepository = new InMemoryIamRepositoryAdapter(); iamRepository.seed([