Compare commits

...

22 Commits

Author SHA1 Message Date
Ruben Fiszel
f77b4227a4 Merge origin/main into cli-onbehaflof 2026-04-11 13:09:46 +00:00
wendrul
ac0d9531f3 add username/email compat 2026-04-09 18:11:24 +02:00
wendrul
18e0724422 Merge remote-tracking branch 'origin/main' into cli-onbehaflof 2026-04-09 17:49:14 +02:00
wendrul
1d1253c916 Merge remote-tracking branch 'origin/main' into cli-onbehaflof 2026-04-07 14:52:52 +02:00
wendrul
dd9a61d880 change email to username 2026-04-07 14:52:34 +02:00
wendrul
f48f4ded70 Merge remote-tracking branch 'origin/main' into cli-onbehaflof 2026-03-31 19:07:08 +02:00
wendrul
ecd02fa322 Merge branch 'main' into cli-onbehaflof 2026-03-31 18:47:03 +02:00
wendrul
84c2817520 fix: hasOnBehalfOf instead of no field on yamls 2026-03-31 18:17:04 +02:00
wendrul
07501aadaf fixes after refactor 2026-03-26 12:30:11 +01:00
wendrul
a0754c215f Merge remote-tracking branch 'origin/main' into cli-onbehaflof 2026-03-26 10:57:37 +01:00
wendrul
30c632a4f2 Merge branch 'main' into cli-onbehaflof 2026-03-10 19:28:29 +01:00
wendrul
992d0c3cc4 wanr and fail if clibhaviour is higher than the current cli supports 2026-03-10 18:24:04 +01:00
wendrul
fac0dab3b4 change version field to cliBehavior with v1,v2,v3 2026-03-09 20:15:48 +01:00
wendrul
1dc2859016 Merge remote-tracking branch 'origin/main' into cli-onbehaflof 2026-03-09 18:50:08 +01:00
wendrul
937555f876 Add logs for when permissioned as is dealt with 2026-03-09 18:19:32 +01:00
wendrul
50a945f9c8 add tests 2026-03-09 16:30:50 +01:00
wendrul
c4495db2e6 remove debug logs 2026-03-09 16:01:35 +01:00
wendrul
29bb77b0cb fix: use a version counter instead plus gitBranches detection issue 2026-03-05 19:14:01 +01:00
wendrul
9e5857cd9b backwards compat: add a flag to enable theis behaviour
defaults to enabled on wmill init, but preserves old behaviour if
wmill.yaml already present without the flag. Upgrade is also simple
2026-03-04 17:45:44 +01:00
wendrul
aa71ff6ec2 Remove error thrown 2026-03-04 17:26:00 +01:00
wendrul
dbbe192b3c preserve on_behalf_of by default (with prompt if no rights to do so) 2026-03-04 17:22:47 +01:00
wendrul
d724fa6cef Add set-permissioned-as command in cli 2026-03-04 15:18:31 +01:00
12 changed files with 1591 additions and 48 deletions

View File

@@ -18,6 +18,8 @@ import lintCommand from "./lint.ts";
import newCommand from "./new.ts";
import generateAgentsCommand from "./generate_agents.ts";
import { isVersionsGeq1585 } from "../sync/global.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { resolvePermissionedAsRule, resolveRuleEmail, resolveRuleUsername, ruleLabel, lookupUsernameByEmail } from "../../core/permissioned_as.ts";
export interface AppFile {
value: any;
@@ -105,7 +107,8 @@ export async function pushApp(
workspace: string,
remotePath: string,
localPath: string,
message?: string
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
@@ -122,6 +125,15 @@ export async function pushApp(
} catch {
//ignore
}
// Save remote policy ownership fields before clearing (needed for preserve)
let remoteOnBehalfOf: string | undefined;
let remoteOnBehalfOfEmail: string | undefined;
if (app?.policy) {
remoteOnBehalfOf = app.policy.on_behalf_of;
remoteOnBehalfOfEmail = app.policy.on_behalf_of_email;
log.debug(`Remote app ${remotePath} policy: on_behalf_of=${remoteOnBehalfOf}, on_behalf_of_email=${remoteOnBehalfOfEmail}`);
}
if (isExecutionModeAnonymous(app)) {
app.public = true;
}
@@ -143,30 +155,78 @@ export async function pushApp(
localApp?.["public"] ??
localApp?.["policy"]?.["execution_mode"] == "anonymous"
);
// Build preserve flags for permissioned_as
const preserveFields: { preserve_on_behalf_of?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (app) {
// Updating: inject remote's on_behalf_of into the freshly generated policy
// The backend requires policy.on_behalf_of to be set for preserve to work
if (localApp.policy && remoteOnBehalfOf) {
(localApp.policy as any).on_behalf_of = remoteOnBehalfOf;
(localApp.policy as any).on_behalf_of_email = remoteOnBehalfOfEmail;
preserveFields.preserve_on_behalf_of = true;
log.info(`Preserving ${remoteOnBehalfOfEmail ?? remoteOnBehalfOf} as permissioned_as for app ${remotePath}`);
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const rule = resolvePermissionedAsRule(
remotePath,
permissionedAsContext.rules
);
if (rule) {
// Set both on_behalf_of and on_behalf_of_email on the policy
// The backend requires on_behalf_of to be set for preserve to work
if (localApp.policy) {
const username = await resolveRuleUsername(
workspace,
rule,
permissionedAsContext.userCache
);
const email = await resolveRuleEmail(
workspace,
rule,
permissionedAsContext.userCache
);
(localApp.policy as any).on_behalf_of = username;
(localApp.policy as any).on_behalf_of_email = email;
}
preserveFields.preserve_on_behalf_of = true;
log.info(`Setting app ${remotePath} to run permissioned as ${ruleLabel(rule)} (matched rule '${rule.path_pattern}' in wmill.yaml)`);
}
}
}
if (app) {
if (isSuperset(localApp, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
return;
}
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
const requestBody = {
deployment_message: message,
...localApp,
...preserveFields,
};
log.debug(`App ${remotePath} update request: preserve_on_behalf_of=${requestBody.preserve_on_behalf_of}, policy.on_behalf_of=${(requestBody as any).policy?.on_behalf_of}, policy.on_behalf_of_email=${(requestBody as any).policy?.on_behalf_of_email}`);
await wmill.updateApp({
workspace,
path: remotePath,
requestBody: {
deployment_message: message,
...localApp,
},
requestBody,
});
} else {
log.info(colors.yellow.bold("Creating new app..."));
const requestBody = {
path: remotePath,
deployment_message: message,
...localApp,
...preserveFields,
};
log.debug(`App ${remotePath} create request: preserve_on_behalf_of=${requestBody.preserve_on_behalf_of}, policy.on_behalf_of=${(requestBody as any).policy?.on_behalf_of}, policy.on_behalf_of_email=${(requestBody as any).policy?.on_behalf_of_email}`);
await wmill.createApp({
workspace,
requestBody: {
path: remotePath,
deployment_message: message,
...localApp,
},
requestBody,
});
}
}
@@ -264,6 +324,36 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
}
}
async function setPermissionedAs(
opts: GlobalOptions,
appPath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
// Look up username for the email — backend requires on_behalf_of (u/<username>) to be set
const cache = new Map<string, string>();
const username = await lookupUsernameByEmail(workspace.workspaceId, email, cache);
await wmill.updateApp({
workspace: workspace.workspaceId,
path: appPath,
requestBody: {
policy: {
on_behalf_of: `u/${username}`,
on_behalf_of_email: email,
} as any,
preserve_on_behalf_of: true,
},
});
log.info(
colors.green(
`Updated permissioned_as for app ${appPath} to ${email}`
)
);
}
const command = new Command()
.description("app related commands")
.option("--json", "Output as JSON (for piping to jq)")
@@ -282,6 +372,12 @@ const command = new Command()
.command("lint", lintCommand)
.command("new", newCommand)
.command("generate-agents", generateAgentsCommand)
.command(
"set-permissioned-as",
"Set the on_behalf_of_email for an app (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action(setPermissionedAs as any)
.command(
"generate-locks",
'DEPRECATED: re-generate app lockfiles. Use "wmill generate-metadata" instead.'

View File

@@ -19,13 +19,15 @@ import { resolve, track_job, pollForJobResult } from "../script/script.ts";
import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import { Flow, OpenFlowWPath } from "../../../gen/types.gen.ts";
import {
collectPathScriptPaths,
replaceInlineScripts,
replaceAllPathScriptsWithLocal,
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { resolvePermissionedAsRule, resolveRuleEmail, ruleLabel } from "../../core/permissioned_as.ts";
import { exts } from "../script/script.ts";
import type { SyncCodebase } from "../../utils/codebase.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
@@ -39,6 +41,8 @@ export interface FlowFile {
description?: string;
value: any;
schema?: any;
on_behalf_of_email?: string;
has_on_behalf_of?: boolean;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
@@ -131,7 +135,8 @@ export async function pushFlow(
workspace: string,
remotePath: string,
localPath: string,
message?: string
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
@@ -177,6 +182,39 @@ export async function pushFlow(
));
}
// Extract CLI-only field before sending to API
const hasOnBehalfOf = localFlow.has_on_behalf_of ?? !!localFlow.on_behalf_of_email;
delete (localFlow as any).has_on_behalf_of;
// Build preserve flags for permissioned_as
const preserveFields: Partial<OpenFlowWPath> = {};
if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) {
if (flow) {
// Updating: preserve the remote's on_behalf_of_email (only if it has one)
if (flow.on_behalf_of_email) {
preserveFields.on_behalf_of_email = flow.on_behalf_of_email;
preserveFields.preserve_on_behalf_of = true;
log.info(`Preserving ${flow.on_behalf_of_email} as permissioned_as for flow ${remotePath}`);
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const rule = resolvePermissionedAsRule(
remotePath,
permissionedAsContext.rules
);
if (rule) {
const email = await resolveRuleEmail(
workspace,
rule,
permissionedAsContext.userCache
);
preserveFields.on_behalf_of_email = email;
preserveFields.preserve_on_behalf_of = true;
log.info(`Setting flow ${remotePath} to run permissioned as ${ruleLabel(rule)} (matched rule '${rule.path_pattern}' in wmill.yaml)`);
}
}
}
if (flow) {
if (isSuperset(localFlow, flow)) {
log.info(colors.green(`Flow ${remotePath} is up to date`));
@@ -190,6 +228,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...preserveFields,
},
});
} else {
@@ -201,6 +240,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...preserveFields,
},
});
} catch (e) {
@@ -717,6 +757,30 @@ export async function bootstrap(
writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" });
}
async function setPermissionedAs(
opts: GlobalOptions,
flowPath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await wmill.updateFlow({
workspace: workspace.workspaceId,
path: flowPath,
requestBody: {
path: flowPath,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
} as any,
});
log.info(
colors.green(
`Updated permissioned_as for flow ${flowPath} to ${email}`
)
);
}
async function history(
opts: GlobalOptions & { json?: boolean },
flowPath: string
@@ -860,6 +924,12 @@ const command = new Command()
.option("--summary <summary:string>", "flow summary")
.option("--description <description:string>", "flow description")
.action(bootstrap as any)
.command(
"set-permissioned-as",
"Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action(setPermissionedAs as any)
.command("history", "Show version history for a flow")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")

View File

@@ -19,6 +19,13 @@ import {
removeType,
} from "../../types.ts";
import { Schedule } from "../../../gen/types.gen.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
resolvePermissionedAsRule,
resolveRuleUsername,
ruleLabel,
lookupUsernameByEmail,
} from "../../core/permissioned_as.ts";
export interface ScheduleFile {
schedule: string;
@@ -103,7 +110,8 @@ export async function pushSchedule(
workspace: string,
path: string,
schedule: Schedule | ScheduleFile | undefined,
localSchedule: ScheduleFile
localSchedule: ScheduleFile,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
path = removeType(path, "schedule").replaceAll(SEP, "/");
log.debug(`Processing local schedule ${path}`);
@@ -117,6 +125,35 @@ export async function pushSchedule(
//ignore
}
// Build preserve flags for permissioned_as
const preserveFields: { permissioned_as?: string; preserve_permissioned_as?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (schedule) {
// Updating: preserve the remote's permissioned_as (u/username format)
preserveFields.preserve_permissioned_as = true;
if ((schedule as Schedule).permissioned_as) {
preserveFields.permissioned_as = (schedule as Schedule).permissioned_as;
log.info(`Preserving ${(schedule as Schedule).permissioned_as} as permissioned_as for schedule ${path}`);
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const rule = resolvePermissionedAsRule(
path,
permissionedAsContext.rules
);
if (rule) {
const username = await resolveRuleUsername(
workspace,
rule,
permissionedAsContext.userCache
);
preserveFields.permissioned_as = username;
preserveFields.preserve_permissioned_as = true;
log.info(`Setting schedule ${path} to run permissioned as ${ruleLabel(rule)} (matched rule '${rule.path_pattern}' in wmill.yaml)`);
}
}
}
if (schedule) {
if (isSuperset(localSchedule, schedule)) {
log.debug(`Schedule ${path} is up to date`);
@@ -132,6 +169,7 @@ export async function pushSchedule(
path,
requestBody: {
...localSchedule,
...preserveFields,
},
});
if (localSchedule.enabled != schedule.enabled) {
@@ -158,6 +196,7 @@ export async function pushSchedule(
requestBody: {
path: path,
...localSchedule,
...preserveFields,
},
});
} catch (e) {
@@ -219,6 +258,36 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
console.log(colors.bold.underline.green("Schedule pushed"));
}
async function setPermissionedAs(
opts: GlobalOptions,
schedulePath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const cache = new Map<string, string>();
const username = await lookupUsernameByEmail(
workspace.workspaceId,
email,
cache
);
await wmill.updateSchedule({
workspace: workspace.workspaceId,
path: schedulePath,
requestBody: {
permissioned_as: `u/${username}`,
preserve_permissioned_as: true,
} as any,
});
log.info(
colors.green(
`Updated permissioned_as for schedule ${schedulePath} to ${email} (username: ${username})`
)
);
}
const command = new Command()
.description("schedule related commands")
.option("--json", "Output as JSON (for piping to jq)")
@@ -239,6 +308,12 @@ const command = new Command()
)
.arguments("<file_path:string> <remote_path:string>")
.action(push as any)
.command(
"set-permissioned-as",
"Set the email (run-as user) for a schedule (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action(setPermissionedAs as any)
.command("enable", "Enable a schedule")
.arguments("<path:string>")
.action(enable as any)

View File

@@ -57,6 +57,8 @@ import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
import { execSync } from "node:child_process";
import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { resolvePermissionedAsRule, resolveRuleEmail, ruleLabel } from "../../core/permissioned_as.ts";
import {
isRawAppBackendPath as isRawAppBackendPathInternal,
isAppInlineScriptPath as isAppInlineScriptPathInternal,
@@ -229,7 +231,8 @@ export async function handleScriptMetadata(
message: string | undefined,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[],
opts: GlobalOptions
opts: GlobalOptions,
permissionedAsContext?: PermissionedAsContext
): Promise<boolean> {
// Flat layout: my_script.script.yaml
const isFlatMeta = path.endsWith(".script.json") ||
@@ -250,7 +253,8 @@ export async function handleScriptMetadata(
message,
opts,
rawWorkspaceDependencies,
codebases
codebases,
permissionedAsContext
);
} else {
return false;
@@ -272,7 +276,8 @@ export async function handleFile(
message: string | undefined,
opts: (GlobalOptions & { defaultTs?: "bun" | "deno" } & Skips) | undefined,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[]
codebases: SyncCodebase[],
permissionedAsContext?: PermissionedAsContext
): Promise<boolean> {
// Detect module entry point: e.g., my_script__mod/script.ts
const moduleEntryPoint = isModuleEntryPoint(path);
@@ -481,9 +486,37 @@ export async function handleFile(
labels: typed?.labels,
};
// console.log(requestBodyCommon.codebase);
// log.info(JSON.stringify(requestBodyCommon, null, 2))
// log.info(JSON.stringify(opts, null, 2))
// Compute whether original remote had on_behalf_of set
const hasOnBehalfOf = typed?.has_on_behalf_of ?? !!typed?.on_behalf_of_email;
// Add preserve flags for permissioned_as
if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) {
if (remote) {
// Updating: preserve the remote's on_behalf_of_email (only if it has one)
if (remote.on_behalf_of_email) {
requestBodyCommon.on_behalf_of_email = remote.on_behalf_of_email;
requestBodyCommon.preserve_on_behalf_of = true;
log.info(`Preserving ${remote.on_behalf_of_email} as permissioned_as for script ${remotePath}`);
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const rule = resolvePermissionedAsRule(
remotePath,
permissionedAsContext.rules
);
if (rule) {
const email = await resolveRuleEmail(
workspace,
rule,
permissionedAsContext.userCache
);
requestBodyCommon.on_behalf_of_email = email;
requestBodyCommon.preserve_on_behalf_of = true;
log.info(`Setting script ${remotePath} to run permissioned as ${ruleLabel(rule)} (matched rule '${rule.path_pattern}' in wmill.yaml)`);
}
}
}
if (remote) {
if (content === remote.content) {
if (
@@ -518,7 +551,7 @@ export async function handleFile(
typed.debounce_key == remote["debounce_key"] &&
typed.debounce_delay_s == remote["debounce_delay_s"] &&
typed.codebase == remote.codebase &&
typed.on_behalf_of_email == remote.on_behalf_of_email &&
(typed.has_on_behalf_of !== undefined ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) &&
deepEqual(typed.envs, remote.envs) &&
deepEqual(modules ?? null, remote.modules ?? null))
) {
@@ -1599,6 +1632,51 @@ async function preview(
}
}
async function setPermissionedAs(
opts: GlobalOptions,
scriptPath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const remote = await wmill.getScriptByPath({
workspace: workspace.workspaceId,
path: scriptPath,
});
if (!remote) {
throw new Error(`Script ${scriptPath} not found`);
}
const body: NewScript = {
content: remote.content,
description: remote.description,
language: remote.language as NewScript["language"],
path: remote.path,
summary: remote.summary,
kind: remote.kind as NewScript["kind"],
lock: Array.isArray(remote.lock)
? remote.lock.join("\n")
: remote.lock ?? undefined,
schema: remote.schema,
tag: remote.tag ?? undefined,
parent_hash: remote.hash,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
};
await wmill.createScript({
workspace: workspace.workspaceId,
requestBody: body,
});
log.info(
colors.green(
`Updated permissioned_as for script ${scriptPath} to ${email}`
)
);
}
async function history(
opts: GlobalOptions & { json?: boolean },
scriptPath: string
@@ -1715,6 +1793,12 @@ const command = new Command()
"Comma separated patterns to specify which file to NOT take into account."
)
.action(generateMetadata as any)
.command(
"set-permissioned-as",
"Set the on_behalf_of_email for a script (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action(setPermissionedAs as any)
.command(
"history",
"show version history for a script"

View File

@@ -46,6 +46,7 @@ import {
import {
getEffectiveSettings,
mergeConfigWithConfigFile,
parseCliBehavior,
SyncOptions,
validateBranchConfiguration,
findWorkspaceByGitBranch,
@@ -81,6 +82,11 @@ import {
import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { isExecutionModeAnonymous } from "../app/app.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
preCheckPermissionedAs,
validatePermissionedAsRules,
} from "../../core/permissioned_as.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,
@@ -612,6 +618,7 @@ function ZipFSElement(
resourceTypeToFormatExtension: Record<string, string>,
resourceTypeToIsFileset: Record<string, boolean>,
ignoreCodebaseChanges: boolean,
stripOnBehalfOf: boolean,
): DynFSElement {
// Pre-scan: find zip base paths of scripts that have modules.
// These scripts use the folder layout: {basePath}__mod/script.{ext}
@@ -762,6 +769,11 @@ function ZipFSElement(
};
}
if (stripOnBehalfOf) {
(flow as any).has_on_behalf_of = !!(flow as any).on_behalf_of_email;
delete (flow as any).on_behalf_of_email;
}
yield {
isDirectory: false,
path: path.join(finalPath, "flow.yaml"),
@@ -1042,6 +1054,10 @@ function ZipFSElement(
if (ignoreCodebaseChanges && parsed["codebase"]) {
parsed["codebase"] = undefined;
}
if (stripOnBehalfOf) {
parsed["has_on_behalf_of"] = !!parsed["on_behalf_of_email"];
delete parsed["on_behalf_of_email"];
}
// Modules are stored as files in __mod/ folder, not in metadata
delete parsed["modules"];
return useYaml
@@ -2210,6 +2226,7 @@ export async function pull(
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
true,
parseCliBehavior(opts.cliBehavior) >= 1,
);
const local = !opts.stateful
@@ -2619,7 +2636,7 @@ function removeSuffix(str: string, suffix: string) {
}
export async function push(
opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string },
opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string; acceptOverridingPermissionedAsWithSelf?: boolean },
) {
if ((opts as any).jsonOutput) log.setSilent(true);
// Save original CLI options before merging with config file
@@ -2768,6 +2785,7 @@ export async function push(
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
false,
parseCliBehavior(opts.cliBehavior) >= 1,
);
const local = await FSFSElement(path.join(process.cwd(), ""), codebases, false);
@@ -3036,6 +3054,36 @@ export async function push(
log.info(colors.gray(`Dry run complete.`));
return;
}
// Build permissioned_as context (only when respectVirtualUserPermissions is enabled)
let permissionedAsContext: PermissionedAsContext | undefined = undefined;
if (parseCliBehavior(opts.cliBehavior) >= 1) {
const user = await wmill.whoami({ workspace: workspace.workspaceId });
const userIsAdminOrDeployer =
user.is_admin || (user.groups ?? []).includes("wm_deployers");
log.debug(`permissioned_as: user=${user.email}, is_admin=${user.is_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}`);
const validatedRules = validatePermissionedAsRules(
opts.defaultPermissionedAs,
"wmill.yaml"
);
log.debug(`permissioned_as: ${validatedRules.length} rules loaded`);
permissionedAsContext = {
rules: validatedRules,
userCache: new Map(),
userIsAdminOrDeployer,
};
// Pre-check: warn about permissioned_as changes
await preCheckPermissionedAs(
changes,
user.email,
userIsAdminOrDeployer,
opts.acceptOverridingPermissionedAsWithSelf ?? false,
!!process.stdin.isTTY,
validatedRules
);
}
if (
!opts.yes &&
!(await Confirm.prompt({
@@ -3133,6 +3181,7 @@ export async function push(
rawWorkspaceDependencies,
codebases,
opts,
permissionedAsContext,
)
) {
if (stateTarget) {
@@ -3148,6 +3197,7 @@ export async function push(
opts,
rawWorkspaceDependencies,
codebases,
permissionedAsContext,
)
) {
if (stateTarget) {
@@ -3267,6 +3317,7 @@ export async function push(
alreadySynced,
opts.message,
originalWorkspaceSpecificPath,
permissionedAsContext,
);
if (stateTarget) {
@@ -3290,6 +3341,7 @@ export async function push(
opts,
rawWorkspaceDependencies,
codebases,
permissionedAsContext,
)
) {
continue;
@@ -3336,6 +3388,7 @@ export async function push(
[],
opts.message,
localFilePath, // Pass the actual local file path
permissionedAsContext,
);
if (stateTarget) {
@@ -3871,6 +3924,10 @@ const command = new Command()
"--locks-required",
"Fail if scripts or flow inline scripts that need locks have no locks",
)
.option(
"--accept-overriding-permissioned-as-with-self",
"Accept that items with a different permissioned_as will be updated with your own user",
)
.option("--auto-metadata", "Automatically regenerate stale metadata (locks and schemas) before pushing")
.action(push as any);

View File

@@ -37,6 +37,13 @@ import {
import { getCurrentGitBranch } from "../../utils/git.ts";
import { requireLogin } from "../../core/auth.ts";
import { validatePath, resolveWorkspace } from "../../core/context.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
resolvePermissionedAsRule,
resolveRuleUsername,
ruleLabel,
lookupUsernameByEmail,
} from "../../core/permissioned_as.ts";
type Trigger = {
http: HttpTrigger;
@@ -56,6 +63,7 @@ type TriggerFile<K extends TriggerType> = Omit<
| "workspace"
| "edited_by"
| "edited_at"
| "permissioned_as"
| "error"
| "last_server_ping"
| "server_id"
@@ -149,7 +157,8 @@ export async function pushTrigger<K extends TriggerType>(
workspace: string,
path: string,
trigger: TriggerFile<K> | Trigger[K] | undefined,
localTrigger: TriggerFile<K>
localTrigger: TriggerFile<K>,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
path = removeType(path, triggerType + "_trigger").replaceAll(SEP, "/");
log.debug(`Processing local ${triggerType} trigger ${path}`);
@@ -162,6 +171,35 @@ export async function pushTrigger<K extends TriggerType>(
//ignore
}
// Build preserve flags for permissioned_as
const preserveFields: { permissioned_as?: string; preserve_permissioned_as?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (trigger) {
// Updating: preserve the remote's permissioned_as (u/username format)
preserveFields.preserve_permissioned_as = true;
if ((trigger as any).permissioned_as) {
preserveFields.permissioned_as = (trigger as any).permissioned_as;
log.info(`Preserving ${(trigger as any).permissioned_as} as permissioned_as for trigger ${path}`);
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const rule = resolvePermissionedAsRule(
path,
permissionedAsContext.rules
);
if (rule) {
const username = await resolveRuleUsername(
workspace,
rule,
permissionedAsContext.userCache
);
preserveFields.permissioned_as = username;
preserveFields.preserve_permissioned_as = true;
log.info(`Setting trigger ${path} to run permissioned as ${ruleLabel(rule)} (matched rule '${rule.path_pattern}' in wmill.yaml)`);
}
}
}
if (trigger) {
if (isSuperset(localTrigger, trigger)) {
log.debug(`${triggerType} trigger ${path} is up to date`);
@@ -171,6 +209,7 @@ export async function pushTrigger<K extends TriggerType>(
try {
await updateTrigger(triggerType, workspace, path, {
...localTrigger,
...preserveFields,
path,
} as Trigger[K]);
} catch (e) {
@@ -184,6 +223,7 @@ export async function pushTrigger<K extends TriggerType>(
try {
await createTrigger(triggerType, workspace, path, {
...localTrigger,
...preserveFields,
path,
} as Trigger[K]);
} catch (e) {
@@ -594,6 +634,47 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
console.log(colors.bold.underline.green("Trigger pushed"));
}
async function setPermissionedAs(
opts: GlobalOptions & { kind?: string },
triggerPath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!opts.kind) {
throw new Error(
"--kind is required. Valid kinds: " + TRIGGER_TYPES.join(", ")
);
}
if (!checkIfValidTrigger(opts.kind)) {
throw new Error(
"Invalid trigger kind: " +
opts.kind +
". Valid kinds: " +
TRIGGER_TYPES.join(", ")
);
}
const cache = new Map<string, string>();
const username = await lookupUsernameByEmail(
workspace.workspaceId,
email,
cache
);
await updateTrigger(opts.kind, workspace.workspaceId, triggerPath, {
permissioned_as: `u/${username}`,
preserve_permissioned_as: true,
path: triggerPath,
} as any);
log.info(
colors.green(
`Updated permissioned_as for ${opts.kind} trigger ${triggerPath} to ${email} (username: ${username})`
)
);
}
const command = new Command()
.description("trigger related commands")
.option("--json", "Output as JSON (for piping to jq)")
@@ -615,6 +696,16 @@ const command = new Command()
"push a local trigger spec. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.action(push as any);
.action(push as any)
.command(
"set-permissioned-as",
"Set the email (run-as user) for a trigger (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.option(
"--kind <kind:string>",
"Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)"
)
.action(setPermissionedAs as any);
export default command;

View File

@@ -12,6 +12,16 @@ import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import { setNonDottedPaths } from "../utils/resource_folders.ts";
import type { PermissionedAsRule } from "./permissioned_as.ts";
export const SUPPORTED_CLI_BEHAVIOR_VERSION = 1;
// Parse cliBehavior version string (e.g. "v1", "v2") into a number. Returns 0 if absent/invalid.
export function parseCliBehavior(value?: string): number {
if (!value) return 0;
const match = value.match(/^v(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
}
export let showDiffs = false;
export function setShowDiffs(value: boolean) {
@@ -34,6 +44,7 @@ export interface WorkspaceEntryConfig extends SyncOptions {
overrides?: Partial<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
specificItems?: SpecificItemsConfig_Yaml;
defaultPermissionedAs?: PermissionedAsRule[];
}
export type WorkspacesConfig = {
@@ -98,6 +109,8 @@ export interface SyncOptions {
promotion?: string;
lint?: boolean;
locksRequired?: boolean;
defaultPermissionedAs?: PermissionedAsRule[];
cliBehavior?: string;
}
export interface Codebase {
@@ -274,6 +287,15 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis
// Initialize global nonDottedPaths setting from config
setNonDottedPaths(conf?.nonDottedPaths ?? false);
// Exit if the config specifies a cliBehavior version higher than what this CLI supports
const cliBehaviorVersion = parseCliBehavior(conf?.cliBehavior);
if (cliBehaviorVersion > SUPPORTED_CLI_BEHAVIOR_VERSION) {
log.error(
`Your wmill.yaml specifies cliBehavior: ${conf!.cliBehavior}, but this CLI only supports up to v${SUPPORTED_CLI_BEHAVIOR_VERSION}. Run 'wmill upgrade' to update.`
);
process.exit(1);
}
return typeof conf == "object" ? conf : ({} as SyncOptions);
} catch (e) {
if (
@@ -333,6 +355,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly<
| "includeSettings"
| "includeKey"
| "nonDottedPaths"
| "cliBehavior"
>
>
> = {
@@ -356,6 +379,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly<
includeKey: false,
skipWorkspaceDependencies: false,
nonDottedPaths: false,
cliBehavior: "v1",
} as const;
export async function mergeConfigWithConfigFile<T>(
@@ -581,23 +605,33 @@ export async function getEffectiveSettings(
`No promotion or regular overrides found for '${promotion}', using top-level settings`
);
}
// Workspace-level defaultPermissionedAs takes priority over root-level
if (targetWs.defaultPermissionedAs) {
effective.defaultPermissionedAs = targetWs.defaultPermissionedAs;
}
}
}
// Otherwise use resolved workspace's overrides
else if (resolvedWsEntry?.overrides) {
Object.assign(effective, resolvedWsEntry.overrides);
if (!suppressLogs) {
const extraLog = originalBranchIfForked
? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)`
: "";
log.info(
`Applied settings for workspace '${resolvedWsName}'${extraLog}`
else if (resolvedWsEntry) {
if (resolvedWsEntry.overrides) {
Object.assign(effective, resolvedWsEntry.overrides);
if (!suppressLogs) {
const extraLog = originalBranchIfForked
? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)`
: "";
log.info(
`Applied settings for workspace '${resolvedWsName}'${extraLog}`
);
}
} else if (resolvedWsName) {
log.debug(
`No overrides found for workspace '${resolvedWsName}', using top-level settings`
);
}
} else if (resolvedWsName) {
log.debug(
`No overrides found for workspace '${resolvedWsName}', using top-level settings`
);
// Workspace-level defaultPermissionedAs takes priority over root-level
if (resolvedWsEntry.defaultPermissionedAs) {
effective.defaultPermissionedAs = resolvedWsEntry.defaultPermissionedAs;
}
}
return effective;

View File

@@ -0,0 +1,402 @@
import { minimatch } from "minimatch";
import * as wmill from "../../gen/services.gen.ts";
import * as log from "./log.ts";
import { colors } from "@cliffy/ansi/colors";
import { Confirm } from "@cliffy/prompt/confirm";
import { getTypeStrFromPath } from "../types.ts";
export interface PermissionedAsRule {
username?: string;
email?: string;
path_pattern: string;
}
export interface PermissionedAsContext {
rules: PermissionedAsRule[];
userCache: Map<string, { username: string; email: string }>;
userIsAdminOrDeployer: boolean;
}
const KNOWN_RULE_FIELDS = new Set(["username", "email", "path_pattern"]);
/**
* Validates defaultPermissionedAs rules from wmill.yaml.
* Throws on invalid rules so the user gets a clear error before any push happens.
*/
export function validatePermissionedAsRules(
rules: unknown,
source: string
): PermissionedAsRule[] {
if (rules === undefined || rules === null) {
return [];
}
if (!Array.isArray(rules)) {
throw new Error(
`Invalid defaultPermissionedAs in ${source}: expected an array of rules, got ${typeof rules}`
);
}
const validated: PermissionedAsRule[] = [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
const ruleLabel = `defaultPermissionedAs[${i}] in ${source}`;
if (typeof rule !== "object" || rule === null) {
throw new Error(`Invalid ${ruleLabel}: expected an object with ('username' or 'email') and 'path_pattern' fields`);
}
// Check for unknown/misspelled fields
const unknownFields = Object.keys(rule).filter((k) => !KNOWN_RULE_FIELDS.has(k));
if (unknownFields.length > 0) {
throw new Error(
`Invalid ${ruleLabel}: unknown field(s) ${unknownFields.map((f) => `'${f}'`).join(", ")}. ` +
`Valid fields are: 'username', 'email', 'path_pattern'`
);
}
// Validate: must have exactly one of username or email
const hasUsername = typeof rule.username === "string" && rule.username.trim() !== "";
const hasEmail = typeof rule.email === "string" && rule.email.trim() !== "";
if (!hasUsername && !hasEmail) {
throw new Error(
`Invalid ${ruleLabel}: either 'username' (e.g. 'u/admin') or 'email' is required`
);
}
if (hasUsername && hasEmail) {
throw new Error(
`Invalid ${ruleLabel}: provide either 'username' or 'email', not both`
);
}
if (typeof rule.path_pattern !== "string" || rule.path_pattern.trim() === "") {
throw new Error(
`Invalid ${ruleLabel}: 'path_pattern' is required and must be a non-empty string`
);
}
// Validate glob pattern by testing it
try {
minimatch("test/path", rule.path_pattern);
} catch (e) {
throw new Error(
`Invalid ${ruleLabel}: 'path_pattern' "${rule.path_pattern}" is not a valid glob pattern: ${
e instanceof Error ? e.message : e
}`
);
}
validated.push({
...(hasUsername ? { username: rule.username } : { email: rule.email }),
path_pattern: rule.path_pattern,
});
}
return validated;
}
/**
* Resolves which rule should be used for a new item based on defaultPermissionedAs rules.
* Returns the matching rule, or undefined if no rule matches.
*/
export function resolvePermissionedAsRule(
path: string,
rules: PermissionedAsRule[]
): PermissionedAsRule | undefined {
for (const rule of rules) {
if (minimatch(path, rule.path_pattern)) {
return rule;
}
}
return undefined;
}
/**
* Populates the user cache from the workspace users API (fetched once).
*/
async function ensureUserCache(
workspace: string,
cache: Map<string, { username: string; email: string }>
): Promise<void> {
if (cache.size > 0) return;
const users = await wmill.listUsers({ workspace });
for (const user of users) {
cache.set(user.username, { username: user.username, email: user.email });
cache.set(user.email, { username: user.username, email: user.email });
}
}
/**
* Resolves the email for a rule. If the rule already has an email, returns it directly.
* If it has a username, looks up the email from the workspace users cache.
*/
export async function resolveRuleEmail(
workspace: string,
rule: PermissionedAsRule,
cache: Map<string, { username: string; email: string }>
): Promise<string> {
if (rule.email) return rule.email;
await ensureUserCache(workspace, cache);
const entry = cache.get(rule.username!);
if (!entry) {
throw new Error(
`Could not find user '${rule.username}' in workspace. Make sure the user exists.`
);
}
return entry.email;
}
/**
* Resolves the username (in u/username format) for a rule. If the rule already has a username,
* returns it directly. If it has an email, looks up the username from the workspace users cache.
*/
export async function resolveRuleUsername(
workspace: string,
rule: PermissionedAsRule,
cache: Map<string, { username: string; email: string }>
): Promise<string> {
if (rule.username) return rule.username;
await ensureUserCache(workspace, cache);
const entry = cache.get(rule.email!);
if (!entry) {
throw new Error(
`Could not find user with email '${rule.email}' in workspace. Make sure the user exists.`
);
}
return `u/${entry.username}`;
}
/** Returns a display label for a rule (whichever identifier was provided). */
export function ruleLabel(rule: PermissionedAsRule): string {
return rule.username ?? rule.email!;
}
/**
* Looks up a username by email using the workspace users API.
* Used by standalone set-permissioned-as commands that accept an email argument.
*/
export async function lookupUsernameByEmail(
workspace: string,
email: string,
cache: Map<string, string>
): Promise<string> {
if (cache.has(email)) {
return cache.get(email)!;
}
if (cache.size === 0) {
const users = await wmill.listUsers({ workspace });
for (const user of users) {
cache.set(user.email, user.username);
}
}
const username = cache.get(email);
if (!username) {
throw new Error(
`Could not find username for email '${email}' in workspace. ` +
`Make sure the user exists in the workspace.`
);
}
return username;
}
export interface Change {
name: "edited" | "added" | "deleted";
path: string;
before?: string;
after?: string;
content?: string;
}
/**
* Extract the remote path (used for rule matching) from a local file path.
*/
function extractRemotePathForRuleCheck(filePath: string, typeStr: string): string | undefined {
if (typeStr === "script") {
const match = filePath.match(/^(.+)\.script\.(yaml|json)$/);
return match ? match[1] : undefined;
}
if (typeStr === "flow") {
// Handle both .flow/ and __flow/ suffixes
const match = filePath.match(/^(.+?)(?:\.flow|__flow)\//);
return match ? match[1] : undefined;
}
return undefined;
}
/**
* Check if content has on_behalf_of set (new has_on_behalf_of boolean or legacy email).
*/
function contentHasOnBehalfOf(content: string, typeStr: string): boolean {
if (typeStr === "script") {
return !!content.match(/has_on_behalf_of:\s*(true)/) ||
!!content.match(/on_behalf_of_email:\s*["']?([^\s"']+)["']?/);
}
if (typeStr === "flow") {
return !!content.match(/has_on_behalf_of:\s*(true)/);
}
return false;
}
/**
* Pre-checks whether items being pushed will have their permissioned_as/email changed.
*
* For admins/deployers editing existing items, preserve flags handle ownership — no warning needed.
* For admins/deployers creating items with has_on_behalf_of but no matching rule, warn/prompt.
* For non-admin/non-deployer users, the API will silently overwrite the owner to the deploying user.
*/
export async function preCheckPermissionedAs(
changes: Change[],
userEmail: string,
userIsAdminOrDeployer: boolean,
acceptOverride: boolean,
isInteractive: boolean,
rules: PermissionedAsRule[] = []
): Promise<void> {
const wouldChangeItems: { path: string; currentOwner: string }[] = [];
for (const change of changes) {
let typeStr: string;
try {
typeStr = getTypeStrFromPath(change.path);
} catch {
continue;
}
// --- "added" changes: new items being created ---
if (change.name === "added") {
const content = change.content;
if (!content) continue;
const isScriptMeta = typeStr === "script" &&
(change.path.endsWith(".script.yaml") || change.path.endsWith(".script.json"));
const isFlowMeta = typeStr === "flow" &&
(change.path.endsWith("flow.yaml") || change.path.endsWith("flow.json"));
if ((isScriptMeta || isFlowMeta) && contentHasOnBehalfOf(content, typeStr)) {
if (userIsAdminOrDeployer) {
// Admins can apply rules — only flag if no rule matches this path
const remotePath = extractRemotePathForRuleCheck(change.path, typeStr);
if (!remotePath || !resolvePermissionedAsRule(remotePath, rules)) {
const label = typeStr === "script" ? "(script owner)" : "(flow owner)";
wouldChangeItems.push({ path: change.path, currentOwner: label });
}
} else {
const label = typeStr === "script" ? "(script owner)" : "(flow owner)";
wouldChangeItems.push({ path: change.path, currentOwner: label });
}
} else if (typeStr === "app" && !userIsAdminOrDeployer) {
wouldChangeItems.push({ path: change.path, currentOwner: "(app policy owner)" });
}
continue;
}
// --- "edited" changes ---
if (change.name !== "edited") {
continue;
}
// For edits, admins preserve from remote — no warning needed
if (userIsAdminOrDeployer) {
continue;
}
const beforeContent = change.before;
if (!beforeContent) continue;
let currentOwner: string | undefined;
if (typeStr === "script") {
// Script metadata is in .script.yaml files
if (
change.path.endsWith(".script.yaml") ||
change.path.endsWith(".script.json")
) {
// New format: has_on_behalf_of boolean
const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/);
if (hasOboMatch) {
currentOwner = "(script owner)";
} else {
// Legacy format: on_behalf_of_email directly in file
const emailMatch = beforeContent.match(
/on_behalf_of_email:\s*["']?([^\s"']+)["']?/
);
if (emailMatch) {
currentOwner = emailMatch[1];
}
}
}
} else if (typeStr === "flow") {
// Only flag when has_on_behalf_of: true is present
if (change.path.endsWith("flow.yaml") || change.path.endsWith("flow.json")) {
const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/);
if (hasOboMatch) {
wouldChangeItems.push({
path: change.path,
currentOwner: "(flow owner)",
});
}
}
continue;
} else if (typeStr === "app") {
// Apps always have on_behalf_of set - any edited app will change owner
wouldChangeItems.push({
path: change.path,
currentOwner: "(app policy owner)",
});
continue;
} else if (typeStr === "schedule") {
const match = beforeContent.match(
/email:\s*["']?([^\s"']+)["']?/
);
if (match) {
currentOwner = match[1];
}
} else if (typeStr.endsWith("_trigger")) {
// Trigger email/edited_by is stripped during sync pull, so we can't
// reliably detect the current owner from local files. Always flag it.
wouldChangeItems.push({
path: change.path,
currentOwner: "(trigger owner)",
});
continue;
}
if (currentOwner && currentOwner !== userEmail) {
wouldChangeItems.push({ path: change.path, currentOwner });
}
}
if (wouldChangeItems.length === 0) {
return;
}
const itemList = wouldChangeItems
.map((item) => ` - ${item.path} (current owner: ${item.currentOwner})`)
.join("\n");
const message = userIsAdminOrDeployer
? `The following ${wouldChangeItems.length} item(s) have on_behalf_of set but no matching defaultPermissionedAs rule in wmill.yaml. ` +
`They will be created with your user (${userEmail}) as permissioned_as:\n${itemList}`
: `You are not an admin or member of 'wm_deployers'. The following ${wouldChangeItems.length} item(s) ` +
`will have their permissioned_as/email changed to your user (${userEmail}):\n${itemList}`;
if (acceptOverride) {
log.warn(colors.yellow(`Warning: ${message}`));
return;
}
if (isInteractive) {
log.warn(colors.yellow(message));
const proceed = await Confirm.prompt({
message:
"Do you want to proceed? (use --accept-overriding-permissioned-as-with-self to skip this prompt)",
default: false,
});
if (!proceed) {
log.info("Push cancelled.");
process.exit(0);
}
} else {
log.error(colors.red(`${message}\n\nUse --accept-overriding-permissioned-as-with-self to proceed anyway.`));
process.exit(1);
}
}

View File

@@ -30,6 +30,7 @@ import {
buildFolderPath,
isScriptModulePath,
} from "./utils/resource_folders.ts";
import type { PermissionedAsContext } from "./core/permissioned_as.ts";
export interface DifferenceCreate {
type: "CREATE";
@@ -152,7 +153,8 @@ export async function pushObj(
plainSecrets: boolean,
alreadySynced: string[],
message?: string,
originalLocalPath?: string
originalLocalPath?: string,
permissionedAsContext?: PermissionedAsContext
) {
const typeEnding = getTypeStrFromPath(p);
@@ -161,7 +163,7 @@ export async function pushObj(
if (!appName) {
throw new Error(`Could not extract app name from path: ${p}`);
}
await pushApp(workspace, appName, buildFolderPath(appName, "app"), message);
await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext);
} else if (typeEnding === "raw_app") {
const rawAppName = extractResourceName(p, "raw_app");
if (!rawAppName) {
@@ -177,7 +179,7 @@ export async function pushObj(
if (!flowName) {
throw new Error(`Could not extract flow name from path: ${p}`);
}
await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message);
await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message, permissionedAsContext);
} else if (typeEnding === "resource") {
if (!alreadySynced.includes(p)) {
alreadySynced.push(p);
@@ -186,25 +188,25 @@ export async function pushObj(
} else if (typeEnding === "resource-type") {
await pushResourceType(workspace, p, befObj, newObj);
} else if (typeEnding === "schedule") {
await pushSchedule(workspace, p, befObj, newObj);
await pushSchedule(workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "http_trigger") {
await pushTrigger("http", workspace, p, befObj, newObj);
await pushTrigger("http", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "websocket_trigger") {
await pushTrigger("websocket", workspace, p, befObj, newObj);
await pushTrigger("websocket", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "kafka_trigger") {
await pushTrigger("kafka", workspace, p, befObj, newObj);
await pushTrigger("kafka", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "nats_trigger") {
await pushTrigger("nats", workspace, p, befObj, newObj);
await pushTrigger("nats", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "postgres_trigger") {
await pushTrigger("postgres", workspace, p, befObj, newObj);
await pushTrigger("postgres", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "mqtt_trigger") {
await pushTrigger("mqtt", workspace, p, befObj, newObj);
await pushTrigger("mqtt", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "sqs_trigger") {
await pushTrigger("sqs", workspace, p, befObj, newObj);
await pushTrigger("sqs", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "gcp_trigger") {
await pushTrigger("gcp", workspace, p, befObj, newObj);
await pushTrigger("gcp", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "email_trigger") {
await pushTrigger("email", workspace, p, befObj, newObj);
await pushTrigger("email", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "native_trigger") {
await pushNativeTrigger(workspace, p, befObj, newObj);
} else if (typeEnding === "user") {

View File

@@ -0,0 +1,39 @@
import { expect, test, describe } from "bun:test";
import { parseCliBehavior } from "../src/core/conf.ts";
describe("parseCliBehavior", () => {
test("parses v1 to 1", () => {
expect(parseCliBehavior("v1")).toBe(1);
});
test("parses v2 to 2", () => {
expect(parseCliBehavior("v2")).toBe(2);
});
test("parses v10 to 10", () => {
expect(parseCliBehavior("v10")).toBe(10);
});
test("returns 0 for undefined", () => {
expect(parseCliBehavior(undefined)).toBe(0);
});
test("returns 0 for empty string", () => {
expect(parseCliBehavior("")).toBe(0);
});
test("returns 0 for invalid format", () => {
expect(parseCliBehavior("0.1")).toBe(0);
expect(parseCliBehavior("1")).toBe(0);
expect(parseCliBehavior("version1")).toBe(0);
expect(parseCliBehavior("V1")).toBe(0);
});
test("version comparisons work correctly", () => {
expect(parseCliBehavior("v1") >= 1).toBe(true);
expect(parseCliBehavior("v2") >= 1).toBe(true);
expect(parseCliBehavior(undefined) >= 1).toBe(false);
expect(parseCliBehavior("v1") >= 2).toBe(false);
expect(parseCliBehavior("v2") >= 2).toBe(true);
});
});

View File

@@ -0,0 +1,148 @@
import { expect, test } from "bun:test";
import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts";
// =============================================================================
// CONF.TS TARGET WORKSPACE TESTS
// Tests for getEffectiveSettings with targetWorkspace parameter
// Verifies that defaultPermissionedAs is resolved based on the target workspace
// =============================================================================
const ROOT_RULES = [
{ email: "root@example.com", path_pattern: "**" },
];
const STAGING_RULES = [
{ email: "staging-deployer@example.com", path_pattern: "f/staging/**" },
];
const PROD_RULES = [
{ email: "prod-deployer@example.com", path_pattern: "f/prod/**" },
];
function makeConfig(opts?: { rootRules?: boolean }): SyncOptions {
return {
defaultTs: "bun",
includes: ["f/**"],
defaultPermissionedAs: opts?.rootRules !== false ? ROOT_RULES : undefined,
gitBranches: {
staging: {
workspaceId: "staging-ws",
baseUrl: "https://staging.windmill.dev/",
overrides: {},
defaultPermissionedAs: STAGING_RULES,
},
production: {
workspaceId: "prod-ws",
baseUrl: "https://prod.windmill.dev/",
overrides: {},
defaultPermissionedAs: PROD_RULES,
},
},
};
}
test("targetWorkspace: uses matching branch's defaultPermissionedAs", async () => {
const config = makeConfig();
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
{ workspaceId: "staging-ws", remote: "https://staging.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(STAGING_RULES);
});
test("targetWorkspace: uses correct branch when workspace differs from git branch", async () => {
const config = makeConfig();
// On staging branch, but targeting production workspace
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
{ workspaceId: "prod-ws", remote: "https://prod.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(PROD_RULES);
});
test("targetWorkspace: falls back to root-level rules when no branch matches workspace", async () => {
const config = makeConfig();
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
{ workspaceId: "unknown-ws", remote: "https://other.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(ROOT_RULES);
});
test("targetWorkspace: falls back to root-level when matched branch has no rules", async () => {
const config: SyncOptions = {
defaultTs: "bun",
defaultPermissionedAs: ROOT_RULES,
gitBranches: {
staging: {
workspaceId: "staging-ws",
baseUrl: "https://staging.windmill.dev/",
overrides: {},
// No defaultPermissionedAs defined
},
},
};
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
{ workspaceId: "staging-ws", remote: "https://staging.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(ROOT_RULES);
});
test("targetWorkspace: requires both workspaceId AND baseUrl to match", async () => {
const config = makeConfig();
// Same workspaceId but different baseUrl — should NOT match staging branch
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
{ workspaceId: "staging-ws", remote: "https://other.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(ROOT_RULES);
});
test("targetWorkspace: without targetWorkspace, uses branch-level rules as before", async () => {
const config = makeConfig();
// No targetWorkspace — should use the branch's rules (existing behavior)
const settings = await getEffectiveSettings(
config, undefined, true, true, "staging",
);
expect(settings.defaultPermissionedAs).toEqual(STAGING_RULES);
});
test("targetWorkspace: without gitBranches, targetWorkspace is ignored", async () => {
const config: SyncOptions = {
defaultTs: "bun",
defaultPermissionedAs: ROOT_RULES,
};
const settings = await getEffectiveSettings(
config, undefined, true, true, undefined,
{ workspaceId: "any-ws", remote: "https://any.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toEqual(ROOT_RULES);
});
test("targetWorkspace: no root-level rules and no match returns undefined", async () => {
const config: SyncOptions = {
defaultTs: "bun",
gitBranches: {
staging: {
workspaceId: "staging-ws",
baseUrl: "https://staging.windmill.dev/",
overrides: {},
defaultPermissionedAs: STAGING_RULES,
},
},
};
const settings = await getEffectiveSettings(
config, undefined, true, true, undefined,
{ workspaceId: "unknown-ws", remote: "https://other.windmill.dev/" },
);
expect(settings.defaultPermissionedAs).toBeUndefined();
});

View File

@@ -0,0 +1,445 @@
/**
* Unit tests for permissioned_as.ts: rule validation, rule resolution, and pre-check logic.
*/
import { expect, test, describe } from "bun:test";
import { mock } from "bun:test";
import {
validatePermissionedAsRules,
resolvePermissionedAsRule,
preCheckPermissionedAs,
type PermissionedAsRule,
type Change,
} from "../src/core/permissioned_as.ts";
// =============================================================================
// validatePermissionedAsRules
// =============================================================================
describe("validatePermissionedAsRules", () => {
test("returns empty array for undefined input", () => {
expect(validatePermissionedAsRules(undefined, "wmill.yaml")).toEqual([]);
});
test("returns empty array for null input", () => {
expect(validatePermissionedAsRules(null, "wmill.yaml")).toEqual([]);
});
test("returns empty array for empty array input", () => {
expect(validatePermissionedAsRules([], "wmill.yaml")).toEqual([]);
});
test("validates a correct rule with username", () => {
const rules = [{ username: "u/admin", path_pattern: "f/**" }];
const result = validatePermissionedAsRules(rules, "wmill.yaml");
expect(result).toEqual([
{ username: "u/admin", path_pattern: "f/**" },
]);
});
test("validates a correct rule with email", () => {
const rules = [{ email: "admin@company.com", path_pattern: "f/**" }];
const result = validatePermissionedAsRules(rules, "wmill.yaml");
expect(result).toEqual([
{ email: "admin@company.com", path_pattern: "f/**" },
]);
});
test("validates mixed username and email rules", () => {
const rules = [
{ username: "u/admin", path_pattern: "f/production/**" },
{ email: "deploy@company.com", path_pattern: "f/**" },
];
const result = validatePermissionedAsRules(rules, "wmill.yaml");
expect(result).toHaveLength(2);
expect(result[0].username).toBe("u/admin");
expect(result[1].email).toBe("deploy@company.com");
});
test("throws on non-array input", () => {
expect(() =>
validatePermissionedAsRules("not-an-array", "wmill.yaml")
).toThrow("expected an array of rules, got string");
});
test("throws on object input", () => {
expect(() =>
validatePermissionedAsRules(
{ username: "u/admin", path_pattern: "f/**" },
"wmill.yaml"
)
).toThrow("expected an array of rules, got object");
});
test("throws on non-object rule entry", () => {
expect(() =>
validatePermissionedAsRules(["not-an-object"], "wmill.yaml")
).toThrow(
"defaultPermissionedAs[0] in wmill.yaml: expected an object with ('username' or 'email') and 'path_pattern' fields"
);
});
test("throws on null rule entry", () => {
expect(() =>
validatePermissionedAsRules([null], "wmill.yaml")
).toThrow(
"defaultPermissionedAs[0] in wmill.yaml: expected an object with ('username' or 'email') and 'path_pattern' fields"
);
});
// --- Unknown/misspelled fields ---
test("throws on misspelled 'username' field", () => {
expect(() =>
validatePermissionedAsRules(
[{ usernamee: "u/admin", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'usernamee'");
});
test("throws on misspelled 'path_pattern' field", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin", pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'pattern'");
});
test("throws on extra unknown field", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin", path_pattern: "f/**", extra_field: true }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'extra_field'");
});
test("throws listing multiple unknown fields", () => {
expect(() =>
validatePermissionedAsRules(
[{ usernamee: "u/admin", pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'usernamee', 'pattern'");
});
test("error message includes valid field names", () => {
expect(() =>
validatePermissionedAsRules(
[{ usernamee: "u/admin", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("Valid fields are: 'username', 'email', 'path_pattern'");
});
// --- Missing required fields ---
test("throws when neither username nor email provided", () => {
expect(() =>
validatePermissionedAsRules(
[{ path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("either 'username' (e.g. 'u/admin') or 'email' is required");
});
test("throws on empty username with no email", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("either 'username' (e.g. 'u/admin') or 'email' is required");
});
test("throws when both username and email provided", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin", email: "admin@co.com", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("provide either 'username' or 'email', not both");
});
test("throws on missing path_pattern (username rule)", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin" }],
"wmill.yaml"
)
).toThrow("'path_pattern' is required and must be a non-empty string");
});
test("throws on missing path_pattern (email rule)", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "admin@co.com" }],
"wmill.yaml"
)
).toThrow("'path_pattern' is required and must be a non-empty string");
});
test("throws on empty path_pattern", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin", path_pattern: "" }],
"wmill.yaml"
)
).toThrow("'path_pattern' is required and must be a non-empty string");
});
test("throws on non-string path_pattern", () => {
expect(() =>
validatePermissionedAsRules(
[{ username: "u/admin", path_pattern: 42 }],
"wmill.yaml"
)
).toThrow("'path_pattern' is required and must be a non-empty string");
});
// --- Error index ---
test("error message includes rule index", () => {
expect(() =>
validatePermissionedAsRules(
[
{ username: "u/admin", path_pattern: "f/**" },
{ username: "", path_pattern: "f/**" },
],
"wmill.yaml"
)
).toThrow("defaultPermissionedAs[1]");
});
// --- Source label ---
test("error message includes source label", () => {
expect(() =>
validatePermissionedAsRules("bad", "gitBranches.main")
).toThrow("in gitBranches.main");
});
});
// =============================================================================
// resolvePermissionedAsRule
// =============================================================================
describe("resolvePermissionedAsRule", () => {
const rules: PermissionedAsRule[] = [
{ username: "u/prod", path_pattern: "f/production/**" },
{ username: "u/staging", path_pattern: "f/staging/**" },
{ username: "u/default", path_pattern: "f/**" },
];
test("returns matching rule for specific path", () => {
const rule = resolvePermissionedAsRule("f/production/my_script", rules);
expect(rule?.username).toBe("u/prod");
expect(rule?.path_pattern).toBe("f/production/**");
});
test("returns first matching rule (production over default)", () => {
const rule = resolvePermissionedAsRule("f/production/deep/nested", rules);
expect(rule?.username).toBe("u/prod");
expect(rule?.path_pattern).toBe("f/production/**");
});
test("returns staging rule for staging path", () => {
const rule = resolvePermissionedAsRule("f/staging/my_flow", rules);
expect(rule?.username).toBe("u/staging");
expect(rule?.path_pattern).toBe("f/staging/**");
});
test("falls through to default rule", () => {
const rule = resolvePermissionedAsRule("f/other/my_script", rules);
expect(rule?.username).toBe("u/default");
expect(rule?.path_pattern).toBe("f/**");
});
test("returns undefined when no rule matches", () => {
expect(
resolvePermissionedAsRule("u/admin/my_script", rules)
).toBeUndefined();
});
test("returns undefined for empty rules", () => {
expect(resolvePermissionedAsRule("f/anything", [])).toBeUndefined();
});
test("handles exact path patterns", () => {
const exactRules: PermissionedAsRule[] = [
{ username: "u/exact", path_pattern: "f/specific/script" },
];
const rule = resolvePermissionedAsRule("f/specific/script", exactRules);
expect(rule?.username).toBe("u/exact");
expect(
resolvePermissionedAsRule("f/specific/other", exactRules)
).toBeUndefined();
});
test("handles single-level wildcard", () => {
const wildcardRules: PermissionedAsRule[] = [
{ username: "u/wild", path_pattern: "f/*/scripts" },
];
const rule = resolvePermissionedAsRule("f/team_a/scripts", wildcardRules);
expect(rule?.username).toBe("u/wild");
expect(
resolvePermissionedAsRule("f/team_a/nested/scripts", wildcardRules)
).toBeUndefined();
});
});
// =============================================================================
// preCheckPermissionedAs — has_on_behalf_of gating
// =============================================================================
describe("preCheckPermissionedAs", () => {
const userEmail = "user@example.com";
// Helper to check if preCheck would exit (flag items)
async function expectFlagged(fn: () => Promise<void>) {
const originalExit = process.exit;
let exitCalled = false;
process.exit = ((code?: number) => { exitCalled = true; }) as any;
try {
await fn();
expect(exitCalled).toBe(true);
} finally {
process.exit = originalExit;
}
}
// Helper: make a script edit change
function scriptEdit(before: string): Change {
return {
name: "edited",
path: "f/my_script.script.yaml",
before,
after: "summary: updated\n",
};
}
// Helper: make a script added change
function scriptAdded(content: string, path = "f/my_script.script.yaml"): Change {
return { name: "added", path, content };
}
// Helper: make a flow edit change
function flowEdit(before: string): Change {
return {
name: "edited",
path: "f/my_flow.flow/flow.yaml",
before,
after: "summary: updated\n",
};
}
// Helper: make a flow added change
function flowAdded(content: string, path = "f/my_flow.flow/flow.yaml"): Change {
return { name: "added", path, content };
}
// --- Non-admin, edited changes ---
test("non-admin: edited script with has_on_behalf_of: true is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([scriptEdit("summary: test\nhas_on_behalf_of: true\n")], userEmail, false, false, false)
);
});
test("non-admin: edited script with has_on_behalf_of: false is not flagged", async () => {
await preCheckPermissionedAs([scriptEdit("summary: test\nhas_on_behalf_of: false\n")], userEmail, false, false, false);
});
test("non-admin: edited flow with has_on_behalf_of: true is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([flowEdit("summary: test\nhas_on_behalf_of: true\n")], userEmail, false, false, false)
);
});
test("non-admin: edited flow with has_on_behalf_of: false is not flagged", async () => {
await preCheckPermissionedAs([flowEdit("summary: test\nhas_on_behalf_of: false\n")], userEmail, false, false, false);
});
test("non-admin: legacy script with on_behalf_of_email is still flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([scriptEdit("summary: test\non_behalf_of_email: foo@bar.com\n")], userEmail, false, false, false)
);
});
test("non-admin: script without obo fields is not flagged", async () => {
await preCheckPermissionedAs([scriptEdit("summary: test\n")], userEmail, false, false, false);
});
// --- Non-admin, added changes ---
test("non-admin: added script with has_on_behalf_of: true is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, false, false, false)
);
});
test("non-admin: added flow with has_on_behalf_of: true is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([flowAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, false, false, false)
);
});
test("non-admin: added script with has_on_behalf_of: false is not flagged", async () => {
await preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: false\n")], userEmail, false, false, false);
});
// --- Admin, edited changes (preserve handles these — not flagged) ---
test("admin: edited script with has_on_behalf_of: true is not flagged (preserve handles)", async () => {
await preCheckPermissionedAs([scriptEdit("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false);
});
test("admin: edited flow with has_on_behalf_of: true is not flagged (preserve handles)", async () => {
await preCheckPermissionedAs([flowEdit("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false);
});
// --- Admin, added changes (no remote to preserve — rule check) ---
test("admin: added script with has_on_behalf_of: true and no rule is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false, [])
);
});
test("admin: added flow with has_on_behalf_of: true and no rule is flagged", async () => {
await expectFlagged(() =>
preCheckPermissionedAs([flowAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false, [])
);
});
test("admin: added script with has_on_behalf_of: true and matching rule is not flagged", async () => {
const rules = [{ username: "u/admin", path_pattern: "f/**" }];
await preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false, rules);
});
test("admin: added flow with has_on_behalf_of: true and matching rule is not flagged", async () => {
const rules = [{ username: "u/admin", path_pattern: "f/**" }];
await preCheckPermissionedAs([flowAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false, rules);
});
test("admin: added script with has_on_behalf_of: true and matching email rule is not flagged", async () => {
const rules = [{ email: "admin@co.com", path_pattern: "f/**" }];
await preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: true\n")], userEmail, true, false, false, rules);
});
test("admin: added script with has_on_behalf_of: false is not flagged (no obo)", async () => {
await preCheckPermissionedAs([scriptAdded("summary: test\nhas_on_behalf_of: false\n")], userEmail, true, false, false, []);
});
// --- acceptOverride flag ---
test("flagged items with acceptOverride: true logs warning but does not exit", async () => {
// Should return normally (warning logged but no exit)
await preCheckPermissionedAs(
[scriptAdded("summary: test\nhas_on_behalf_of: true\n")],
userEmail, true, true, false, []
);
});
});