Skip to content

Commit 92e1a3e

Browse files
fix(copilot): enforce secret-admin on env writes and gate KB indexing on usage
Two more paths where the agent is a weaker route to the same write than the UI. upsertWorkspaceEnvVars was a weakened copy of the environment route's write: no per-key secret-admin check, no advisory lock, no audit row. Its only caller is the set_environment_variables copilot tool, which gates on workspace 'write' alone — so any write-level member could have the agent overwrite a workspace secret they do not administer, with nothing in the audit log and a lost-update race against the route's locked transaction. The gate now lives in the function so every caller inherits it, reusing getWorkspaceEnvKeyAdminAccess rather than restating the route's logic. knowledge_base add_file computed a billing attribution and then indexed without calling checkAttributedUsageLimits, which every upload route applies before accepting indexing work — an over-quota workspace could index without limit through the agent. The same file's query operation already gated correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 22ee1fc commit 92e1a3e

4 files changed

Lines changed: 280 additions & 26 deletions

File tree

apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
8282
checkKnowledgeBaseWriteAccess: mockCheckKnowledgeBaseWriteAccess,
8383
}))
8484

85+
import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution'
8586
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
87+
import { createSingleDocument } from '@/lib/knowledge/documents/service'
88+
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
89+
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
8690

8791
const BILLING_ATTRIBUTION = {
8892
actorUserId: 'external-admin',
@@ -173,3 +177,58 @@ describe('knowledge base connector Copilot operations', () => {
173177
}
174178
)
175179
})
180+
181+
describe('knowledge base add_file usage gate', () => {
182+
beforeEach(() => {
183+
vi.clearAllMocks()
184+
resetDbChainMock()
185+
mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({
186+
hasAccess: true,
187+
knowledgeBase: { id: 'knowledge-base-1', workspaceId: 'workspace-paid', name: 'Paid KB' },
188+
})
189+
vi.mocked(getKnowledgeBaseById).mockResolvedValue({
190+
id: 'knowledge-base-1',
191+
workspaceId: 'workspace-paid',
192+
} as Awaited<ReturnType<typeof getKnowledgeBaseById>>)
193+
})
194+
195+
function addFile() {
196+
return knowledgeBaseServerTool.execute(
197+
{
198+
operation: 'add_file',
199+
args: { knowledgeBaseId: 'knowledge-base-1', filePaths: ['files/report.pdf'] },
200+
},
201+
{
202+
userId: 'external-admin',
203+
workspaceId: 'workspace-paid',
204+
billingAttribution: BILLING_ATTRIBUTION,
205+
}
206+
)
207+
}
208+
209+
it('refuses to index when the payer is over its usage limit', async () => {
210+
vi.mocked(checkAttributedUsageLimits).mockResolvedValue({
211+
isExceeded: true,
212+
message: 'Usage limit exceeded.',
213+
} as Awaited<ReturnType<typeof checkAttributedUsageLimits>>)
214+
215+
const result = await addFile()
216+
217+
expect(result.success).toBe(false)
218+
expect(result.message).toContain('Usage limit exceeded')
219+
// The gate must precede any indexing work, matching the upload routes.
220+
expect(resolveWorkspaceFileReference).not.toHaveBeenCalled()
221+
expect(createSingleDocument).not.toHaveBeenCalled()
222+
})
223+
224+
it('gates on the knowledge base workspace payer, not the caller', async () => {
225+
vi.mocked(checkAttributedUsageLimits).mockResolvedValue({
226+
isExceeded: false,
227+
} as Awaited<ReturnType<typeof checkAttributedUsageLimits>>)
228+
vi.mocked(resolveWorkspaceFileReference).mockResolvedValue(null)
229+
230+
await addFile()
231+
232+
expect(checkAttributedUsageLimits).toHaveBeenCalledWith(BILLING_ATTRIBUTION)
233+
})
234+
})

apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
349349

350350
const kbWorkspaceId: string = targetKb.workspaceId
351351
const billingAttribution = requireKnowledgeBillingAttribution(context, kbWorkspaceId)
352+
353+
// Gate the payer before accepting indexing work, same as the upload routes.
354+
const usage = await checkAttributedUsageLimits(billingAttribution)
355+
if (usage.isExceeded) {
356+
return {
357+
success: false,
358+
message:
359+
usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
360+
}
361+
}
362+
352363
const added: Array<{ documentId: string; filename: string }> = []
353364
const failedFiles: string[] = []
354365

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const {
7+
mockCreateWorkspaceEnvCredentials,
8+
mockEncryptSecret,
9+
mockGetUserEntityPermissions,
10+
mockGetWorkspaceEnvKeyAdminAccess,
11+
mockRecordAudit,
12+
} = vi.hoisted(() => ({
13+
mockCreateWorkspaceEnvCredentials: vi.fn(),
14+
mockEncryptSecret: vi.fn(),
15+
mockGetUserEntityPermissions: vi.fn(),
16+
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
17+
mockRecordAudit: vi.fn(),
18+
}))
19+
20+
// vitest.setup.ts mocks this module globally; this suite tests the real one.
21+
vi.unmock('@/lib/environment/utils')
22+
23+
vi.mock('@sim/audit', () => ({
24+
AuditAction: { ENVIRONMENT_UPDATED: 'environment.updated' },
25+
AuditResourceType: { ENVIRONMENT: 'environment' },
26+
recordAudit: mockRecordAudit,
27+
}))
28+
vi.mock('@/lib/core/security/encryption', () => ({
29+
decryptSecret: vi.fn(),
30+
encryptSecret: mockEncryptSecret,
31+
}))
32+
vi.mock('@/lib/credentials/environment', () => ({
33+
createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials,
34+
getAccessibleEnvCredentials: vi.fn(),
35+
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
36+
syncPersonalEnvCredentialsForUser: vi.fn(),
37+
}))
38+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
39+
checkWorkspaceAccess: vi.fn(),
40+
getUserEntityPermissions: mockGetUserEntityPermissions,
41+
}))
42+
43+
import { upsertWorkspaceEnvVars, WorkspaceEnvAccessError } from '@/lib/environment/utils'
44+
45+
describe('upsertWorkspaceEnvVars', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
mockEncryptSecret.mockResolvedValue({ encrypted: 'cipher' })
49+
})
50+
51+
it('refuses to overwrite an existing secret the caller does not administer', async () => {
52+
// Workspace `write` is what the copilot tool checks; the route additionally
53+
// requires secret-admin on the specific key. Without this the agent was the
54+
// weaker path to the same write.
55+
mockGetUserEntityPermissions.mockResolvedValue('write')
56+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
57+
adminKeys: new Set<string>(),
58+
knownKeys: new Set(['STRIPE_KEY']),
59+
})
60+
61+
await expect(
62+
upsertWorkspaceEnvVars('ws-1', { STRIPE_KEY: 'rotated' }, 'user-1')
63+
).rejects.toBeInstanceOf(WorkspaceEnvAccessError)
64+
65+
expect(mockEncryptSecret).not.toHaveBeenCalled()
66+
expect(mockRecordAudit).not.toHaveBeenCalled()
67+
})
68+
69+
it('refuses to add a new secret without workspace write', async () => {
70+
mockGetUserEntityPermissions.mockResolvedValue('read')
71+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
72+
adminKeys: new Set<string>(),
73+
knownKeys: new Set<string>(),
74+
})
75+
76+
await expect(
77+
upsertWorkspaceEnvVars('ws-1', { NEW_KEY: 'value' }, 'user-1')
78+
).rejects.toBeInstanceOf(WorkspaceEnvAccessError)
79+
80+
expect(mockEncryptSecret).not.toHaveBeenCalled()
81+
})
82+
83+
it('allows a key admin to rotate the key they administer', async () => {
84+
mockGetUserEntityPermissions.mockResolvedValue('write')
85+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
86+
adminKeys: new Set(['STRIPE_KEY']),
87+
knownKeys: new Set(['STRIPE_KEY']),
88+
})
89+
90+
await expect(
91+
upsertWorkspaceEnvVars('ws-1', { STRIPE_KEY: 'rotated' }, 'user-1')
92+
).resolves.toEqual(['STRIPE_KEY'])
93+
94+
expect(mockEncryptSecret).toHaveBeenCalledWith('rotated')
95+
expect(mockRecordAudit).toHaveBeenCalledWith(
96+
expect.objectContaining({ workspaceId: 'ws-1', actorId: 'user-1' })
97+
)
98+
})
99+
100+
it('treats a workspace admin as an admin of every key', async () => {
101+
mockGetUserEntityPermissions.mockResolvedValue('admin')
102+
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
103+
adminKeys: new Set<string>(),
104+
knownKeys: new Set(['STRIPE_KEY']),
105+
})
106+
107+
await expect(
108+
upsertWorkspaceEnvVars('ws-1', { STRIPE_KEY: 'rotated' }, 'user-1')
109+
).resolves.toEqual(['STRIPE_KEY'])
110+
})
111+
112+
it('records no audit and takes no lock for an empty update', async () => {
113+
await expect(upsertWorkspaceEnvVars('ws-1', {}, 'user-1')).resolves.toEqual([])
114+
115+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
116+
expect(mockRecordAudit).not.toHaveBeenCalled()
117+
})
118+
})

apps/sim/lib/environment/utils.ts

Lines changed: 92 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { db } from '@sim/db'
23
import { environment, workspaceEnvironment } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
45
import { getErrorMessage } from '@sim/utils/errors'
56
import { generateId } from '@sim/utils/id'
6-
import { eq, inArray } from 'drizzle-orm'
7+
import { eq, inArray, sql } from 'drizzle-orm'
78
import { LRUCache } from 'lru-cache'
89
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
910
import {
1011
createWorkspaceEnvCredentials,
1112
getAccessibleEnvCredentials,
13+
getWorkspaceEnvKeyAdminAccess,
1214
syncPersonalEnvCredentialsForUser,
1315
} from '@/lib/credentials/environment'
14-
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
16+
import {
17+
checkWorkspaceAccess,
18+
getUserEntityPermissions,
19+
type WorkspaceAccess,
20+
} from '@/lib/workspaces/permissions/utils'
1521

1622
const logger = createLogger('EnvironmentUtils')
23+
const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000
24+
25+
/** Thrown when the acting user may not write one of the requested env keys. */
26+
export class WorkspaceEnvAccessError extends Error {
27+
constructor(readonly keys: string[]) {
28+
super('You must be an admin of these secrets to edit them')
29+
this.name = 'WorkspaceEnvAccessError'
30+
}
31+
}
1732
const EFFECTIVE_DECRYPTED_ENV_CACHE_TTL_MS = 2_000
1833
const EFFECTIVE_DECRYPTED_ENV_CACHE_MAX_ENTRIES = 1_000
1934

@@ -317,43 +332,94 @@ export async function upsertWorkspaceEnvVars(
317332
newVars: Record<string, string>,
318333
actingUserId: string
319334
): Promise<string[]> {
320-
const updatedKeys: string[] = []
321-
if (Object.keys(newVars).length === 0) return updatedKeys
335+
const updatedKeys = Object.keys(newVars)
336+
if (updatedKeys.length === 0) return []
322337

323-
const wsRows = await db
324-
.select()
325-
.from(workspaceEnvironment)
326-
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
327-
.limit(1)
328-
const existingWsEncrypted = (wsRows[0]?.variables as Record<string, string>) || {}
338+
const permission = await getUserEntityPermissions(actingUserId, 'workspace', workspaceId)
339+
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
340+
workspaceId,
341+
envKeys: updatedKeys,
342+
userId: actingUserId,
343+
})
344+
345+
// Overwriting an existing secret needs secret-admin on that specific key;
346+
// workspace `write` alone only covers adding new ones.
347+
const forbidden = updatedKeys.filter(
348+
(key) => knownKeys.has(key) && permission !== 'admin' && !adminKeys.has(key)
349+
)
350+
if (forbidden.length > 0) {
351+
logger.warn('Workspace env update denied', {
352+
workspaceId,
353+
userId: actingUserId,
354+
reason: 'not-secret-admin',
355+
keys: forbidden,
356+
})
357+
throw new WorkspaceEnvAccessError(forbidden)
358+
}
359+
const addingNew = updatedKeys.some((key) => !knownKeys.has(key))
360+
if (addingNew && permission !== 'admin' && permission !== 'write') {
361+
logger.warn('Workspace env update denied', {
362+
workspaceId,
363+
userId: actingUserId,
364+
reason: 'write-access-required',
365+
keys: updatedKeys.filter((key) => !knownKeys.has(key)),
366+
})
367+
throw new WorkspaceEnvAccessError(updatedKeys.filter((key) => !knownKeys.has(key)))
368+
}
329369

330370
const newlyEncrypted: Record<string, string> = {}
331371
for (const [key, val] of Object.entries(newVars)) {
332372
const { encrypted } = await encryptSecret(val)
333373
newlyEncrypted[key] = encrypted
334-
updatedKeys.push(key)
335374
}
336375

337-
const merged = { ...existingWsEncrypted, ...newlyEncrypted }
376+
// Read-modify-write on a single jsonb column, so serialize against the
377+
// route's identically-locked transaction or concurrent writers lose keys.
378+
await db.transaction(async (tx) => {
379+
await tx.execute(
380+
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
381+
)
382+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
338383

339-
await db
340-
.insert(workspaceEnvironment)
341-
.values({
342-
id: generateId(),
343-
workspaceId,
344-
variables: merged,
345-
createdAt: new Date(),
346-
updatedAt: new Date(),
347-
})
348-
.onConflictDoUpdate({
349-
target: [workspaceEnvironment.workspaceId],
350-
set: { variables: merged, updatedAt: new Date() },
351-
})
384+
const [existingRow] = await tx
385+
.select()
386+
.from(workspaceEnvironment)
387+
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
388+
.limit(1)
389+
const merged = {
390+
...((existingRow?.variables as Record<string, string>) || {}),
391+
...newlyEncrypted,
392+
}
393+
394+
await tx
395+
.insert(workspaceEnvironment)
396+
.values({
397+
id: generateId(),
398+
workspaceId,
399+
variables: merged,
400+
createdAt: new Date(),
401+
updatedAt: new Date(),
402+
})
403+
.onConflictDoUpdate({
404+
target: [workspaceEnvironment.workspaceId],
405+
set: { variables: merged, updatedAt: new Date() },
406+
})
407+
})
352408

353409
invalidateEffectiveDecryptedEnvCache({ workspaceId })
354-
const newKeys = Object.keys(newVars).filter((k) => !(k in existingWsEncrypted))
410+
const newKeys = updatedKeys.filter((key) => !knownKeys.has(key))
355411
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId })
356412

413+
recordAudit({
414+
workspaceId,
415+
actorId: actingUserId,
416+
action: AuditAction.ENVIRONMENT_UPDATED,
417+
resourceType: AuditResourceType.ENVIRONMENT,
418+
resourceId: workspaceId,
419+
description: `Updated ${updatedKeys.length} workspace environment variable(s)`,
420+
metadata: { variableCount: updatedKeys.length, updatedKeys },
421+
})
422+
357423
return updatedKeys
358424
}
359425

0 commit comments

Comments
 (0)