Files
windmill/cli/src/core/permissioned_as.ts
T
hugocasa 5f3f99ba69 fix(cli): keep permissioned_as on single-item push, as sync push does (#11000)
* fix(cli): keep permissioned_as on single-item push, as sync push does

* fix(cli): resolve syncBehavior from the target workspace, not the branch alone

* refactor(cli): share the workspace-name resolution between sync and single-item push

* test(cli): import the moved workspace-name helper from its new home
2026-09-07 16:46:35 +02:00

241 lines
7.0 KiB
TypeScript

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";
import { parseSyncBehavior } from "./conf.ts";
export interface PermissionedAsContext {
userCache: Map<string, { username: string; email: string }>;
userIsAdminOrDeployer: boolean;
userEmail: string;
}
/**
* The whole-tree `sync push` and the single-item `push` commands must resolve
* ownership the same way, so both build the context here: a push that leaves it
* undefined reassigns `permissioned_as` / `on_behalf_of` to whoever ran it.
* Undefined below syncBehavior v1, where that reassignment is the contract, and
* for a caller who is neither admin nor in `wm_deployers` the backend enforces
* it anyway — the flag on the context is what keeps the CLI from claiming
* otherwise.
*/
export async function buildPermissionedAsContext(
workspace: string,
syncBehavior: string | number | undefined
): Promise<PermissionedAsContext | undefined> {
if (parseSyncBehavior(syncBehavior) < 1) {
return undefined;
}
const user = await wmill.whoami({ workspace });
const userIsAdminOrDeployer =
user.is_admin || (user.groups ?? []).includes("wm_deployers");
log.debug(
`permissioned_as: user=${user.email}, is_admin=${user.is_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}`
);
return {
userCache: new Map(),
userIsAdminOrDeployer,
userEmail: user.email,
};
}
async function ensureUserCache(
workspace: string,
cache: Map<string, { username: string; email: string }>
): Promise<void> {
if (cache.size > 0) return;
const users = await wmill.listUsers({ workspace });
for (const user of users) {
cache.set(user.username, { username: user.username, email: user.email });
cache.set(user.email, { username: user.username, email: user.email });
}
}
export async function lookupUsernameByEmail(
workspace: string,
email: string,
cache: Map<string, { username: string; email: string }>
): Promise<string> {
await ensureUserCache(workspace, cache);
const entry = cache.get(email);
if (!entry) {
throw new Error(
`Could not find username for email '${email}' in workspace. ` +
`Make sure the user exists in the workspace.`
);
}
return entry.username;
}
export interface Change {
name: "edited" | "added" | "deleted";
path: string;
before?: string;
after?: string;
content?: string;
}
function contentHasOnBehalfOf(content: string, typeStr: string): boolean {
if (typeStr === "script") {
return (
!!content.match(/has_on_behalf_of:\s*(true)/) ||
!!content.match(/on_behalf_of_email:\s*["']?([^\s"']+)["']?/)
);
}
if (typeStr === "flow") {
return !!content.match(/has_on_behalf_of:\s*(true)/);
}
return false;
}
export async function preCheckPermissionedAs(
changes: Change[],
userEmail: string,
userIsAdminOrDeployer: boolean,
acceptOverride: boolean,
isInteractive: boolean
): Promise<void> {
// Admins and wm_deployers can always preserve ownership via the CLI's
// preserve-on-update path, so nothing would silently change for them.
if (userIsAdminOrDeployer) return;
const wouldChangeItems: { path: string; currentOwner: string }[] = [];
for (const change of changes) {
let typeStr: string;
try {
typeStr = getTypeStrFromPath(change.path);
} catch {
continue;
}
if (change.name === "added") {
const content = change.content;
if (!content) continue;
const isScriptMeta =
typeStr === "script" &&
(change.path.endsWith(".script.yaml") ||
change.path.endsWith(".script.json"));
const isFlowMeta =
typeStr === "flow" &&
(change.path.endsWith("flow.yaml") ||
change.path.endsWith("flow.json"));
if (
(isScriptMeta || isFlowMeta) &&
contentHasOnBehalfOf(content, typeStr)
) {
const label =
typeStr === "script" ? "(script owner)" : "(flow owner)";
wouldChangeItems.push({ path: change.path, currentOwner: label });
} else if (typeStr === "app") {
wouldChangeItems.push({
path: change.path,
currentOwner: "(app policy owner)",
});
}
continue;
}
if (change.name !== "edited") continue;
const beforeContent = change.before;
if (!beforeContent) continue;
let currentOwner: string | undefined;
if (typeStr === "script") {
if (
change.path.endsWith(".script.yaml") ||
change.path.endsWith(".script.json")
) {
const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/);
if (hasOboMatch) {
currentOwner = "(script owner)";
} else {
const emailMatch = beforeContent.match(
/on_behalf_of_email:\s*["']?([^\s"']+)["']?/
);
if (emailMatch) {
currentOwner = emailMatch[1];
}
}
}
} else if (typeStr === "flow") {
if (
change.path.endsWith("flow.yaml") ||
change.path.endsWith("flow.json")
) {
const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/);
if (hasOboMatch) {
wouldChangeItems.push({
path: change.path,
currentOwner: "(flow owner)",
});
}
}
continue;
} else if (typeStr === "app") {
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")) {
wouldChangeItems.push({
path: change.path,
currentOwner: "(trigger owner)",
});
continue;
}
if (currentOwner && currentOwner !== userEmail) {
wouldChangeItems.push({ path: change.path, currentOwner });
}
}
if (wouldChangeItems.length === 0) return;
const itemList = wouldChangeItems
.map((item) => ` - ${item.path} (current owner: ${item.currentOwner})`)
.join("\n");
const message =
`You are not an admin or member of 'wm_deployers'. The following ${wouldChangeItems.length} item(s) ` +
`will have their permissioned_as/email changed to your user (${userEmail}):\n${itemList}`;
if (acceptOverride) {
log.warn(colors.yellow(`Warning: ${message}`));
return;
}
if (isInteractive) {
log.warn(colors.yellow(message));
const proceed = await Confirm.prompt({
message:
"Do you want to proceed? (use --accept-overriding-permissioned-as-with-self to skip this prompt)",
default: false,
});
if (!proceed) {
log.info("Push cancelled.");
process.exit(0);
}
} else {
log.error(
colors.red(
`${message}\n\nUse --accept-overriding-permissioned-as-with-self to proceed anyway.`
)
);
process.exit(1);
}
}