fix(execution): resolve secrets against the acting principal, not the workflow owner (#6690)

* fix(execution): resolve secrets against the acting principal, not the workflow owner

* fix(execution): resolve anonymous public-API runs as the workspace billing account

* fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets
This commit is contained in:
Vikhyath Mondreti
2026-08-13 18:50:15 -07:00
committed by GitHub
parent 009f5fea7f
commit fa394e5e07
16 changed files with 488 additions and 20 deletions
+21
View File
@@ -239,6 +239,27 @@ export const workflow = pgTable(
'workflow',
{
id: text('id').primaryKey(),
/**
* Creator and owner. Legitimate as ownership: it anchors personal
* (workspace-less) workflows, cascades the workflow away with the account,
* and names the owner for webhook config and deploy-as-block resolution.
*
* @deprecated As an execution identity. Do not use it to decide who a run
* acts as, what it may read, or what it may authorize. The acting principal
* is `ExecutionMetadata.userId`, which the principal layer
* (`resolvePrincipalAttribution`) resolves to the caller for a session,
* personal API key, or delegated run, and to the workspace billing account
* for a workspace API key, schedule, or webhook.
*
* Exactly one execution use survives, carried as
* `ExecutionMetadata.workflowUserId`: the personal-environment fallback in
* `executeWorkflowCore`, for runs with no identifiable caller — workspace
* API keys, schedules, webhooks, and unauthenticated public-API calls. Those
* have nobody to resolve personal variables as, and a deployed workflow is
* routinely authored against its owner's personal keys, so dropping the
* fallback would break them. Workspace variables never fall back here; they
* always authorize against the actor.
*/
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
@@ -30,12 +30,63 @@ function emptyPersonalAndWorkspaceEnv(): {
* environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'k' })
* ```
*/
/**
* Mirrors the real resolver: one lookup when both identities match, two when the
* execution actor differs from the identity owning the personal variables.
* Delegating keeps `mockGetPersonalAndWorkspaceEnv` the single place a test has
* to stub environment data.
*/
async function delegateExecutionEnvironment(
personalUserId: string | undefined,
workspaceUserId: string,
workspaceId?: string
) {
const resolve = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv
if (personalUserId === undefined) {
const workspaceOnly = await resolve(workspaceUserId, workspaceId)
return {
...workspaceOnly,
personalEncrypted: {},
personalDecrypted: {},
personalOwners: {},
conflicts: [],
decryptionFailures: (workspaceOnly.decryptionFailures ?? []).filter(
(k: string) => k in (workspaceOnly.workspaceEncrypted ?? {})
),
}
}
if (!workspaceId || workspaceUserId === personalUserId) {
return resolve(personalUserId, workspaceId)
}
const [personal, actor] = await Promise.all([
resolve(personalUserId, workspaceId),
resolve(workspaceUserId, workspaceId),
])
const personalEncrypted = personal.personalEncrypted ?? {}
const workspaceEncrypted = actor.workspaceEncrypted ?? {}
return {
...personal,
workspaceEncrypted: actor.workspaceEncrypted,
workspaceDecrypted: actor.workspaceDecrypted,
conflicts: Object.keys(personalEncrypted).filter((key) => key in workspaceEncrypted),
decryptionFailures: [
...new Set([
...(personal.decryptionFailures ?? []).filter((k: string) => k in personalEncrypted),
...(actor.decryptionFailures ?? []).filter((k: string) => k in workspaceEncrypted),
]),
],
}
}
export const environmentUtilsMockFns = {
mockInvalidateEffectiveDecryptedEnvCache: vi.fn(),
mockGetEnvironmentVariableKeys: vi.fn().mockResolvedValue({ variableNames: [], count: 0 }),
mockGetPersonalAndWorkspaceEnv: vi
.fn()
.mockImplementation(async () => emptyPersonalAndWorkspaceEnv()),
mockGetExecutionEnvironment: vi.fn().mockImplementation(delegateExecutionEnvironment),
mockGetEffectiveEnvironmentSnapshot: vi
.fn()
.mockImplementation(async () => emptyPersonalAndWorkspaceEnv()),
@@ -55,6 +106,9 @@ export function resetEnvironmentUtilsMock(): void {
environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv
.mockReset()
.mockImplementation(async () => emptyPersonalAndWorkspaceEnv())
environmentUtilsMockFns.mockGetExecutionEnvironment
.mockReset()
.mockImplementation(delegateExecutionEnvironment)
environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot
.mockReset()
.mockImplementation(async () => emptyPersonalAndWorkspaceEnv())
@@ -79,6 +133,7 @@ export const environmentUtilsMock = {
environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache,
getEnvironmentVariableKeys: environmentUtilsMockFns.mockGetEnvironmentVariableKeys,
getPersonalAndWorkspaceEnv: environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv,
getExecutionEnvironment: environmentUtilsMockFns.mockGetExecutionEnvironment,
getEffectiveEnvironmentSnapshot: environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot,
upsertPersonalEnvVars: environmentUtilsMockFns.mockUpsertPersonalEnvVars,
upsertWorkspaceEnvVars: environmentUtilsMockFns.mockUpsertWorkspaceEnvVars,