Skip to content

Commit bc88931

Browse files
icecrasher321claude
andcommitted
fix(execution): suspend personal secrets for banned owners, and gate the ban candidate correctly
Review round 1 (Greptile P1 security, cubic P1 + P2). Removing the workflow owner from the ban gate let a suspended account's personal secrets keep flowing into background runs: a ban revokes neither workspace membership nor the pointer naming that person, so the run continued on their own keys. `getExecutionEnvironment` now drops the personal namespace when that identity is suspended, the same answer it already gives a departed one — the run survives, their credentials do not. Placing it in the shared resolver rather than in `execution-core` covers the webhook and custom-block paths too, and keeps the ban module out of the executor's import graph. The ban candidate itself was also inconsistent: callers overload `userId`, so it is an authenticated caller on a manual run but a stored pointer everywhere else — the workflow owner from `checkWebhookPreprocessing`, the chat's creator from the deployed-chat route, `'unknown'` from a schedule. Reading it unconditionally meant the same ban suspended a webhook while the schedule beside it kept running. It is now gated on `useAuthenticatedUserAsActor`, which is exactly the flag that distinguishes the two — `workflow-column-execution` toggles them together. Also corrects the custom-block authority TSDoc, which still claimed the owner supplies both environment slices after this branch split them. Regenerates the CLI API client for the v2 log contract change, which CI's `check:cli-api` audit caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ce15c49 commit bc88931

6 files changed

Lines changed: 130 additions & 33 deletions

File tree

apps/sim/lib/environment/utils.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ const {
1818
mockGetUserEntityPermissions,
1919
mockGetWorkspaceEnvKeyAdminAccess,
2020
mockRecordAudit,
21+
mockGetActivelyBannedUserIds,
2122
} = vi.hoisted(() => ({
2223
mockCreateWorkspaceEnvCredentials: vi.fn(),
2324
mockCheckWorkspaceAccess: vi.fn(),
2425
mockGetAccessibleEnvCredentials: vi.fn(),
2526
mockGetUserEntityPermissions: vi.fn(),
2627
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
2728
mockRecordAudit: vi.fn(),
29+
mockGetActivelyBannedUserIds: vi.fn().mockResolvedValue([]),
2830
}))
2931

3032
// vitest.setup.ts mocks this module globally; this suite tests the real one.
@@ -42,6 +44,9 @@ vi.mock('@/lib/credentials/environment', () => ({
4244
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
4345
syncPersonalEnvCredentialsForUser: vi.fn(),
4446
}))
47+
vi.mock('@/lib/auth/ban', () => ({
48+
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
49+
}))
4550
vi.mock('@/lib/workspaces/permissions/utils', () => ({
4651
checkWorkspaceAccess: mockCheckWorkspaceAccess,
4752
getUserEntityPermissions: mockGetUserEntityPermissions,
@@ -444,6 +449,7 @@ describe('getExecutionEnvironment', () => {
444449
vi.clearAllMocks()
445450
resetDbChainMock()
446451
mockGetAccessibleEnvCredentials.mockResolvedValue([])
452+
mockGetActivelyBannedUserIds.mockResolvedValue([])
447453
encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
448454
decrypted: `plain:${encryptedValue}`,
449455
}))
@@ -615,6 +621,39 @@ describe('getExecutionEnvironment', () => {
615621
expect(snapshot.workspaceDecrypted).toEqual({})
616622
})
617623

624+
/**
625+
* Admission deliberately stops blocking runs on the personal-variable
626+
* identity, so that a suspended member does not take down their teammates'
627+
* schedules and webhooks. That must not become a way for a suspended account's
628+
* own credentials to keep running — the run continues, their namespace does not.
629+
*/
630+
it('resolves workspace variables only when the personal identity is suspended', async () => {
631+
grantAdminTo('actor-1')
632+
mockGetActivelyBannedUserIds.mockResolvedValue(['suspended-owner'])
633+
queueTableRows(environment, [{ variables: { OWNER_KEY: 'owner-cipher' } }])
634+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
635+
636+
const snapshot = await getExecutionEnvironment('suspended-owner', 'actor-1', 'workspace-1')
637+
638+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['suspended-owner'])
639+
expect(snapshot.personalDecrypted).toEqual({})
640+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
641+
})
642+
643+
/** The actor is cleared by admission, so only the personal identity is looked up. */
644+
it('does not re-check the execution actor for a ban', async () => {
645+
grantAdminTo('actor-1')
646+
queueTableRows(environment, [{ variables: {} }])
647+
queueTableRows(workspaceEnvironment, [{ variables: {} }])
648+
queueTableRows(environment, [{ variables: {} }])
649+
queueTableRows(workspaceEnvironment, [{ variables: {} }])
650+
651+
await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1')
652+
653+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledOnce()
654+
expect(mockGetActivelyBannedUserIds.mock.calls[0][0]).not.toContain('actor-1')
655+
})
656+
618657
/** With no reachable identity there is nobody to authorize the workspace slice against. */
619658
it('raises when neither identity can reach the workspace', async () => {
620659
mockCheckWorkspaceAccess.mockResolvedValue({

apps/sim/lib/environment/utils.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import { eq, inArray } from 'drizzle-orm'
77
import { LRUCache } from 'lru-cache'
8+
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
89
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
910
import { lockPersonalEnvMap, lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
1011
import {
@@ -493,9 +494,10 @@ export async function getExecutionEnvironment(
493494
return getPersonalAndWorkspaceEnv(personalUserId, workspaceId)
494495
}
495496

496-
const [actorAccess, personalAccess] = await Promise.all([
497+
const [actorAccess, personalAccess, suspendedPersonalIds] = await Promise.all([
497498
checkWorkspaceAccess(workspaceId, workspaceUserId),
498499
checkWorkspaceAccess(workspaceId, personalUserId),
500+
getActivelyBannedUserIds([personalUserId]),
499501
])
500502

501503
/**
@@ -507,7 +509,23 @@ export async function getExecutionEnvironment(
507509
throw new Error(`Workspace ${workspaceId} does not exist`)
508510
}
509511

510-
if (!personalAccess.hasAccess) {
512+
/**
513+
* A suspended account lends nothing, even when the run itself may continue.
514+
*
515+
* Admission blocks on the identities a run acts as and deliberately not on the
516+
* personal-variable fallback, so that suspending one member does not take down
517+
* the schedules, webhooks, and deployed chats their teammates depend on. But
518+
* "this run may continue" and "that person's private credentials may still be
519+
* used" are different questions, and a ban revokes neither workspace
520+
* membership nor the pointer naming them — so without this the run proceeds on
521+
* a suspended account's own keys.
522+
*
523+
* Only the personal identity is checked here; admission already cleared the
524+
* actor before execution reached this point.
525+
*/
526+
const personalIdentitySuspended = suspendedPersonalIds.length > 0
527+
528+
if (!personalAccess.hasAccess || personalIdentitySuspended) {
511529
if (!actorAccess.hasAccess) {
512530
logger.error('Neither execution identity can reach the workspace', {
513531
personalUserId,
@@ -518,7 +536,9 @@ export async function getExecutionEnvironment(
518536
}
519537

520538
logger.error(
521-
'Personal-environment identity cannot reach the workspace; resolving workspace variables only',
539+
personalIdentitySuspended
540+
? 'Personal-environment identity is suspended; resolving workspace variables only'
541+
: 'Personal-environment identity cannot reach the workspace; resolving workspace variables only',
522542
{ personalUserId, workspaceUserId, workspaceId }
523543
)
524544
return toWorkspaceOnlySnapshot(

apps/sim/lib/execution/preprocessing.test.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -500,12 +500,29 @@ describe('preprocessExecution ban gate', () => {
500500
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
501501
})
502502

503-
it('checks the actor and the caller-provided userId in one call', async () => {
504-
const result = await preprocessExecution(baseOptions)
503+
/** An authenticated caller becomes the actor, so one candidate covers them. */
504+
it('checks the authenticated caller as the actor', async () => {
505+
const result = await preprocessExecution({ ...baseOptions, useAuthenticatedUserAsActor: true })
505506

506507
expect(result.success).toBe(true)
507508
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1)
508-
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'owner-1'])
509+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['owner-1'])
510+
})
511+
512+
/**
513+
* The one shape where the two genuinely differ: an upstream boundary captured
514+
* the attribution, so the actor comes from there while `userId` still names
515+
* the authenticated caller. Both are identities the run acts as.
516+
*/
517+
it('checks both when a captured attribution names a different actor', async () => {
518+
const result = await preprocessExecution({
519+
...baseOptions,
520+
useAuthenticatedUserAsActor: true,
521+
billingAttribution: { ...ORGANIZATION_ATTRIBUTION, actorUserId: 'delegated-actor-1' } as any,
522+
})
523+
524+
expect(result.success).toBe(true)
525+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['delegated-actor-1', 'owner-1'])
509526
})
510527

511528
it('excludes the "unknown" sentinel userId', async () => {
@@ -516,21 +533,34 @@ describe('preprocessExecution ban gate', () => {
516533
})
517534

518535
/**
519-
* Banning one member must not take down the schedules, webhooks, and deployed
520-
* chats their teammates depend on. Those runs act as the workspace billing
521-
* account; the owner's name on the workflow row is a personal-variable
522-
* fallback, and member removal reassigns it anyway.
536+
* Callers overload `userId`: an authenticated caller on a manual run, but a
537+
* stored pointer on a system-triggered one — the workflow owner from
538+
* `checkWebhookPreprocessing`, the chat's creator from the deployed-chat
539+
* route. Without `useAuthenticatedUserAsActor` gating it, the same ban
540+
* suspended a webhook while the schedule beside it kept running.
541+
*/
542+
it('ignores a stored-pointer userId when it is not the authenticated caller', async () => {
543+
const result = await preprocessExecution({ ...baseOptions, userId: 'creator-1' })
544+
545+
expect(result.success).toBe(true)
546+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1'])
547+
expect(mockGetActivelyBannedUserIds.mock.calls[0][0]).not.toContain('creator-1')
548+
})
549+
550+
/**
551+
* The webhook shape specifically: `checkWebhookPreprocessing` passes the
552+
* workflow owner as `userId` with no `useAuthenticatedUserAsActor`, so a
553+
* banned owner must not take the webhook down.
523554
*/
524555
it('does not block a system-triggered run because the workflow owner is banned', async () => {
525556
mockGetActivelyBannedUserIds.mockImplementation(async (ids: string[]) =>
526557
ids.filter((id) => id === 'creator-1')
527558
)
528559

529-
const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' })
560+
const result = await preprocessExecution({ ...baseOptions, userId: 'creator-1' })
530561

531562
expect(result.success).toBe(true)
532563
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1'])
533-
expect(mockGetActivelyBannedUserIds.mock.calls[0][0]).not.toContain('creator-1')
534564
})
535565

536566
it('fails closed with 500 when the ban check errors', async () => {

apps/sim/lib/execution/preprocessing.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -449,19 +449,26 @@ export async function preprocessExecution(
449449

450450
const banCheck = (async (): Promise<GateFailure | null> => {
451451
/**
452-
* Blocks when the resolved actor or the caller-provided user has an active
453-
* ban or blocked email domain — the identities this run actually acts as.
452+
* Blocks when an identity this run actually acts as has an active ban or
453+
* blocked email domain.
454454
*
455-
* The workflow owner is deliberately NOT a candidate. A system-triggered run
456-
* acts as the workspace billing account, and that account is already the
457-
* actor here; the owner is only the personal-variable fallback and is a
458-
* stored pointer that member removal reassigns. Banning one member of a
459-
* workspace should suspend the work they do, not silently take down every
460-
* schedule, webhook, and deployed chat their teammates still depend on
461-
* because their name happens to sit on the workflow row.
455+
* `userId` is only such an identity when `useAuthenticatedUserAsActor` says
456+
* so. Callers overload that parameter: it is an authenticated caller on a
457+
* manual or personal-key run, but a stored pointer everywhere else — the
458+
* workflow owner from `checkWebhookPreprocessing`, the chat's creator from
459+
* the deployed-chat route, the literal `'unknown'` from a schedule. Reading
460+
* it unconditionally made the same ban suspend a webhook while leaving the
461+
* schedule beside it running, for no reason a workspace could observe.
462+
* `workflow-column-execution` toggles the two together and is the clearest
463+
* statement of the rule.
464+
*
465+
* A stored pointer being banned must not take down work their teammates
466+
* still depend on — but it must not lend that person's credentials either,
467+
* which is why the executor drops a banned identity's personal namespace
468+
* rather than this gate blocking the whole run.
462469
*/
463470
const banCandidateIds = [actorUserId]
464-
if (userId && userId !== 'unknown' && userId !== actorUserId) {
471+
if (useAuthenticatedUserAsActor && userId && userId !== 'unknown' && userId !== actorUserId) {
465472
banCandidateIds.push(userId)
466473
}
467474
try {

apps/sim/lib/workflows/custom-blocks/operations.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -312,18 +312,18 @@ export async function getCustomBlockManageContext(id: string): Promise<{
312312
* cascade-deletes the workflow → the custom_block row, so there is never an
313313
* orphaned block. `null` when no enabled block matches the type.
314314
*
315-
* `ownerUserId` is the child run's whole identity — both environment slices, the
316-
* billing actor, and the subject of its delegated tool calls. That is NOT what a
317-
* deployed API/schedule/webhook run does: those act as the workspace billing
318-
* account and fall back to the owner only for personal variables. A custom block
319-
* needs the stronger form because it publishes a fixed behavior to consumers who
320-
* can see none of its internals, and the publisher's own integrations and personal
321-
* keys are part of that behavior.
315+
* `ownerUserId` carries further than the owner does on any other trigger. It is
316+
* the child run's actor, the personal-variable identity, and the subject of its
317+
* delegated tool calls, because a custom block publishes a fixed behavior to
318+
* consumers who can see none of its internals and the publisher's own
319+
* integrations and personal keys are part of that behavior.
322320
*
323-
* The cost is that the owner is load-bearing rather than a fallback: an owner who
324-
* leaves the source workspace takes the block's environment resolution down with
325-
* them, where a schedule on the same workflow keeps running. Any repair belongs
326-
* here or in the member-removal reassignment, not at the call site.
321+
* It is NOT the identity for the two things a workspace owns. Workspace
322+
* variables authorize against the source workspace's billing account, and that
323+
* account is the payer, exactly as they would for a schedule on the same
324+
* workflow — see the environment resolution in `workflow-handler`. Reading those
325+
* as the owner too gave a published block a narrower workspace-secret selection
326+
* than the workflow got on every other trigger, which no consumer could see.
327327
*/
328328
export async function getCustomBlockAuthority(
329329
type: string,

packages/sim-cli/src/generated/v2-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4038,6 +4038,7 @@ type GetLogResponseRef2 = {
40384038
endedAt: string | null
40394039
totalDurationMs: number | null
40404040
files: Array<GetLogResponseRef0> | null
4041+
executedByEmail: string | null
40414042
workflow: {
40424043
id: string | null
40434044
name: string

0 commit comments

Comments
 (0)