Compare commits
22 Commits
main
...
cli-onbeha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77b4227a4 | ||
|
|
ac0d9531f3 | ||
|
|
18e0724422 | ||
|
|
1d1253c916 | ||
|
|
dd9a61d880 | ||
|
|
f48f4ded70 | ||
|
|
ecd02fa322 | ||
|
|
84c2817520 | ||
|
|
07501aadaf | ||
|
|
a0754c215f | ||
|
|
30c632a4f2 | ||
|
|
992d0c3cc4 | ||
|
|
fac0dab3b4 | ||
|
|
1dc2859016 | ||
|
|
937555f876 | ||
|
|
50a945f9c8 | ||
|
|
c4495db2e6 | ||
|
|
29bb77b0cb | ||
|
|
9e5857cd9b | ||
|
|
aa71ff6ec2 | ||
|
|
dbbe192b3c | ||
|
|
d724fa6cef |
@@ -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.'
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
402
cli/src/core/permissioned_as.ts
Normal file
402
cli/src/core/permissioned_as.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
39
cli/test/cli_behavior.test.ts
Normal file
39
cli/test/cli_behavior.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
cli/test/conf_target_workspace.test.ts
Normal file
148
cli/test/conf_target_workspace.test.ts
Normal 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();
|
||||
});
|
||||
445
cli/test/permissioned_as.test.ts
Normal file
445
cli/test/permissioned_as.test.ts
Normal 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, []
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user