mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 00:02:59 +00:00
Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
301 lines
9.3 KiB
TypeScript
301 lines
9.3 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 {
|
|
extractFolderPath,
|
|
isAppFolderMetadataFile,
|
|
isRawAppFolderMetadataFile,
|
|
} from "../utils/resource_folders.ts";
|
|
import { deploysWithRawApp } from "../utils/app_files.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 });
|
|
// A superadmin acts as admin in every workspace, so the backend preserves
|
|
// ownership for them even when their own `usr` row there is not an admin.
|
|
const userIsAdminOrDeployer =
|
|
user.is_admin ||
|
|
user.is_super_admin ||
|
|
(user.groups ?? []).includes("wm_deployers");
|
|
log.debug(
|
|
`permissioned_as: user=${user.email}, is_admin=${user.is_admin}, is_super_admin=${user.is_super_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;
|
|
}
|
|
|
|
type AppTypeStr = "app" | "raw_app";
|
|
|
|
function isAppTypeStr(typeStr: string): typeStr is AppTypeStr {
|
|
return typeStr === "app" || typeStr === "raw_app";
|
|
}
|
|
|
|
/** The app folder a file belongs to. `isAppFolderMetadataFile` and its raw twin
|
|
* match a literal `/`, unlike `extractFolderPath` — so normalize before either,
|
|
* or a Windows path takes a different branch from the same file on Linux. */
|
|
function appFolderOf(path: string, typeStr: AppTypeStr): string {
|
|
return extractFolderPath(path, typeStr) ?? path;
|
|
}
|
|
|
|
function toPosix(path: string): string {
|
|
return path.replaceAll("\\", "/");
|
|
}
|
|
|
|
/** App folders whose own metadata file is being added or deleted, which is how a
|
|
* whole app arrives or goes rather than being redeployed. Neither takes an owner
|
|
* over: a create has none yet, and a delete leaves none behind. */
|
|
function appsArrivingOrLeaving(changes: Change[]): Set<string> {
|
|
const folders = new Set<string>();
|
|
for (const change of changes) {
|
|
if (change.name === "edited") continue;
|
|
const path = toPosix(change.path);
|
|
if (!isAppFolderMetadataFile(path) && !isRawAppFolderMetadataFile(path)) {
|
|
continue;
|
|
}
|
|
let typeStr: string;
|
|
try {
|
|
typeStr = getTypeStrFromPath(path);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (isAppTypeStr(typeStr)) folders.add(appFolderOf(path, typeStr));
|
|
}
|
|
return folders;
|
|
}
|
|
|
|
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 }[] = [];
|
|
const addItem = (item: { path: string; currentOwner: string }) => {
|
|
if (!wouldChangeItems.some((i) => i.path === item.path)) {
|
|
wouldChangeItems.push(item);
|
|
}
|
|
};
|
|
const arrivingOrLeaving = appsArrivingOrLeaving(changes);
|
|
|
|
for (const change of changes) {
|
|
let typeStr: string;
|
|
try {
|
|
typeStr = getTypeStrFromPath(change.path);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// An app is redeployed whole by any change to any of the files it actually
|
|
// sends — added, edited or deleted alike — so its policy is rewritten
|
|
// regardless of what the file holds. Settled here, before the content the
|
|
// other kinds parse to find their owner, which an app has none of to parse.
|
|
if (isAppTypeStr(typeStr)) {
|
|
const path = toPosix(change.path);
|
|
const folder = appFolderOf(path, typeStr);
|
|
if (
|
|
!arrivingOrLeaving.has(folder) &&
|
|
(typeStr === "app" || deploysWithRawApp(path.slice(folder.length)))
|
|
) {
|
|
addItem({ path: folder, currentOwner: "(app policy owner)" });
|
|
}
|
|
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 });
|
|
}
|
|
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 === "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);
|
|
}
|
|
}
|