mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +00:00
Add set-permissioned-as command in cli
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user