Add set-permissioned-as command in cli

This commit is contained in:
wendrul
2026-03-04 15:18:31 +01:00
parent e97da86067
commit d724fa6cef
10 changed files with 973 additions and 38 deletions
+60 -1
View File
@@ -17,6 +17,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 { resolvePermissionedAsEmail } from "../../core/permissioned_as.ts";
export interface AppFile {
value: any;
@@ -104,7 +106,8 @@ export async function pushApp(
workspace: string,
remotePath: string,
localPath: string,
message?: string
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
@@ -142,6 +145,29 @@ 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: preserve the remote's on_behalf_of
preserveFields.preserve_on_behalf_of = true;
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const ruleEmail = resolvePermissionedAsEmail(
remotePath,
permissionedAsContext.rules
);
if (ruleEmail) {
// Set the policy on_behalf_of_email for the new app
if (localApp.policy) {
(localApp.policy as any).on_behalf_of_email = ruleEmail;
}
preserveFields.preserve_on_behalf_of = true;
}
}
}
if (app) {
if (isSuperset(localApp, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
@@ -154,6 +180,7 @@ export async function pushApp(
requestBody: {
deployment_message: message,
...localApp,
...preserveFields,
},
});
} else {
@@ -165,6 +192,7 @@ export async function pushApp(
path: remotePath,
deployment_message: message,
...localApp,
...preserveFields,
},
});
}
@@ -245,6 +273,31 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
log.info(colors.bold.underline.green("App pushed"));
}
async function setPermissionedAs(
opts: GlobalOptions,
appPath: string,
email: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await wmill.updateApp({
workspace: workspace.workspaceId,
path: appPath,
requestBody: {
policy: {
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)")
@@ -263,6 +316,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",
"re-generate the lockfiles for app runnables inline scripts that have changed"
+61 -3
View File
@@ -17,15 +17,18 @@ import { resolve, track_job } 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 { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { resolvePermissionedAsEmail } from "../../core/permissioned_as.ts";
export interface FlowFile {
summary: string;
description?: string;
value: any;
schema?: any;
on_behalf_of_email?: string;
}
const alreadySynced: string[] = [];
@@ -34,7 +37,8 @@ export async function pushFlow(
workspace: string,
remotePath: string,
localPath: string,
message?: string
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
@@ -64,6 +68,28 @@ export async function pushFlow(
SEP
);
// Build preserve flags for permissioned_as
const preserveFields: Partial<OpenFlowWPath> = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (flow) {
// Updating: preserve the remote's on_behalf_of_email
preserveFields.preserve_on_behalf_of = true;
if (flow.on_behalf_of_email) {
preserveFields.on_behalf_of_email = flow.on_behalf_of_email;
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const ruleEmail = resolvePermissionedAsEmail(
remotePath,
permissionedAsContext.rules
);
if (ruleEmail) {
preserveFields.on_behalf_of_email = ruleEmail;
preserveFields.preserve_on_behalf_of = true;
}
}
}
if (flow) {
if (isSuperset(localFlow, flow)) {
log.info(colors.green(`Flow ${remotePath} is up to date`));
@@ -77,6 +103,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...preserveFields,
},
});
} else {
@@ -88,6 +115,7 @@ export async function pushFlow(
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...preserveFields,
},
});
} catch (e) {
@@ -395,6 +423,30 @@ export 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}`
)
);
}
const command = new Command()
.description("flow related commands")
.option("--show-archived", "Enable archived flows in output")
@@ -463,6 +515,12 @@ const command = new Command()
.arguments("<flow_path:string>")
.option("--summary <summary:string>", "flow summary")
.option("--description <description:string>", "flow description")
.action(bootstrap as any);
.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);
export default command;
+73 -2
View File
@@ -17,6 +17,11 @@ import {
removeType,
} from "../../types.ts";
import { Schedule } from "../../../gen/types.gen.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
resolvePermissionedAsEmail,
lookupUsernameByEmail,
} from "../../core/permissioned_as.ts";
export interface ScheduleFile {
schedule: string;
@@ -98,7 +103,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}`);
@@ -112,6 +118,33 @@ export async function pushSchedule(
//ignore
}
// Build preserve flags for permissioned_as
const preserveFields: { email?: string; preserve_email?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (schedule) {
// Updating: preserve the remote's edited_by (username)
preserveFields.preserve_email = true;
if ((schedule as Schedule).edited_by) {
preserveFields.email = (schedule as Schedule).edited_by;
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const ruleEmail = resolvePermissionedAsEmail(
path,
permissionedAsContext.rules
);
if (ruleEmail) {
const username = await lookupUsernameByEmail(
workspace,
ruleEmail,
permissionedAsContext.emailToUsernameCache
);
preserveFields.email = username;
preserveFields.preserve_email = true;
}
}
}
if (schedule) {
if (isSuperset(localSchedule, schedule)) {
log.debug(`Schedule ${path} is up to date`);
@@ -127,6 +160,7 @@ export async function pushSchedule(
path,
requestBody: {
...localSchedule,
...preserveFields,
},
});
if (localSchedule.enabled != schedule.enabled) {
@@ -153,6 +187,7 @@ export async function pushSchedule(
requestBody: {
path: path,
...localSchedule,
...preserveFields,
},
});
} catch (e) {
@@ -186,6 +221,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: {
email: username,
preserve_email: 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)")
@@ -205,6 +270,12 @@ const command = new Command()
"push a local schedule 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 schedule (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action(setPermissionedAs as any);
export default command;
+81 -7
View File
@@ -52,6 +52,8 @@ import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
import { execSync } from "node:child_process";
import { NewScript, Script } from "../../../gen/types.gen.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { resolvePermissionedAsEmail } from "../../core/permissioned_as.ts";
import {
isRawAppBackendPath as isRawAppBackendPathInternal,
isAppInlineScriptPath as isAppInlineScriptPathInternal,
@@ -186,7 +188,8 @@ export async function handleScriptMetadata(
message: string | undefined,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[],
opts: GlobalOptions
opts: GlobalOptions,
permissionedAsContext?: PermissionedAsContext
): Promise<boolean> {
if (
path.endsWith(".script.json") ||
@@ -201,7 +204,8 @@ export async function handleScriptMetadata(
message,
opts,
rawWorkspaceDependencies,
codebases
codebases,
permissionedAsContext
);
} else {
return false;
@@ -223,7 +227,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> {
if (
!isAppInlineScriptPath(path) &&
@@ -421,9 +426,27 @@ export async function handleFile(
envs: typed?.envs,
};
// console.log(requestBodyCommon.codebase);
// log.info(JSON.stringify(requestBodyCommon, null, 2))
// log.info(JSON.stringify(opts, null, 2))
// Add preserve flags for permissioned_as
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (remote) {
// Updating: preserve the remote's on_behalf_of_email
requestBodyCommon.preserve_on_behalf_of = true;
if (remote.on_behalf_of_email) {
requestBodyCommon.on_behalf_of_email = remote.on_behalf_of_email;
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const ruleEmail = resolvePermissionedAsEmail(
remotePath,
permissionedAsContext.rules
);
if (ruleEmail) {
requestBodyCommon.on_behalf_of_email = ruleEmail;
requestBodyCommon.preserve_on_behalf_of = true;
}
}
}
if (remote) {
if (content === remote.content) {
if (
@@ -1284,6 +1307,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}`
)
);
}
const command = new Command()
.description("script related commands")
.option("--show-archived", "Enable archived scripts in output")
@@ -1358,6 +1426,12 @@ const command = new Command()
"-e --excludes <patterns:file[]>",
"Comma separated patterns to specify which file to NOT take into account."
)
.action(generateMetadata as any);
.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);
export default command;
+39 -1
View File
@@ -78,6 +78,11 @@ import {
import { extractInlineScripts as extractInlineScriptsForFlows } 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,
@@ -2248,7 +2253,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 },
) {
// Save original CLI options before merging with config file
const originalCliOpts = { ...opts };
@@ -2565,6 +2570,30 @@ export async function push(
log.info(colors.gray(`Dry run complete.`));
return;
}
// Build permissioned_as context
const user = await wmill.whoami({ workspace: workspace.workspaceId });
const userIsAdminOrDeployer =
user.is_admin || (user.groups ?? []).includes("wm_deployers");
const validatedRules = validatePermissionedAsRules(
opts.defaultPermissionedAs,
"wmill.yaml"
);
const permissionedAsContext: PermissionedAsContext = {
rules: validatedRules,
emailToUsernameCache: new Map<string, string>(),
userIsAdminOrDeployer,
};
// Pre-check: warn non-admin/non-deployer users about permissioned_as changes
await preCheckPermissionedAs(
changes,
user.email,
userIsAdminOrDeployer,
opts.acceptOverridingPermissionedAsWithSelf ?? false,
!!process.stdin.isTTY
);
if (
!opts.yes &&
!(await Confirm.prompt({
@@ -2661,6 +2690,7 @@ export async function push(
rawWorkspaceDependencies,
codebases,
opts,
permissionedAsContext,
)
) {
if (stateTarget) {
@@ -2676,6 +2706,7 @@ export async function push(
opts,
rawWorkspaceDependencies,
codebases,
permissionedAsContext,
)
) {
if (stateTarget) {
@@ -2780,6 +2811,7 @@ export async function push(
alreadySynced,
opts.message,
originalBranchSpecificPath,
permissionedAsContext,
);
if (stateTarget) {
@@ -2803,6 +2835,7 @@ export async function push(
opts,
rawWorkspaceDependencies,
codebases,
permissionedAsContext,
)
) {
continue;
@@ -2838,6 +2871,7 @@ export async function push(
[],
opts.message,
localFilePath, // Pass the actual local file path
permissionedAsContext,
);
if (stateTarget) {
@@ -3264,6 +3298,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",
)
.action(push as any);
export default command;
+88 -2
View File
@@ -36,6 +36,11 @@ 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 {
resolvePermissionedAsEmail,
lookupUsernameByEmail,
} from "../../core/permissioned_as.ts";
type Trigger = {
http: HttpTrigger;
@@ -148,7 +153,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}`);
@@ -161,6 +167,33 @@ export async function pushTrigger<K extends TriggerType>(
//ignore
}
// Build preserve flags for permissioned_as
const preserveFields: { email?: string; preserve_email?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (trigger) {
// Updating: preserve the remote's edited_by (username)
preserveFields.preserve_email = true;
if ((trigger as any).edited_by) {
preserveFields.email = (trigger as any).edited_by;
}
} else {
// Creating: apply defaultPermissionedAs rule if one matches
const ruleEmail = resolvePermissionedAsEmail(
path,
permissionedAsContext.rules
);
if (ruleEmail) {
const username = await lookupUsernameByEmail(
workspace,
ruleEmail,
permissionedAsContext.emailToUsernameCache
);
preserveFields.email = username;
preserveFields.preserve_email = true;
}
}
}
if (trigger) {
if (isSuperset(localTrigger, trigger)) {
log.debug(`${triggerType} trigger ${path} is up to date`);
@@ -170,6 +203,7 @@ export async function pushTrigger<K extends TriggerType>(
try {
await updateTrigger(triggerType, workspace, path, {
...localTrigger,
...preserveFields,
path,
} as Trigger[K]);
} catch (e) {
@@ -183,6 +217,7 @@ export async function pushTrigger<K extends TriggerType>(
try {
await createTrigger(triggerType, workspace, path, {
...localTrigger,
...preserveFields,
path,
} as Trigger[K]);
} catch (e) {
@@ -559,6 +594,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, {
email: username,
preserve_email: 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)")
@@ -580,6 +656,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;
+24 -9
View File
@@ -12,6 +12,7 @@ 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 let showDiffs = false;
export function setShowDiffs(value: boolean) {
@@ -102,6 +103,7 @@ export interface SyncOptions {
promotion?: string;
lint?: boolean;
locksRequired?: boolean;
defaultPermissionedAs?: PermissionedAsRule[];
}
export interface Codebase {
@@ -556,23 +558,36 @@ export async function getEffectiveSettings(
`No promotion or regular overrides found for branch '${promotion}', using top-level settings`
);
}
// Branch-level defaultPermissionedAs takes priority over root-level
if (targetBranch.defaultPermissionedAs) {
effective.defaultPermissionedAs = targetBranch.defaultPermissionedAs;
}
}
// Otherwise use current branch overrides (existing behavior)
else if (
currentBranch &&
gitBranches &&
gitBranches[currentBranch] &&
gitBranches[currentBranch].overrides
gitBranches[currentBranch]
) {
Object.assign(effective, gitBranches[currentBranch].overrides);
if (!suppressLogs) {
const extraLog = originalBranchIfForked
? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)`
: "";
log.info(
`Applied settings for Git branch: ${currentBranch}${extraLog}`
if (gitBranches[currentBranch].overrides) {
Object.assign(effective, gitBranches[currentBranch].overrides);
if (!suppressLogs) {
const extraLog = originalBranchIfForked
? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)`
: "";
log.info(
`Applied settings for Git branch: ${currentBranch}${extraLog}`
);
}
} else {
log.debug(
`No branch-specific overrides found for '${currentBranch}', using top-level settings`
);
}
// Branch-level defaultPermissionedAs takes priority over root-level
if (gitBranches[currentBranch].defaultPermissionedAs) {
effective.defaultPermissionedAs = gitBranches[currentBranch].defaultPermissionedAs;
}
} else if (currentBranch) {
log.debug(
`No branch-specific overrides found for '${currentBranch}', using top-level settings`
+259
View File
@@ -0,0 +1,259 @@
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 {
email: string;
path_pattern: string;
}
export interface PermissionedAsContext {
rules: PermissionedAsRule[];
emailToUsernameCache: Map<string, string>;
userIsAdminOrDeployer: boolean;
}
const KNOWN_RULE_FIELDS = new Set(["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 '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: 'email', 'path_pattern'`
);
}
// Validate required fields
if (typeof rule.email !== "string" || rule.email.trim() === "") {
throw new Error(
`Invalid ${ruleLabel}: 'email' is required and must be a non-empty string`
);
}
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({ email: rule.email, path_pattern: rule.path_pattern });
}
return validated;
}
/**
* Resolves which email should be used for a new item based on defaultPermissionedAs rules.
* Returns undefined if no rule matches.
*/
export function resolvePermissionedAsEmail(
path: string,
rules: PermissionedAsRule[]
): string | undefined {
for (const rule of rules) {
if (minimatch(path, rule.path_pattern)) {
return rule.email;
}
}
return undefined;
}
/**
* Looks up a username by email using the workspace users API.
* Results are cached in the provided map.
*/
export async function lookupUsernameByEmail(
workspace: string,
email: string,
cache: Map<string, string>
): Promise<string> {
if (cache.has(email)) {
return cache.get(email)!;
}
// Populate entire cache from users list (only fetched once)
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;
}
/**
* Pre-checks whether items being pushed will have their permissioned_as/email changed
* because the deploying user is not an admin or deployer.
*
* For admins/deployers, preserve flags will be sent with the API calls, so no warning is needed.
* 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
): Promise<void> {
if (userIsAdminOrDeployer) {
return;
}
const wouldChangeItems: { path: string; currentOwner: string }[] = [];
for (const change of changes) {
if (change.name !== "edited") {
continue;
}
let typeStr: string;
try {
typeStr = getTypeStrFromPath(change.path);
} catch {
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")
) {
const match = beforeContent.match(
/on_behalf_of_email:\s*["']?([^\s"']+)["']?/
);
if (match) {
currentOwner = match[1];
}
}
} else if (typeStr === "flow") {
// Flow metadata may have on_behalf_of_email
const match = beforeContent.match(
/on_behalf_of_email:\s*["']?([^\s"']+)["']?/
);
if (match) {
currentOwner = match[1];
}
} 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")) {
const match = beforeContent.match(
/email:\s*["']?([^\s"']+)["']?/
);
if (match) {
currentOwner = match[1];
}
}
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 =
`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) {
throw new Error("Push cancelled by user due to permissioned_as changes.");
}
} else {
throw new Error(
`${message}\n\nUse --accept-overriding-permissioned-as-with-self to proceed anyway.`
);
}
}
+15 -13
View File
@@ -29,6 +29,7 @@ import {
extractResourceName,
buildFolderPath,
} from "./utils/resource_folders.ts";
import type { PermissionedAsContext } from "./core/permissioned_as.ts";
export interface DifferenceCreate {
type: "CREATE";
@@ -151,13 +152,14 @@ export async function pushObj(
plainSecrets: boolean,
alreadySynced: string[],
message?: string,
originalLocalPath?: string
originalLocalPath?: string,
permissionedAsContext?: PermissionedAsContext
) {
const typeEnding = getTypeStrFromPath(p);
if (typeEnding === "app") {
const appName = extractResourceName(p, "app")!;
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")!;
await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message);
@@ -167,7 +169,7 @@ export async function pushObj(
await pushVariable(workspace, p, befObj, newObj, plainSecrets);
} else if (typeEnding === "flow") {
const flowName = extractResourceName(p, "flow")!;
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);
@@ -176,25 +178,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") {
+273
View File
@@ -0,0 +1,273 @@
/**
* Unit tests for permissioned_as.ts: rule validation, rule resolution, and pre-check logic.
*/
import { expect, test, describe } from "bun:test";
import {
validatePermissionedAsRules,
resolvePermissionedAsEmail,
type PermissionedAsRule,
} 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", () => {
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 multiple correct rules", () => {
const rules = [
{ email: "admin@company.com", path_pattern: "f/production/**" },
{ email: "deploy@company.com", path_pattern: "f/**" },
];
const result = validatePermissionedAsRules(rules, "wmill.yaml");
expect(result).toHaveLength(2);
expect(result[0].email).toBe("admin@company.com");
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(
{ email: "a@b.com", 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 '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 'email' and 'path_pattern' fields"
);
});
// --- Unknown/misspelled fields ---
test("throws on misspelled 'email' field", () => {
expect(() =>
validatePermissionedAsRules(
[{ emaill: "a@b.com", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'emaill'");
});
test("throws on misspelled 'path_pattern' field", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "a@b.com", pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'pattern'");
});
test("throws on extra unknown field", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "a@b.com", path_pattern: "f/**", extra_field: true }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'extra_field'");
});
test("throws listing multiple unknown fields", () => {
expect(() =>
validatePermissionedAsRules(
[{ emaill: "a@b.com", pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("unknown field(s) 'emaill', 'pattern'");
});
test("error message includes valid field names", () => {
expect(() =>
validatePermissionedAsRules(
[{ emaill: "a@b.com", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("Valid fields are: 'email', 'path_pattern'");
});
// --- Missing required fields ---
test("throws on missing email", () => {
expect(() =>
validatePermissionedAsRules(
[{ path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("'email' is required and must be a non-empty string");
});
test("throws on empty email", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "", path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("'email' is required and must be a non-empty string");
});
test("throws on non-string email", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: 123, path_pattern: "f/**" }],
"wmill.yaml"
)
).toThrow("'email' is required and must be a non-empty string");
});
test("throws on missing path_pattern", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "a@b.com" }],
"wmill.yaml"
)
).toThrow("'path_pattern' is required and must be a non-empty string");
});
test("throws on empty path_pattern", () => {
expect(() =>
validatePermissionedAsRules(
[{ email: "a@b.com", 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(
[{ email: "a@b.com", 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(
[
{ email: "a@b.com", path_pattern: "f/**" },
{ email: "", 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");
});
});
// =============================================================================
// resolvePermissionedAsEmail
// =============================================================================
describe("resolvePermissionedAsEmail", () => {
const rules: PermissionedAsRule[] = [
{ email: "prod@company.com", path_pattern: "f/production/**" },
{ email: "staging@company.com", path_pattern: "f/staging/**" },
{ email: "default@company.com", path_pattern: "f/**" },
];
test("returns matching email for specific path", () => {
expect(
resolvePermissionedAsEmail("f/production/my_script", rules)
).toBe("prod@company.com");
});
test("returns first matching rule (production over default)", () => {
expect(
resolvePermissionedAsEmail("f/production/deep/nested", rules)
).toBe("prod@company.com");
});
test("returns staging email for staging path", () => {
expect(
resolvePermissionedAsEmail("f/staging/my_flow", rules)
).toBe("staging@company.com");
});
test("falls through to default rule", () => {
expect(
resolvePermissionedAsEmail("f/other/my_script", rules)
).toBe("default@company.com");
});
test("returns undefined when no rule matches", () => {
expect(
resolvePermissionedAsEmail("u/admin/my_script", rules)
).toBeUndefined();
});
test("returns undefined for empty rules", () => {
expect(resolvePermissionedAsEmail("f/anything", [])).toBeUndefined();
});
test("handles exact path patterns", () => {
const exactRules: PermissionedAsRule[] = [
{ email: "exact@company.com", path_pattern: "f/specific/script" },
];
expect(
resolvePermissionedAsEmail("f/specific/script", exactRules)
).toBe("exact@company.com");
expect(
resolvePermissionedAsEmail("f/specific/other", exactRules)
).toBeUndefined();
});
test("handles single-level wildcard", () => {
const wildcardRules: PermissionedAsRule[] = [
{ email: "wild@company.com", path_pattern: "f/*/scripts" },
];
expect(
resolvePermissionedAsEmail("f/team_a/scripts", wildcardRules)
).toBe("wild@company.com");
expect(
resolvePermissionedAsEmail("f/team_a/nested/scripts", wildcardRules)
).toBeUndefined();
});
});