From ed27155f1a24c19a1f0cafa7c3d794c968bc5693 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:09:11 +0900 Subject: [PATCH 1/6] fix(planning): require signed workspace authority on all routes --- apps/planning-service/src/main.ts | 101 +++++++++++++++++------------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index ae7746c47..1e8f334e3 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -1,6 +1,5 @@ import 'reflect-metadata'; import { - BadRequestException, Body, Controller, Get, @@ -53,15 +52,6 @@ interface PassthroughResponse { setHeader(name: string, value: string): void; } -/** Requires the tenant workspace boundary used by legacy planning operations. */ -function requireWorkspaceId(value: string | undefined): string { - const workspaceId = value?.trim(); - if (!workspaceId) { - throw new BadRequestException('x-workspace-id header is required'); - } - return workspaceId; -} - /** Returns a stable not-found problem without disclosing another tenant's state. */ function todayNotFound(): HttpException { return new HttpException( @@ -197,67 +187,83 @@ export class PlanningController { } } - /** Creates a goal inside the caller's required workspace. */ + /** Creates a goal inside the signed gateway workspace. */ @Post('goals') async createGoal( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: { title?: unknown }, ): Promise { try { - return await this.planningService.createGoal( - requireWorkspaceId(workspaceHeader), - { - title: requireTitle(body), - }, + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, ); + return await this.planningService.createGoal(trustedWorkspaceId, { + title: requireTitle(body), + }); } catch (error) { throw toHttpException(error); } } - /** Lists goals belonging to the caller's required workspace. */ + /** Lists goals belonging to the signed gateway workspace. */ @Get('goals') async listGoals( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, ): Promise { try { - return await this.planningService.listGoals( - requireWorkspaceId(workspaceHeader), + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, ); + return await this.planningService.listGoals(trustedWorkspaceId); } catch (error) { throw toHttpException(error); } } - /** Creates a project below a workspace-owned goal. */ + /** Creates a project below a goal in the signed gateway workspace. */ @Post('goals/:goalId/projects') async createProject( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, @Body() body: { title?: unknown }, ): Promise { try { - return await this.planningService.createProject( - requireWorkspaceId(workspaceHeader), - { - goalId, - title: requireTitle(body), - }, + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, ); + return await this.planningService.createProject(trustedWorkspaceId, { + goalId, + title: requireTitle(body), + }); } catch (error) { throw toHttpException(error); } } - /** Lists projects below a workspace-owned goal. */ + /** Lists projects below a goal in the signed gateway workspace. */ @Get('goals/:goalId/projects') async listProjects( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, ): Promise { try { + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, + ); return await this.planningService.listProjects( - requireWorkspaceId(workspaceHeader), + trustedWorkspaceId, goalId, ); } catch (error) { @@ -265,35 +271,44 @@ export class PlanningController { } } - /** Creates a task below a workspace-owned project. */ + /** Creates a task below a project in the signed gateway workspace. */ @Post('projects/:projectId/tasks') async createTask( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, @Body() body: { title?: unknown }, ): Promise { try { - return await this.planningService.createTask( - requireWorkspaceId(workspaceHeader), - { - projectId, - title: requireTitle(body), - }, + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, ); + return await this.planningService.createTask(trustedWorkspaceId, { + projectId, + title: requireTitle(body), + }); } catch (error) { throw toHttpException(error); } } - /** Lists tasks below a workspace-owned project. */ + /** Lists tasks below a project in the signed gateway workspace. */ @Get('projects/:projectId/tasks') async listTasks( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, ): Promise { try { + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, + ); return await this.planningService.listTasks( - requireWorkspaceId(workspaceHeader), + trustedWorkspaceId, projectId, ); } catch (error) { From 63dcd7564b66eab4878d210e45bc5c0438c8b56c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:09:25 +0900 Subject: [PATCH 2/6] test(planning): bind every route to signed workspace authority --- .../src/planning-controller-authority.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/planning-service/src/planning-controller-authority.test.ts diff --git a/apps/planning-service/src/planning-controller-authority.test.ts b/apps/planning-service/src/planning-controller-authority.test.ts new file mode 100644 index 000000000..7766f7698 --- /dev/null +++ b/apps/planning-service/src/planning-controller-authority.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const CONTROLLER_SOURCE = readFileSync(join(__dirname, 'main.ts'), 'utf8'); + +const LEGACY_WORKSPACE_HEADER = /@Headers\(['"]x-workspace-id['"]\)/gu; +const TRUSTED_WORKSPACE_HEADER = + /@Headers\(['"]x-life-os-workspace-id['"]\)/gu; +const TRUSTED_ISSUED_AT_HEADER = + /@Headers\(['"]x-life-os-context-issued-at['"]\)/gu; +const TRUSTED_SIGNATURE_HEADER = + /@Headers\(['"]x-life-os-context-signature['"]\)/gu; + +/** Counts stable route-boundary tokens in the Planning controller source. */ +function count(pattern: RegExp): number { + return [...CONTROLLER_SOURCE.matchAll(pattern)].length; +} + +describe('PlanningController workspace authority contract', () => { + it('never accepts a bare client-selected workspace header', () => { + expect(count(LEGACY_WORKSPACE_HEADER)).toBe(0); + expect(CONTROLLER_SOURCE).not.toContain('function requireWorkspaceId'); + }); + + it('binds every workspace-scoped planning route to the signed workspace context', () => { + // search + Today GET/PUT + six Goal/Project/Task routes. + expect(count(TRUSTED_WORKSPACE_HEADER)).toBe(9); + expect(count(TRUSTED_ISSUED_AT_HEADER)).toBe(9); + expect(count(TRUSTED_SIGNATURE_HEADER)).toBe(9); + expect(CONTROLLER_SOURCE.match(/requireTrustedWorkspaceContext\(/gu)).toHaveLength(9); + }); +}); From 0ef5f8ac326d11976dcc5e49b3479b3385a23515 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:09:36 +0900 Subject: [PATCH 3/6] docs(planning): trace signed workspace authority --- ...2026-08-10-planning-workspace-authority.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/research/2026-08-10-planning-workspace-authority.md diff --git a/docs/research/2026-08-10-planning-workspace-authority.md b/docs/research/2026-08-10-planning-workspace-authority.md new file mode 100644 index 000000000..6c23e0dd8 --- /dev/null +++ b/docs/research/2026-08-10-planning-workspace-authority.md @@ -0,0 +1,23 @@ +# Planning workspace authority hardening — standards traceability + +## Status + +**Implemented on active PR** + +This note records the standards basis for issue #158. It does not claim certification or formal NIST conformance. + +## Decision + +Goal, project and task create/list endpoints use the same short-lived signed `life-os.workspace.v1` gateway context already used by planning search and durable Today. The browser-visible workspace identifier is an input to a cryptographically verified service context, not a standalone authorization claim. A bare legacy `x-workspace-id` header no longer establishes tenant ownership. + +The planning service continues to enforce workspace predicates in its domain/repository boundaries, so the signed gateway context is defense in depth rather than a replacement for tenant-scoped persistence. + +## Security rationale + +NIST SP 800-53 Rev. 5 separates identification/authentication from access-control enforcement and emphasizes least privilege, authorized access, auditability and protection against unauthorized use. LifeOS applies those principles by requiring the authenticated gateway boundary to establish workspace authority before the request reaches planning operations, while retaining workspace-scoped service and database checks. + +No model or autonomous agent participates in planning authorization. Model availability and model judgments cannot turn an invalid workspace context into an authorized request. + +## APA 7 reference + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5) [Final publication]. National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 From 106829ef650f13fbedd87f6f7b78881411a080a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:10:14 +0900 Subject: [PATCH 4/6] docs(planning): record signed workspace boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de7883a5..050a02f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to LifeOS are documented in this file. ### Security - Calendar local disconnect never accepts client-selected ownership as authority, never reads provider secret handles, revalidates durable revocation evidence against the signed workspace+user context, and maps absent or differently owned connections to the same public not-found result. +- Goal, project, and task create/list routes now reject bare client-selected `x-workspace-id` authority and require the same short-lived signed `life-os.workspace.v1` context used by planning search and durable Today. - The data-rights request ledger keeps personal export payloads out of durable audit rows and normalizes primary-key/idempotency collisions before dependency errors can escape the service boundary. - The commercial-development model account no longer performs Docker commands, never receives Docker-socket authority, and cannot trigger provider-wide model discovery through the credential bridge. - The scheduled live-model harness uses only `NVIDIA_NIM_API_KEY`, seeds it through the encrypted contextual-orchestrator credential registry, installs hash-locked dependencies from an exact commit, confines LifeOS traffic to loopback, allowlists NVIDIA NIM egress, and excludes provider credentials, prompts, responses, traces, and hidden reasoning from retained artifacts. From 8a4e5455d5d4a4ff255306a28eeab81b374e0525 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 17:30:49 +0900 Subject: [PATCH 5/6] test(planning): execute signed workspace authority routes --- .../src/planning-controller-authority.test.ts | 212 +++++++++++++++++- 1 file changed, 210 insertions(+), 2 deletions(-) diff --git a/apps/planning-service/src/planning-controller-authority.test.ts b/apps/planning-service/src/planning-controller-authority.test.ts index 7766f7698..b3c9bf63e 100644 --- a/apps/planning-service/src/planning-controller-authority.test.ts +++ b/apps/planning-service/src/planning-controller-authority.test.ts @@ -1,6 +1,11 @@ +import { createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PlanningService } from './planning-domain'; +import { PlanningController } from './main'; +import type { TodaySyncService } from './today-sync'; const CONTROLLER_SOURCE = readFileSync(join(__dirname, 'main.ts'), 'utf8'); @@ -11,13 +16,156 @@ const TRUSTED_ISSUED_AT_HEADER = /@Headers\(['"]x-life-os-context-issued-at['"]\)/gu; const TRUSTED_SIGNATURE_HEADER = /@Headers\(['"]x-life-os-context-signature['"]\)/gu; +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const GOAL_ID = '22222222-2222-4222-8222-222222222222'; +const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; +const CONTEXT_SECRET = randomBytes(32).toString('base64url'); + +interface RouteHeaders { + readonly workspaceId: string | undefined; + readonly issuedAt: string | undefined; + readonly signature: string | undefined; +} + +interface PlanningServiceSpies { + readonly createGoal: ReturnType; + readonly listGoals: ReturnType; + readonly createProject: ReturnType; + readonly listProjects: ReturnType; + readonly createTask: ReturnType; + readonly listTasks: ReturnType; +} + +interface RouteCase { + readonly name: string; + readonly serviceMethod: keyof PlanningServiceSpies; + readonly invoke: ( + controller: PlanningController, + headers: RouteHeaders, + ) => Promise; +} + +const ROUTES: readonly RouteCase[] = [ + { + name: 'createGoal', + serviceMethod: 'createGoal', + invoke: (controller, headers) => + controller.createGoal( + headers.workspaceId, + headers.issuedAt, + headers.signature, + { title: 'Goal' }, + ), + }, + { + name: 'listGoals', + serviceMethod: 'listGoals', + invoke: (controller, headers) => + controller.listGoals( + headers.workspaceId, + headers.issuedAt, + headers.signature, + ), + }, + { + name: 'createProject', + serviceMethod: 'createProject', + invoke: (controller, headers) => + controller.createProject( + headers.workspaceId, + headers.issuedAt, + headers.signature, + GOAL_ID, + { title: 'Project' }, + ), + }, + { + name: 'listProjects', + serviceMethod: 'listProjects', + invoke: (controller, headers) => + controller.listProjects( + headers.workspaceId, + headers.issuedAt, + headers.signature, + GOAL_ID, + ), + }, + { + name: 'createTask', + serviceMethod: 'createTask', + invoke: (controller, headers) => + controller.createTask( + headers.workspaceId, + headers.issuedAt, + headers.signature, + PROJECT_ID, + { title: 'Task' }, + ), + }, + { + name: 'listTasks', + serviceMethod: 'listTasks', + invoke: (controller, headers) => + controller.listTasks( + headers.workspaceId, + headers.issuedAt, + headers.signature, + PROJECT_ID, + ), + }, +]; /** Counts stable route-boundary tokens in the Planning controller source. */ function count(pattern: RegExp): number { return [...CONTROLLER_SOURCE.matchAll(pattern)].length; } -describe('PlanningController workspace authority contract', () => { +/** Creates the six domain-service spies exercised by workspace-scoped routes. */ +function createPlanningServiceSpies(): PlanningServiceSpies { + return { + createGoal: vi.fn(), + listGoals: vi.fn(), + createProject: vi.fn(), + listProjects: vi.fn(), + createTask: vi.fn(), + listTasks: vi.fn(), + }; +} + +/** Creates a controller with no durable Today dependency because these routes do not use it. */ +function createController(service: PlanningServiceSpies): PlanningController { + return new PlanningController( + service as unknown as PlanningService, + {} as TodaySyncService, + ); +} + +/** Produces a valid short-lived signed gateway context for the supplied issue time. */ +function signedHeaders(issuedAtSeconds: number): RouteHeaders { + const issuedAt = String(issuedAtSeconds); + const signature = createHmac('sha256', CONTEXT_SECRET) + .update(`life-os.workspace.v1\n${WORKSPACE_ID}\n${issuedAt}`, 'utf8') + .digest('base64url'); + return { workspaceId: WORKSPACE_ID, issuedAt, signature }; +} + +/** Captures the status of an expected HTTP rejection without hiding false success. */ +async function rejectedStatus(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + return (error as HttpException).getStatus(); + } + throw new Error('Expected Planning route to reject untrusted workspace context'); +} + +afterEach(() => { + delete process.env.PLANNING_GATEWAY_CONTEXT_SECRET; + vi.restoreAllMocks(); +}); + +describe.sequential('PlanningController workspace authority contract', () => { it('never accepts a bare client-selected workspace header', () => { expect(count(LEGACY_WORKSPACE_HEADER)).toBe(0); expect(CONTROLLER_SOURCE).not.toContain('function requireWorkspaceId'); @@ -30,4 +178,64 @@ describe('PlanningController workspace authority contract', () => { expect(count(TRUSTED_SIGNATURE_HEADER)).toBe(9); expect(CONTROLLER_SOURCE.match(/requireTrustedWorkspaceContext\(/gu)).toHaveLength(9); }); + + it('passes the verified workspace to every Goal, Project, and Task service route', async () => { + process.env.PLANNING_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const nowSeconds = Math.floor(Date.now() / 1000); + const headers = signedHeaders(nowSeconds); + const service = createPlanningServiceSpies(); + const controller = createController(service); + + for (const route of ROUTES) { + vi.clearAllMocks(); + await route.invoke(controller, headers); + expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes(1); + expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( + WORKSPACE_ID, + ); + } + }); + + it('rejects untrusted contexts before any Goal, Project, or Task service call', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const fresh = signedHeaders(nowSeconds); + const expired = signedHeaders(nowSeconds - 120); + const future = signedHeaders(nowSeconds + 120); + const tampered = { + ...fresh, + signature: `${fresh.signature?.slice(0, -1)}${ + fresh.signature?.endsWith('A') ? 'B' : 'A' + }`, + }; + const malformed = { ...fresh, workspaceId: 'not-a-uuid' }; + const invalidContexts = [ + { name: 'missing', headers: { ...fresh, workspaceId: undefined }, status: 401 }, + { name: 'expired', headers: expired, status: 401 }, + { name: 'future', headers: future, status: 401 }, + { name: 'tampered', headers: tampered, status: 401 }, + { name: 'malformed', headers: malformed, status: 401 }, + { name: 'secret-unconfigured', headers: fresh, status: 503, secret: false }, + ] as const; + const service = createPlanningServiceSpies(); + const controller = createController(service); + + for (const invalid of invalidContexts) { + for (const route of ROUTES) { + vi.clearAllMocks(); + if (invalid.secret === false) { + delete process.env.PLANNING_GATEWAY_CONTEXT_SECRET; + } else { + process.env.PLANNING_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + } + expect( + await rejectedStatus(route.invoke(controller, invalid.headers)), + `${route.name}:${invalid.name}`, + ).toBe(invalid.status); + expect( + service[route.serviceMethod], + `${route.name}:${invalid.name}`, + ).not.toHaveBeenCalled(); + } + } + }); }); From 7cc0f9d9757f68979e004686df14d602a62eb0cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:56:33 +0900 Subject: [PATCH 6/6] fix(planning): narrow unconfigured-secret test case --- apps/planning-service/src/planning-controller-authority.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/planning-service/src/planning-controller-authority.test.ts b/apps/planning-service/src/planning-controller-authority.test.ts index b3c9bf63e..686fcc54a 100644 --- a/apps/planning-service/src/planning-controller-authority.test.ts +++ b/apps/planning-service/src/planning-controller-authority.test.ts @@ -222,7 +222,7 @@ describe.sequential('PlanningController workspace authority contract', () => { for (const invalid of invalidContexts) { for (const route of ROUTES) { vi.clearAllMocks(); - if (invalid.secret === false) { + if ('secret' in invalid && invalid.secret === false) { delete process.env.PLANNING_GATEWAY_CONTEXT_SECRET; } else { process.env.PLANNING_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET;