diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 3aa8305542..85e962326f 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -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 { 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(" ") + .action(setPermissionedAs as any) .command( "generate-locks", "re-generate the lockfiles for app runnables inline scripts that have changed" diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index eff742ee68..b8eec79924 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -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 { if (alreadySynced.includes(localPath)) { return; @@ -64,6 +68,28 @@ export async function pushFlow( SEP ); + // Build preserve flags for permissioned_as + const preserveFields: Partial = {}; + 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("") .option("--summary ", "flow summary") .option("--description ", "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(" ") + .action(setPermissionedAs as any); export default command; diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index c8582c5315..3fbdda79cf 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -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 { 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(); + 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(" ") - .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(" ") + .action(setPermissionedAs as any); export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9c6f094b41..d1ad8b78fe 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -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, codebases: SyncCodebase[], - opts: GlobalOptions + opts: GlobalOptions, + permissionedAsContext?: PermissionedAsContext ): Promise { 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, - codebases: SyncCodebase[] + codebases: SyncCodebase[], + permissionedAsContext?: PermissionedAsContext ): Promise { 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 ", "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(" ") + .action(setPermissionedAs as any); export default command; diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6d4a359600..3156ce0ef3 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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(), + 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; diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index be645a83fa..9027a9ee09 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -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( workspace: string, path: string, trigger: TriggerFile | Trigger[K] | undefined, - localTrigger: TriggerFile + localTrigger: TriggerFile, + permissionedAsContext?: PermissionedAsContext ): Promise { path = removeType(path, triggerType + "_trigger").replaceAll(SEP, "/"); log.debug(`Processing local ${triggerType} trigger ${path}`); @@ -161,6 +167,33 @@ export async function pushTrigger( //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( try { await updateTrigger(triggerType, workspace, path, { ...localTrigger, + ...preserveFields, path, } as Trigger[K]); } catch (e) { @@ -183,6 +217,7 @@ export async function pushTrigger( 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(); + 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(" ") - .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(" ") + .option( + "--kind ", + "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)" + ) + .action(setPermissionedAs as any); export default command; diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 22acd536f0..67d72295e6 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -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` diff --git a/cli/src/core/permissioned_as.ts b/cli/src/core/permissioned_as.ts new file mode 100644 index 0000000000..2cc6d79294 --- /dev/null +++ b/cli/src/core/permissioned_as.ts @@ -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; + 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 +): Promise { + 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 { + 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.` + ); + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts index 69592664f8..20466be691 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -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") { diff --git a/cli/test/permissioned_as.test.ts b/cli/test/permissioned_as.test.ts new file mode 100644 index 0000000000..16509c6931 --- /dev/null +++ b/cli/test/permissioned_as.test.ts @@ -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(); + }); +});