Skip to content

Commit a77abd6

Browse files
authored
fix(executor): load permission config once per run and retry transient reads (#7847)
* fix(executor): load permission config once per run and retry transient reads Every block ran on a shallow copy of the execution context, so the permission-config memo written onto it was discarded and the full permission-group config reloaded from the database before every block. That per-block load had no retry, so a single transient database error failed the whole run, and the raw query error (SQL and bound parameters) surfaced as the block error. - Memoize the in-flight load in a run-scoped map shared by every block copy, keyed by governed subject and workspace; failed loads are evicted - Move the bounded transient-read retry from the tool-only wrapper into the shared loader so block, model, agent and tool gates all get it - Replace a database query error's message in the block error handler and log only its redacted cause - Redact bound parameters in the execution failure cause log * fix(executor): honor caller cancellation in unshared permission loads
1 parent 7ae92b7 commit a77abd6

11 files changed

Lines changed: 386 additions & 160 deletions

File tree

apps/sim/ee/access-control/utils/permission-check.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { createLogger } from '@sim/logger'
2+
import { describeError } from '@sim/utils/errors'
3+
import { sleep } from '@sim/utils/helpers'
4+
import { backoffWithJitter } from '@sim/utils/retry'
25
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
36
import {
47
getAllowedIntegrationsFromEnv,
58
isInvitationsDisabled,
69
isPublicApiDisabled,
710
} from '@/lib/core/config/env-flags'
11+
import { findDatabaseQueryError } from '@/lib/core/errors/database-query-error'
12+
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
813
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
914
import {
1015
CAPABILITY_RULES,
@@ -199,43 +204,78 @@ function governedSubjectUserId(
199204
return declared ?? undefined
200205
}
201206

207+
const PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS = 3
208+
const PERMISSION_CONFIG_LOAD_RETRY_BACKOFF = { baseMs: 25, maxMs: 100 } as const
209+
202210
/**
203-
* Cache-aware wrapper around `getUserPermissionConfig`. When an
204-
* `ExecutionContext` is provided, the resolved config is memoized on the
205-
* context so repeated checks during a single workflow run share one DB hit.
206-
*
207-
* The subject is resolved HERE rather than by each caller, because the memo is
208-
* keyed by nothing but the context. `validateModelProvider` and
209-
* `validateBlockType` take the actor's id positionally, so a run declaring a
210-
* different gate subject had the first model check fill the cache with the
211-
* BILLING actor's group — and every later `assertPermissionsAllowed`, having
212-
* correctly resolved the governed subject, was handed that stale entry. Doing
213-
* the derivation at the one place the config is loaded makes the memo correct
214-
* by construction: within a run `capabilityGovernedUserId` is fixed, so every
215-
* path resolves and caches the same person.
211+
* Loads a permission config, retrying a transient database read failure a bounded number of times.
212+
* The last failure is rethrown: resolving `null` would turn every gate off.
213+
*/
214+
async function loadPermissionConfig(
215+
userId: string,
216+
workspaceId: string,
217+
signal: AbortSignal | undefined
218+
): Promise<PermissionGroupConfig | null> {
219+
for (let attempt = 1; ; attempt += 1) {
220+
signal?.throwIfAborted()
221+
try {
222+
return await getUserPermissionConfig(userId, workspaceId)
223+
} catch (error) {
224+
signal?.throwIfAborted()
225+
if (
226+
attempt >= PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS ||
227+
!findDatabaseQueryError(error) ||
228+
!isRetryableInfrastructureError(error)
229+
) {
230+
throw error
231+
}
232+
233+
const delayMs = backoffWithJitter(attempt, null, PERMISSION_CONFIG_LOAD_RETRY_BACKOFF)
234+
logger.warn('Retrying permission config load after database error', {
235+
workspaceId,
236+
attempt,
237+
maxAttempts: PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS,
238+
delayMs,
239+
cause: describeError(error),
240+
})
241+
await sleep(delayMs)
242+
}
243+
}
244+
}
245+
246+
/**
247+
* Loads the governed subject's permission config. The subject is resolved here, not by callers,
248+
* so every gate reads the same person's group. On a run context the in-flight load is memoized per
249+
* subject and workspace in the run's `permissionConfigCache`, and a failed load is evicted. A shared
250+
* load observes only the run's abort signal, so one caller's cancellation cannot fail it for others;
251+
* an unshared load observes the caller's `signal`.
216252
*/
217253
async function getPermissionConfig(
218254
actorUserId: string | undefined,
219255
workspaceId: string | undefined,
220-
ctx?: ExecutionContext
256+
ctx?: ExecutionContext,
257+
signal?: AbortSignal
221258
): Promise<PermissionGroupConfig | null> {
222259
const userId = governedSubjectUserId(actorUserId, ctx)
223260
if (!userId || !workspaceId) {
224261
return mergeEnvAllowlist(null)
225262
}
226263

227-
if (ctx) {
228-
if (ctx.permissionConfigLoaded) {
229-
return ctx.permissionConfig ?? null
230-
}
231-
232-
const config = await getUserPermissionConfig(userId, workspaceId)
233-
ctx.permissionConfig = config
234-
ctx.permissionConfigLoaded = true
235-
return config
264+
const cache = ctx?.permissionConfigCache
265+
if (!cache) {
266+
return loadPermissionConfig(userId, workspaceId, signal ?? ctx?.abortSignal)
236267
}
237268

238-
return getUserPermissionConfig(userId, workspaceId)
269+
const key = `${userId}:${workspaceId}`
270+
const cached = cache.get(key)
271+
if (cached) return cached
272+
273+
const pending = loadPermissionConfig(userId, workspaceId, ctx?.abortSignal)
274+
cache.set(key, pending)
275+
pending.catch(() => {
276+
if (cache.get(key) === pending) cache.delete(key)
277+
})
278+
return pending
239279
}
240280

241281
/**
@@ -499,6 +539,8 @@ interface PermissionAssertion {
499539
toolId?: string
500540
toolKind?: ToolKind
501541
ctx?: ExecutionContext
542+
/** Caller cancellation, observed while loading a config that is not shared through a run cache. */
543+
signal?: AbortSignal
502544
}
503545

504546
/**
@@ -516,7 +558,7 @@ interface PermissionAssertion {
516558
/** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */
517559
/** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */
518560
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise<void> {
519-
const { workspaceId, model, blockType, toolId, toolKind, ctx } = req
561+
const { workspaceId, model, blockType, toolId, toolKind, ctx, signal } = req
520562
const userId = governedSubjectUserId(req.userId, ctx)
521563

522564
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
@@ -527,7 +569,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
527569

528570
const config =
529571
userId && workspaceId
530-
? await getPermissionConfig(userId, workspaceId, ctx)
572+
? await getPermissionConfig(userId, workspaceId, ctx, signal)
531573
: mergeEnvAllowlist(null)
532574

533575
const subject = { userId, workspaceId }

apps/sim/ee/access-control/utils/permission-gate-subject.test.ts

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { DrizzleQueryError } from 'drizzle-orm/errors'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

67
const mocks = vi.hoisted(() => ({
@@ -18,6 +19,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({
1819
isOrganizationOnEnterprisePlan: vi.fn(),
1920
}))
2021
vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn() }))
22+
vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }))
2123
vi.mock('@/providers/utils', () => ({
2224
isFunctionToolCall: () => false,
2325
getProviderFromModel: () => 'openai',
@@ -36,7 +38,10 @@ import {
3638
* field and keeps gating on the caller.
3739
*/
3840
function runDeclaring(capabilityGovernedUserId?: string | null): ExecutionContext {
39-
return { metadata: { capabilityGovernedUserId } } as unknown as ExecutionContext
41+
return {
42+
metadata: { capabilityGovernedUserId },
43+
permissionConfigCache: new Map(),
44+
} as unknown as ExecutionContext
4045
}
4146

4247
describe('the subject a run’s permission gate is decided about', () => {
@@ -164,3 +169,183 @@ describe('the group a run’s later gates read from its cache', () => {
164169
expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled()
165170
})
166171
})
172+
173+
function databaseError(code = 'ECONNRESET'): DrizzleQueryError {
174+
return new DrizzleQueryError(
175+
'select "billing_blocked" from "user_stats" where "user_stats"."user_id" = $1',
176+
['owner-secret-id'],
177+
Object.assign(new Error(`driver failure ${code}`), { code })
178+
)
179+
}
180+
181+
/** Every block runs on a shallow copy of the run's context, so the memo lives in a Map they share. */
182+
describe('the run-scoped permission config cache', () => {
183+
function runContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
184+
return {
185+
metadata: {},
186+
permissionConfigCache: new Map(),
187+
...overrides,
188+
} as unknown as ExecutionContext
189+
}
190+
191+
function gate(ctx: ExecutionContext, workspaceId = 'workspace-1') {
192+
return assertPermissionsAllowed({
193+
userId: 'user-1',
194+
workspaceId,
195+
toolId: 'http_request',
196+
ctx,
197+
})
198+
}
199+
200+
beforeEach(() => {
201+
vi.clearAllMocks()
202+
mocks.getUserPermissionConfig.mockResolvedValue({ deniedTools: [] })
203+
})
204+
205+
it('loads once across the per-block copies of one run', async () => {
206+
const run = runContext()
207+
208+
await gate({ ...run })
209+
await gate({ ...run })
210+
211+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1')
212+
})
213+
214+
it('shares one in-flight load between concurrent parallel branches', async () => {
215+
const run = runContext()
216+
let release!: (config: unknown) => void
217+
mocks.getUserPermissionConfig.mockReturnValueOnce(
218+
new Promise((resolve) => {
219+
release = resolve
220+
})
221+
)
222+
223+
const branches = Promise.all(Array.from({ length: 5 }, () => gate({ ...run })))
224+
release({ deniedTools: [] })
225+
await branches
226+
227+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1)
228+
})
229+
230+
it('keeps a separate entry per workspace', async () => {
231+
const run = runContext()
232+
mocks.getUserPermissionConfig.mockImplementation(async (_userId, workspaceId) =>
233+
workspaceId === 'workspace-2' ? { deniedTools: ['http_request'] } : { deniedTools: [] }
234+
)
235+
236+
await gate({ ...run }, 'workspace-1')
237+
await expect(gate({ ...run }, 'workspace-2')).rejects.toBeInstanceOf(ToolNotAllowedError)
238+
239+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
240+
})
241+
242+
it('evicts a failed load so a later gate loads again', async () => {
243+
const run = runContext()
244+
mocks.getUserPermissionConfig.mockRejectedValueOnce(new Error('config unavailable'))
245+
246+
await expect(gate({ ...run })).rejects.toThrow('config unavailable')
247+
await gate({ ...run })
248+
249+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
250+
})
251+
252+
it('retries a transient database failure and then caches the result', async () => {
253+
const run = runContext()
254+
mocks.getUserPermissionConfig.mockRejectedValueOnce(databaseError())
255+
256+
await gate({ ...run })
257+
await gate({ ...run })
258+
259+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
260+
})
261+
262+
it('does not retry a database failure that is not transient', async () => {
263+
const sqlError = databaseError('42703')
264+
mocks.getUserPermissionConfig.mockRejectedValue(sqlError)
265+
266+
await expect(gate(runContext())).rejects.toBe(sqlError)
267+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1)
268+
})
269+
270+
it('fails closed with the last error once retries are exhausted', async () => {
271+
const error = databaseError()
272+
mocks.getUserPermissionConfig.mockRejectedValue(error)
273+
274+
await expect(gate(runContext())).rejects.toBe(error)
275+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(3)
276+
})
277+
278+
it('stops retrying when the run is cancelled', async () => {
279+
const controller = new AbortController()
280+
const reason = new Error('Execution cancelled')
281+
mocks.getUserPermissionConfig.mockImplementationOnce(async () => {
282+
controller.abort(reason)
283+
throw databaseError()
284+
})
285+
286+
await expect(gate(runContext({ abortSignal: controller.signal }))).rejects.toBe(reason)
287+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1)
288+
})
289+
290+
it('does not memoize on a context that carries no run cache', async () => {
291+
const ctx = { metadata: {} } as unknown as ExecutionContext
292+
293+
await gate(ctx)
294+
await gate(ctx)
295+
296+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
297+
expect(ctx.permissionConfigCache).toBeUndefined()
298+
})
299+
300+
it('stops retrying when the caller of a check outside a run cancels', async () => {
301+
const controller = new AbortController()
302+
const reason = new Error('Tool cancelled')
303+
mocks.getUserPermissionConfig.mockImplementationOnce(async () => {
304+
controller.abort(reason)
305+
throw databaseError()
306+
})
307+
308+
await expect(
309+
assertPermissionsAllowed({
310+
userId: 'user-1',
311+
workspaceId: 'workspace-1',
312+
toolId: 'http_request',
313+
signal: controller.signal,
314+
})
315+
).rejects.toBe(reason)
316+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1)
317+
})
318+
319+
it('does not let one caller cancel a load shared through the run cache', async () => {
320+
const run = runContext()
321+
const controller = new AbortController()
322+
mocks.getUserPermissionConfig.mockImplementationOnce(async () => {
323+
controller.abort(new Error('Tool cancelled'))
324+
throw databaseError()
325+
})
326+
327+
const cancelled = assertPermissionsAllowed({
328+
userId: 'user-1',
329+
workspaceId: 'workspace-1',
330+
toolId: 'http_request',
331+
ctx: { ...run },
332+
signal: controller.signal,
333+
})
334+
const other = gate({ ...run })
335+
336+
await expect(Promise.all([cancelled, other])).resolves.toBeDefined()
337+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
338+
})
339+
340+
it('retries a transient failure for a check made outside a run', async () => {
341+
mocks.getUserPermissionConfig.mockRejectedValueOnce(databaseError())
342+
343+
await assertPermissionsAllowed({
344+
userId: 'user-1',
345+
workspaceId: 'workspace-1',
346+
toolId: 'http_request',
347+
})
348+
349+
expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2)
350+
})
351+
})

0 commit comments

Comments
 (0)