Skip to content

Commit d70565c

Browse files
committed
feat(mothership): authorize private cross-workspace memory scopes
1 parent 7c115a1 commit d70565c

8 files changed

Lines changed: 302 additions & 14 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readMemoryScopeContract } from '@/lib/api/contracts/mothership-memory'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
} from '@/lib/api/server/routes'
7+
import { internalCopilotAuth } from '@/lib/mothership/auth/internal'
8+
import {
9+
MEMORY_SCOPE_AUDIENCE,
10+
readMemoryScope,
11+
readMemoryScopeOperation,
12+
} from '@/lib/mothership/memory/application/read-scope'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: readMemoryScopeContract,
16+
auth: internalCopilotAuth(MEMORY_SCOPE_AUDIENCE, { organization: true }),
17+
operation: readMemoryScopeOperation,
18+
rateLimit: internalRateLimits.none({
19+
reason: 'Authenticated worker resolves its current private chat memory scope.',
20+
}),
21+
errorPolicy: internalOrchestrationErrorPolicy,
22+
mapInput: ({ body }) => body,
23+
useCase: readMemoryScope,
24+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineRouteContract } from '@/lib/api/contracts/types'
2+
import { MemoryScopeRequest, MemoryScopeResponse } from '@/lib/mothership/generated/memory-scope'
3+
4+
export const readMemoryScopeContract = defineRouteContract({
5+
method: 'POST',
6+
path: '/api/mothership/memory/scope',
7+
body: MemoryScopeRequest,
8+
response: { mode: 'json', schema: MemoryScopeResponse },
9+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-scope.ts
2+
// Regenerate with `bun run contracts:sync` in the worker.
3+
4+
import { z } from "zod";
5+
6+
export const MemoryScopeRequest = z.object({ chatId: z.uuid() });
7+
export type MemoryScopeRequest = z.infer<typeof MemoryScopeRequest>;
8+
export const MemoryScopeResponse = z.object({
9+
userId: z.string().min(1),
10+
organizationId: z.string().min(1).nullable(),
11+
workspaceId: z.uuid().nullable(),
12+
});

‎apps/sim/lib/mothership/generated/sim-transport.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { z } from "zod";
55
import { IntegrationCatalogRequest } from "./integration-catalog";
6+
import { MemoryScopeRequest } from "./memory-scope";
67
import { RunControlRequest } from "./run-control";
78
import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks";
89

@@ -26,6 +27,7 @@ export type SimScope = z.infer<typeof SimScope>;
2627

2728
/** Only idempotent controls use the idle channel; tool effects use the run event log. */
2829
export const SimControlOperation = z.discriminatedUnion("kind", [
30+
z.object({ kind: z.literal("memory_scope"), input: MemoryScopeRequest }),
2931
z.object({ kind: z.literal("run_control"), input: RunControlRequest }),
3032
z.object({ kind: z.literal("workflow_status"), input: WorkflowWatchRequest }),
3133
z.object({ kind: z.literal("wake"), input: TaskWakeRequest }),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/** @vitest-environment node */
2+
import { copilotChats, member, workspace } from '@sim/db/schema'
3+
import { queueTableRows, resetDbChainMock } from '@sim/testing'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
createTrustedCopilotPrincipal,
7+
createTrustedOrganizationCopilotPrincipal,
8+
} from '@/lib/mothership/auth/application-delegation'
9+
import { MEMORY_SCOPE_AUDIENCE, readMemoryScope } from './read-scope'
10+
11+
const mocks = vi.hoisted(() => ({
12+
config: vi.fn(),
13+
banned: vi.fn(),
14+
permission: vi.fn(),
15+
capability: vi.fn(),
16+
}))
17+
vi.mock('@/lib/auth/ban', () => ({ getActivelyBannedUserIds: mocks.banned }))
18+
vi.mock('@/lib/permission-groups/resolve.server', () => ({
19+
getUserPermissionConfigForOrganization: mocks.config,
20+
}))
21+
vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({
22+
...(await importOriginal<typeof import('@sim/platform-authz/workspace')>()),
23+
resolveEffectiveWorkspacePermission: mocks.permission,
24+
}))
25+
vi.mock('@/lib/permission-groups/capability-assertions', async (importOriginal) => ({
26+
...(await importOriginal<typeof import('@/lib/permission-groups/capability-assertions')>()),
27+
assertWorkspaceCapability: mocks.capability,
28+
}))
29+
const input = { chatId: 'chat-1' }
30+
function principal() {
31+
return createTrustedOrganizationCopilotPrincipal(
32+
{ userId: 'actor', organizationId: 'org-1', chatId: 'chat-1', delegationId: 'memory-1' },
33+
{ audience: MEMORY_SCOPE_AUDIENCE, ttlMs: 60_000 }
34+
)
35+
}
36+
function queueChat(mode = 'plan', membership = true) {
37+
queueTableRows(copilotChats, [
38+
{ userId: 'actor', organizationId: 'org-1', workspaceId: null, type: 'mothership', mode },
39+
])
40+
queueTableRows(member, membership ? [{ role: 'member' }] : [])
41+
}
42+
describe('private memory scope', () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
resetDbChainMock()
46+
mocks.banned.mockResolvedValue([])
47+
mocks.config.mockResolvedValue(null)
48+
mocks.permission.mockResolvedValue('read')
49+
mocks.capability.mockResolvedValue(undefined)
50+
})
51+
it.each(['plan', 'agent'])('resolves the current %s chat owner', async (mode) => {
52+
queueChat(mode)
53+
expect(await readMemoryScope.execute({ principal: principal(), input })).toEqual({
54+
userId: 'actor',
55+
organizationId: 'org-1',
56+
workspaceId: null,
57+
})
58+
})
59+
it.each(['user', 'organization', 'chat', 'audience', 'expired'] as const)(
60+
'rejects forged %s',
61+
async (field) => {
62+
queueChat()
63+
const trusted = principal()
64+
const caller = {
65+
...trusted,
66+
...(field === 'user' ? { subjectUserId: 'other' } : {}),
67+
...(field === 'organization' ? { organizationId: 'other' } : {}),
68+
...(field === 'chat' ? { resourceScope: { chatId: 'other' } } : {}),
69+
...(field === 'audience' ? { audience: 'other' } : {}),
70+
...(field === 'expired' ? { expiresAt: new Date(0) } : {}),
71+
}
72+
await expect(readMemoryScope.execute({ principal: caller, input })).rejects.toThrow()
73+
}
74+
)
75+
it('rejects revoked membership, Search chats, and suspended users', async () => {
76+
queueChat('plan', false)
77+
await expect(readMemoryScope.execute({ principal: principal(), input })).rejects.toThrow()
78+
resetDbChainMock()
79+
queueChat('assistant')
80+
await expect(readMemoryScope.execute({ principal: principal(), input })).rejects.toThrow()
81+
resetDbChainMock()
82+
queueChat()
83+
mocks.banned.mockResolvedValue(['actor'])
84+
await expect(readMemoryScope.execute({ principal: principal(), input })).rejects.toThrow()
85+
})
86+
it('derives workspace organization from canonical state and rechecks access on every call', async () => {
87+
function queueWorkspace() {
88+
queueTableRows(copilotChats, [
89+
{
90+
userId: 'actor',
91+
organizationId: null,
92+
workspaceId: 'workspace',
93+
type: 'mothership',
94+
mode: 'plan',
95+
},
96+
])
97+
queueTableRows(workspace, [
98+
{
99+
id: 'workspace',
100+
organizationId: 'canonical-org',
101+
allowPersonalApiKeys: true,
102+
billedAccountUserId: 'billing-owner',
103+
},
104+
])
105+
}
106+
const caller = createTrustedCopilotPrincipal(
107+
{ userId: 'actor', workspaceId: 'workspace', chatId: 'chat-1', delegationId: 'memory' },
108+
{ audience: MEMORY_SCOPE_AUDIENCE, ttlMs: 60000 }
109+
)
110+
queueWorkspace()
111+
expect(await readMemoryScope.execute({ principal: caller, input })).toEqual({
112+
userId: 'actor',
113+
organizationId: 'canonical-org',
114+
workspaceId: 'workspace',
115+
})
116+
expect(mocks.capability).toHaveBeenCalledWith(
117+
'actor',
118+
'workspace',
119+
'copilot.use',
120+
'canonical-org',
121+
undefined
122+
)
123+
resetDbChainMock()
124+
queueWorkspace()
125+
mocks.permission.mockResolvedValue(null)
126+
await expect(readMemoryScope.execute({ principal: caller, input })).rejects.toThrow()
127+
resetDbChainMock()
128+
queueWorkspace()
129+
mocks.permission.mockResolvedValue('read')
130+
await expect(
131+
readMemoryScope.execute({
132+
principal: { ...caller, resourceScope: { chatId: 'other' } },
133+
input,
134+
})
135+
).rejects.toThrow()
136+
resetDbChainMock()
137+
queueWorkspace()
138+
mocks.capability.mockRejectedValue(new Error('Capability revoked'))
139+
await expect(readMemoryScope.execute({ principal: caller, input })).rejects.toThrow(
140+
'Capability revoked'
141+
)
142+
})
143+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal'
2+
import { defineWorkspaceOperation } from '@/lib/core/application'
3+
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
4+
import { OrchestrationError } from '@/lib/core/orchestration/types'
5+
import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case'
6+
import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context'
7+
import type { MemoryScopeRequest } from '@/lib/mothership/generated/memory-scope'
8+
9+
export const MEMORY_SCOPE_AUDIENCE = 'sim:copilot-memory'
10+
export const readMemoryScopeOperation = defineWorkspaceOperation({
11+
id: 'mothership.memory.read_scope',
12+
minimumRole: 'read',
13+
workspaceApiKey: 'deny',
14+
capability: 'copilot.use',
15+
principalKinds: ['delegated'],
16+
delegatedServices: ['copilot'],
17+
})
18+
19+
/** Canonical, currently authorized ownership; never a model-selected graph namespace. */
20+
export const readMemoryScope = defineAuthorizedChatUseCase({
21+
operation: readMemoryScopeOperation,
22+
organizationOperation: defineOrganizationOperation({
23+
id: readMemoryScopeOperation.id,
24+
minimumRole: 'member',
25+
capability: readMemoryScopeOperation.capability,
26+
principalKinds: ['organization_delegated'],
27+
delegationAudience: MEMORY_SCOPE_AUDIENCE,
28+
delegatedServices: ['copilot'],
29+
}),
30+
resolveContext: ({
31+
principal,
32+
input,
33+
}: {
34+
principal: DelegatedPrincipal | OrganizationDelegatedPrincipal
35+
input: MemoryScopeRequest
36+
}) => resolveOwnedChatContext(principal, input.chatId),
37+
authorizationOptions: {
38+
delegation: {
39+
audience: MEMORY_SCOPE_AUDIENCE,
40+
isWithinScope: (principal, context) =>
41+
principal.serviceId === 'copilot' &&
42+
principal.workspaceId === context.workspaceId &&
43+
principal.resourceScope?.chatId === context.chatId,
44+
},
45+
},
46+
async execute({ context }) {
47+
if (context.mode !== 'agent' && context.mode !== 'plan') {
48+
throw new OrchestrationError('forbidden', 'Private memory requires an interactive agent chat')
49+
}
50+
return {
51+
userId: context.userId,
52+
organizationId: context.organizationId ?? context.workspaceOrganizationId ?? null,
53+
workspaceId: context.workspaceId ?? null,
54+
}
55+
},
56+
})

‎apps/sim/lib/mothership/transport/control.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
const handlers = vi.hoisted(() => ({
4+
memory: vi.fn(),
45
read: vi.fn(),
56
status: vi.fn(),
67
prepare: vi.fn(),
78
wake: vi.fn(),
89
workspace: vi.fn(),
910
catalog: vi.fn(),
1011
}))
12+
vi.mock('@/lib/mothership/memory/application/read-scope', () => ({
13+
MEMORY_SCOPE_AUDIENCE: 'memory',
14+
readMemoryScope: { execute: handlers.memory },
15+
}))
1116
vi.mock('@/lib/mothership/integrations/application/catalog', () => ({
1217
INTEGRATION_CATALOG_AUDIENCE: 'catalog',
1318
readIntegrationCatalog: { execute: handlers.catalog },
@@ -205,3 +210,25 @@ it('routes catalog reads through catalog-specific delegated authority', async ()
205210
input,
206211
})
207212
})
213+
214+
it('routes memory through the same use case for both delivery scopes', async () => {
215+
handlers.memory.mockResolvedValue({ userId: 'user', organizationId: 'org', workspaceId: null })
216+
for (const owner of [scope, { userId: 'user', organizationId: 'org', chatId: 'chat' }]) {
217+
expect(
218+
(
219+
await executeSimControl({
220+
...request({ kind: 'memory_scope', input: { chatId: 'chat' } }),
221+
scope: owner,
222+
})
223+
).status
224+
).toBe(200)
225+
expect(handlers.memory).toHaveBeenLastCalledWith({
226+
input: { chatId: 'chat' },
227+
principal: expect.objectContaining({
228+
subjectUserId: 'user',
229+
audience: 'memory',
230+
resourceScope: { chatId: 'chat' },
231+
}),
232+
})
233+
}
234+
})

‎apps/sim/lib/mothership/transport/control.ts‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
INTEGRATION_CATALOG_AUDIENCE,
1313
readIntegrationCatalog,
1414
} from '@/lib/mothership/integrations/application/catalog'
15+
import {
16+
MEMORY_SCOPE_AUDIENCE,
17+
readMemoryScope,
18+
} from '@/lib/mothership/memory/application/read-scope'
1519
import {
1620
RUN_CONTROL_AUDIENCE,
1721
readRunControl,
@@ -41,32 +45,43 @@ export async function executeSimControl(request: SimControlRequest): Promise<Sim
4145
},
4246
{
4347
audience:
44-
operation.kind === 'integration_catalog'
45-
? INTEGRATION_CATALOG_AUDIENCE
46-
: operation.kind === 'workspace_context'
47-
? WORKSPACE_TARGET_AUDIENCE
48-
: operation.kind === 'run_control'
49-
? RUN_CONTROL_AUDIENCE
50-
: TASK_DELEGATION_AUDIENCE,
48+
operation.kind === 'memory_scope'
49+
? MEMORY_SCOPE_AUDIENCE
50+
: operation.kind === 'integration_catalog'
51+
? INTEGRATION_CATALOG_AUDIENCE
52+
: operation.kind === 'workspace_context'
53+
? WORKSPACE_TARGET_AUDIENCE
54+
: operation.kind === 'run_control'
55+
? RUN_CONTROL_AUDIENCE
56+
: TASK_DELEGATION_AUDIENCE,
5157
ttlMs: 60_000,
5258
}
5359
)
5460
: createTrustedCopilotPrincipal(
5561
{ ...scope, workspaceId: scope.workspaceId!, delegationId: `transport:${request.id}` },
5662
{
5763
audience:
58-
operation.kind === 'integration_catalog'
59-
? INTEGRATION_CATALOG_AUDIENCE
60-
: operation.kind === 'workspace_context'
61-
? WORKSPACE_TARGET_AUDIENCE
62-
: operation.kind === 'run_control'
63-
? RUN_CONTROL_AUDIENCE
64-
: TASK_DELEGATION_AUDIENCE,
64+
operation.kind === 'memory_scope'
65+
? MEMORY_SCOPE_AUDIENCE
66+
: operation.kind === 'integration_catalog'
67+
? INTEGRATION_CATALOG_AUDIENCE
68+
: operation.kind === 'workspace_context'
69+
? WORKSPACE_TARGET_AUDIENCE
70+
: operation.kind === 'run_control'
71+
? RUN_CONTROL_AUDIENCE
72+
: TASK_DELEGATION_AUDIENCE,
6573
ttlMs: 60_000,
6674
}
6775
)
6876
try {
6977
switch (operation.kind) {
78+
case 'memory_scope':
79+
return {
80+
status: 200,
81+
body: JSON.stringify(
82+
await readMemoryScope.execute({ principal, input: operation.input })
83+
),
84+
}
7085
case 'integration_catalog':
7186
return {
7287
status: 200,

0 commit comments

Comments
 (0)