feat: restricted job tokens (job_token_scopes on scripts and flows)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-02 17:14:26 +02:00
co-authored by Claude Opus 5.5
parent f74113c75a
commit 06a5d73bb8
99 changed files with 3286 additions and 603 deletions
+15 -2
View File
@@ -52,6 +52,7 @@ export interface FlowFile {
schema?: any;
on_behalf_of_email?: string;
has_on_behalf_of?: boolean;
job_token_scopes?: string[] | null;
// Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through update_flow — so a perm-only
@@ -265,12 +266,22 @@ export async function pushFlow(
// so a perm-only edit never bumps the flow version. Strip the field from the
// body that goes to update_flow / create_flow and treat it as a separate
// step both for the up-to-date short-circuit and after the deploy.
const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & {
const {
extra_perms: localPerms,
job_token_scopes: localJobTokenScopes,
...localFlowBody
} = localFlow as FlowFile & {
extra_perms?: Record<string, boolean>;
};
// Always sent, unlike the other settings: the server keeps a restriction the body omits,
// so a flow.yaml without the key has to clear it explicitly.
const jobTokenScopes = localJobTokenScopes ?? null;
if (flow) {
if (isSuperset(localFlowBody, flow)) {
if (
isSuperset(localFlowBody, flow) &&
JSON.stringify(jobTokenScopes) === JSON.stringify(flow.job_token_scopes ?? null)
) {
log.info(colors.green(`Flow ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating flow ${remotePath}...`));
@@ -281,6 +292,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlowBody,
job_token_scopes: jobTokenScopes,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
@@ -296,6 +308,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlowBody,
job_token_scopes: jobTokenScopes,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
+4
View File
@@ -636,6 +636,9 @@ export async function handleFile(
envs: typed?.envs,
modules: modules,
labels: typed?.labels,
// Always sent: the server keeps a restriction the body omits, so a script.yaml without
// the key has to clear it explicitly.
job_token_scopes: typed?.job_token_scopes ?? null,
};
const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email;
@@ -712,6 +715,7 @@ export async function handleFile(
(hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) &&
deepEqual(typed.envs, remote.envs) &&
deepEqual(typed.labels ?? null, remote.labels ?? null) &&
deepEqual(typed.job_token_scopes ?? null, remote.job_token_scopes ?? null) &&
deepEqual(modules ?? null, remote.modules ?? null))
) {
log.info(colors.green(`Script ${remotePath} is up to date`));