Repository navigation
Fix dashboard project resolution for authenticated workspaces #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<StableIdentifierV1 | undefined>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
167
to
179
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a dashboard workspace has zero or multiple active projects, the resolver returns
Suggested change
Context Used: AGENTS.md (source) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!resolved.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Reject dashboard requests when no project is resolved.
Line 170 retains the workspace scope when
projectIdisundefined. This occurs when the resolver is absent or when it returnsundefinedfor zero or multiple active projects. The dashboard request can then proceed without the required unique project scope.For a dashboard request without
dashboardProjectId, reject the request when resolution does not produce a project ID. Add tests for an omitted resolver and for a resolver that returnsundefined.🤖 Prompt for AI Agents