507 lines
17 KiB
JavaScript
507 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* zitadel-roles.mjs — diagnose and repair the OpenClaw Platform project's
|
|
* role keys + customer authorizations. Group A of the bug triage.
|
|
*
|
|
* Subcommands
|
|
* -----------
|
|
* diagnose Print the project's current roles and a raw dump
|
|
* of all authorizations granted on the project.
|
|
* Read-only. Safe to run any time.
|
|
*
|
|
* apply Idempotently create the four canonical role keys
|
|
* (owner, user, platform_admin, platform_operator)
|
|
* if they are missing. Existing roles are left as
|
|
* they are; legacy keys (e.g. "customer") are NOT
|
|
* deleted by this command — see `migrate-auth`.
|
|
*
|
|
* migrate-auth <user> Drop every authorization the given user holds
|
|
* on the project and replace with a single
|
|
* authorization carrying ["owner"]. Use after
|
|
* `apply` to promote a legacy customer to the
|
|
* new role keys. Idempotent.
|
|
*
|
|
* migrate-grants Ensure every existing project grant on the
|
|
* OpenClaw Platform project includes both
|
|
* `owner` and `user` role keys. Without `user`
|
|
* in the grant, `CreateAuthorization` for an
|
|
* invited member returns Errors.Project.Role.NotFound
|
|
* (Bug 21). Idempotent: grants already containing
|
|
* both keys are skipped.
|
|
*
|
|
* Env vars (loaded from .env if you run with `node --env-file=.env`):
|
|
* ZITADEL_ISSUER e.g. https://auth.pieced.ch
|
|
* ZITADEL_SA_PAT PAT for pieced-sa (IAM_OWNER)
|
|
* ZITADEL_PROJECT_ID e.g. 367435120493199793
|
|
*
|
|
* Examples
|
|
* --------
|
|
* node --env-file=.env scripts/zitadel-roles.mjs diagnose
|
|
* node --env-file=.env scripts/zitadel-roles.mjs apply
|
|
* node --env-file=.env scripts/zitadel-roles.mjs migrate-auth 12345...
|
|
*
|
|
* The script does not import from src/ on purpose — it must be runnable
|
|
* even when the portal can't start (which is the failure mode we're
|
|
* here to repair).
|
|
*/
|
|
|
|
const ISSUER = process.env.ZITADEL_ISSUER;
|
|
const PAT = process.env.ZITADEL_SA_PAT;
|
|
const PROJECT_ID = process.env.ZITADEL_PROJECT_ID;
|
|
|
|
if (!ISSUER || !PAT || !PROJECT_ID) {
|
|
console.error(
|
|
"Missing env. Need ZITADEL_ISSUER, ZITADEL_SA_PAT, ZITADEL_PROJECT_ID."
|
|
);
|
|
console.error("Run with: node --env-file=.env scripts/zitadel-roles.mjs ...");
|
|
process.exit(2);
|
|
}
|
|
|
|
// Canonical role set — must match types/index.ts (CustomerRole + PlatformRole).
|
|
const CANONICAL = [
|
|
{ key: "owner", displayName: "Customer Owner", group: "Customer" },
|
|
{ key: "user", displayName: "Customer User", group: "Customer" },
|
|
{ key: "platform_admin", displayName: "Platform Admin", group: "Platform" },
|
|
{
|
|
key: "platform_operator",
|
|
displayName: "Platform Operator",
|
|
group: "Platform",
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP plumbing — Connect RPC against ZITADEL v2 services.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function rpc(service, method, body) {
|
|
const url = `${ISSUER}/${service}/${method}`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${PAT}`,
|
|
"Connect-Protocol-Version": "1",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
const err = new Error(`${service}/${method} -> ${res.status}: ${text}`);
|
|
err.status = res.status;
|
|
err.body = text;
|
|
throw err;
|
|
}
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
const projectSvc = "zitadel.project.v2.ProjectService";
|
|
const authSvc = "zitadel.authorization.v2.AuthorizationService";
|
|
|
|
async function listProjectRoles() {
|
|
const data = await rpc(projectSvc, "ListProjectRoles", {
|
|
projectId: PROJECT_ID,
|
|
});
|
|
return Array.isArray(data?.projectRoles) ? data.projectRoles : [];
|
|
}
|
|
|
|
async function addProjectRole(roleKey, displayName, group) {
|
|
return rpc(projectSvc, "AddProjectRole", {
|
|
projectId: PROJECT_ID,
|
|
roleKey,
|
|
displayName,
|
|
...(group ? { group } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The Connect RPC filter shape for ListAuthorizations is a oneof variant
|
|
* map — each filter has a discriminator key matching one of the variants
|
|
* documented as `authorization_ids|in_user_ids|organization_id|project_id|
|
|
* role_key|...`. Different ZITADEL services and versions differ on the
|
|
* exact wrapper naming (e.g. `projectId` vs `projectIdFilter`) and on
|
|
* whether ID values are bare strings or wrapped in `{ id: "..." }`.
|
|
*
|
|
* Rather than guess, we probe candidate shapes until ZITADEL accepts one.
|
|
* The winner tells us exactly what to bake into `lib/zitadel.ts`. Each
|
|
* candidate is labelled so the diagnostic output makes the right choice
|
|
* obvious.
|
|
*/
|
|
const FILTER_CANDIDATES = [
|
|
// No filter at all — ZITADEL returns whatever the SA can see. Slowest
|
|
// but always works; useful as a control.
|
|
{
|
|
label: "no-filter",
|
|
build: () => ({}),
|
|
},
|
|
// Pattern from discussion #8831 (roleKey -> key+method). Plausible
|
|
// generalisation: project_id -> projectId.id
|
|
{
|
|
label: "projectId.id",
|
|
build: (projectId) => ({ filters: [{ projectId: { id: projectId } }] }),
|
|
},
|
|
// Pattern from ProjectService.ListProjects (organizationIdFilter -> organizationId).
|
|
{
|
|
label: "projectIdFilter.id",
|
|
build: (projectId) => ({
|
|
filters: [{ projectIdFilter: { id: projectId } }],
|
|
}),
|
|
},
|
|
// Same family but with the value field named after the filter, like the
|
|
// user search API uses (`organizationIdQuery: { organizationId: "..." }`).
|
|
{
|
|
label: "projectIdFilter.projectId",
|
|
build: (projectId) => ({
|
|
filters: [{ projectIdFilter: { projectId } }],
|
|
}),
|
|
},
|
|
// Bare-string variant — just in case.
|
|
{
|
|
label: "projectId (bare string)",
|
|
build: (projectId) => ({ filters: [{ projectId }] }),
|
|
},
|
|
];
|
|
|
|
const USER_FILTER_CANDIDATES = [
|
|
{ label: "userId.id", key: "userId", build: (id) => ({ id }) },
|
|
{ label: "userIdFilter.id", key: "userIdFilter", build: (id) => ({ id }) },
|
|
{ label: "userIdFilter.userId", key: "userIdFilter", build: (id) => ({ userId: id }) },
|
|
];
|
|
|
|
/**
|
|
* Try every candidate; return on the first one that returns 200. Logs each
|
|
* attempt so a reader can see which shape won.
|
|
*/
|
|
async function probeListAuthorizations(extraFilters = []) {
|
|
for (const c of FILTER_CANDIDATES) {
|
|
const body = c.build(PROJECT_ID);
|
|
if (extraFilters.length > 0) {
|
|
body.filters = (body.filters || []).concat(extraFilters);
|
|
}
|
|
body.pagination = { limit: 500 };
|
|
try {
|
|
const data = await rpc(authSvc, "ListAuthorizations", body);
|
|
const count = Array.isArray(data?.authorizations)
|
|
? data.authorizations.length
|
|
: 0;
|
|
console.log(` OK ${c.label.padEnd(28)} -> ${count} authorization(s)`);
|
|
return { label: c.label, body, data };
|
|
} catch (err) {
|
|
const oneLine = String(err.body || err.message)
|
|
.replace(/\s+/g, " ")
|
|
.slice(0, 110);
|
|
console.log(` FAIL ${c.label.padEnd(28)} -> ${oneLine}`);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function listUserAuthorizations(userId) {
|
|
// Use the same project-filter shape that won the probe, plus a user-id
|
|
// filter probed independently.
|
|
const probed = await probeListAuthorizations();
|
|
if (!probed) throw new Error("No filter shape accepted by ZITADEL");
|
|
|
|
for (const u of USER_FILTER_CANDIDATES) {
|
|
const body = JSON.parse(JSON.stringify(probed.body));
|
|
body.filters = (body.filters || []).concat([
|
|
{ [u.key]: u.build(userId) },
|
|
]);
|
|
try {
|
|
const data = await rpc(authSvc, "ListAuthorizations", body);
|
|
console.log(` user filter ${u.label} accepted.`);
|
|
return data;
|
|
} catch (err) {
|
|
// Try next.
|
|
}
|
|
}
|
|
// Fallback: return all and filter client-side from the user dump.
|
|
return probed.data;
|
|
}
|
|
|
|
async function deleteAuthorization(authorizationId) {
|
|
return rpc(authSvc, "DeleteAuthorization", { id: authorizationId });
|
|
}
|
|
|
|
async function createAuthorization(userId, organizationId, roleKeys) {
|
|
return rpc(authSvc, "CreateAuthorization", {
|
|
userId,
|
|
projectId: PROJECT_ID,
|
|
organizationId,
|
|
roleKeys,
|
|
});
|
|
}
|
|
|
|
async function listProjectGrants() {
|
|
// Same approach as authorizations: skip server-side filters, narrow
|
|
// client-side by projectId. Pilot scale; cheap.
|
|
const data = await rpc(projectSvc, "ListProjectGrants", {
|
|
pagination: { limit: 500 },
|
|
});
|
|
const all = Array.isArray(data?.projectGrants) ? data.projectGrants : [];
|
|
return all.filter((g) => g?.projectId === PROJECT_ID);
|
|
}
|
|
|
|
async function updateProjectGrant(grantedOrganizationId, roleKeys) {
|
|
return rpc(projectSvc, "UpdateProjectGrant", {
|
|
projectId: PROJECT_ID,
|
|
grantedOrganizationId,
|
|
roleKeys,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function diagnose() {
|
|
console.log(`Project: ${PROJECT_ID}`);
|
|
console.log(`Issuer: ${ISSUER}\n`);
|
|
|
|
console.log("--- Project roles ---");
|
|
const roles = await listProjectRoles();
|
|
if (roles.length === 0) {
|
|
console.log(" (none)");
|
|
} else {
|
|
for (const r of roles) {
|
|
console.log(` key=${r.key.padEnd(20)} displayName=${r.displayName ?? ""} group=${r.group ?? ""}`);
|
|
}
|
|
}
|
|
|
|
const present = new Set(roles.map((r) => r.key));
|
|
const missing = CANONICAL.filter((c) => !present.has(c.key));
|
|
const legacy = roles.filter((r) => !CANONICAL.some((c) => c.key === r.key));
|
|
|
|
console.log("\n--- Canonical key check ---");
|
|
for (const c of CANONICAL) {
|
|
console.log(` ${present.has(c.key) ? "OK " : "MISS"} ${c.key}`);
|
|
}
|
|
if (legacy.length > 0) {
|
|
console.log("\n Non-canonical keys still on the project:");
|
|
for (const r of legacy) console.log(` ${r.key}`);
|
|
console.log(" (consider migrating any authorizations off these.)");
|
|
}
|
|
|
|
console.log("\n--- Authorizations on project (probing filter shape) ---");
|
|
const probed = await probeListAuthorizations();
|
|
if (!probed) {
|
|
console.log(
|
|
"\nNo filter shape was accepted. Cannot enumerate authorizations."
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(`\nWinning filter shape: ${probed.label}`);
|
|
console.log("Raw response (first 2 entries):");
|
|
const trimmed = {
|
|
...probed.data,
|
|
authorizations: (probed.data.authorizations || []).slice(0, 2),
|
|
};
|
|
console.log(JSON.stringify(trimmed, null, 2));
|
|
|
|
// Parsed view — what `lib/zitadel.ts::listOrgAuthorizations` SHOULD return
|
|
// once the parser is fixed. Useful for confirming the response field
|
|
// names without wading through the raw blob.
|
|
const auths = probed.data.authorizations || [];
|
|
console.log(`\nParsed (${auths.length} authorization(s)):`);
|
|
for (const a of auths) {
|
|
const userId = a.user?.id ?? "?";
|
|
const userName = a.user?.displayName ?? a.user?.preferredLoginName ?? "";
|
|
const orgId = a.organization?.id ?? "?";
|
|
const orgName = a.organization?.name ?? "";
|
|
const roleKeys = Array.isArray(a.roles)
|
|
? a.roles.map((r) => r.key).join(",")
|
|
: "(none)";
|
|
console.log(
|
|
` ${a.id?.slice(0, 12) ?? "?"}… user=${userName} (${userId.slice(0, 10)}…) org=${orgName} roles=[${roleKeys}]`
|
|
);
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
console.log(
|
|
`\nNext step: run \`apply\` to create ${missing.length} missing role(s).`
|
|
);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("\nAll canonical roles present.");
|
|
}
|
|
}
|
|
|
|
async function apply() {
|
|
const existing = await listProjectRoles();
|
|
const present = new Set(existing.map((r) => r.key));
|
|
|
|
let created = 0;
|
|
for (const c of CANONICAL) {
|
|
if (present.has(c.key)) {
|
|
console.log(`SKIP ${c.key} (already exists)`);
|
|
continue;
|
|
}
|
|
try {
|
|
await addProjectRole(c.key, c.displayName, c.group);
|
|
console.log(`ADD ${c.key}`);
|
|
created++;
|
|
} catch (err) {
|
|
// ZITADEL returns AlreadyExists if a role with the same key was
|
|
// created in a race; treat as success so the script stays idempotent.
|
|
if (
|
|
err.body &&
|
|
/already.*exist/i.test(err.body)
|
|
) {
|
|
console.log(`SKIP ${c.key} (already exists, race)`);
|
|
continue;
|
|
}
|
|
console.error(`FAIL ${c.key}: ${err.message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone. ${created} role(s) created.`);
|
|
}
|
|
|
|
async function migrateAuth(userId) {
|
|
if (!userId) {
|
|
console.error("Usage: migrate-auth <userId>");
|
|
process.exit(2);
|
|
}
|
|
|
|
// Verify owner role exists before we touch anything; otherwise we'd
|
|
// delete authorizations and fail to recreate them.
|
|
const roles = await listProjectRoles();
|
|
if (!roles.some((r) => r.key === "owner")) {
|
|
console.error("Project has no `owner` role. Run `apply` first.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Listing authorizations for user ${userId} on project ${PROJECT_ID}...`);
|
|
const auths = await listUserAuthorizations(userId);
|
|
const list = Array.isArray(auths?.authorizations) ? auths.authorizations : [];
|
|
// Filter client-side to the requested user, in case the user filter probe
|
|
// didn't narrow things down.
|
|
const userAuths = list.filter((a) => a.user?.id === userId);
|
|
|
|
if (userAuths.length === 0) {
|
|
console.log("No existing authorizations found. Cannot infer organizationId.");
|
|
console.log("Pass it explicitly via the env: ORG_ID=... or use the portal flow.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Pick the organizationId from any of the existing authorizations — it
|
|
// should be the same across all of them for a single user/project pair.
|
|
const orgIds = [...new Set(userAuths.map((a) => a.organization?.id).filter(Boolean))];
|
|
if (orgIds.length !== 1) {
|
|
console.error(`Expected exactly 1 organizationId, got ${orgIds.length}: ${orgIds.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
const orgId = orgIds[0];
|
|
|
|
console.log(`Found ${userAuths.length} authorization(s) in org ${orgId}:`);
|
|
for (const a of userAuths) {
|
|
const id = a.id ?? "?";
|
|
const keys = Array.isArray(a.roles) ? a.roles.map((r) => r.key).join(",") : "(none)";
|
|
console.log(` ${id} roles=[${keys}]`);
|
|
}
|
|
|
|
// Already correct?
|
|
if (
|
|
userAuths.length === 1 &&
|
|
Array.isArray(userAuths[0].roles) &&
|
|
userAuths[0].roles.length === 1 &&
|
|
userAuths[0].roles[0].key === "owner"
|
|
) {
|
|
console.log("Already correct — no changes needed.");
|
|
return;
|
|
}
|
|
|
|
console.log("\nDeleting existing authorizations...");
|
|
for (const a of userAuths) {
|
|
if (!a.id) continue;
|
|
await deleteAuthorization(a.id);
|
|
console.log(` deleted ${a.id}`);
|
|
}
|
|
|
|
console.log("Creating fresh owner authorization...");
|
|
const created = await createAuthorization(userId, orgId, ["owner"]);
|
|
console.log(` created ${JSON.stringify(created)}`);
|
|
console.log("Done.");
|
|
}
|
|
|
|
async function migrateGrants() {
|
|
// Ensure every existing project grant for the OpenClaw Platform project
|
|
// includes the `user` role alongside `owner`. Without `user` in the
|
|
// grant, the granted org cannot invite members in `user` role —
|
|
// `CreateAuthorization` returns `Errors.Project.Role.NotFound`.
|
|
//
|
|
// Idempotent: grants already containing both keys are skipped.
|
|
// Per UpdateProjectGrant docs, `roleKeys` is REPLACE not MERGE — we
|
|
// re-send the full desired set every time.
|
|
const desired = ["owner", "user"];
|
|
const grants = await listProjectGrants();
|
|
|
|
if (grants.length === 0) {
|
|
console.log("No project grants found on this project.");
|
|
return;
|
|
}
|
|
|
|
console.log(`Found ${grants.length} grant(s) on project ${PROJECT_ID}:`);
|
|
for (const g of grants) {
|
|
const current = Array.isArray(g.grantedRoleKeys)
|
|
? g.grantedRoleKeys
|
|
: [];
|
|
const hasAll = desired.every((k) => current.includes(k));
|
|
const action = hasAll ? "SKIP" : "FIX ";
|
|
console.log(
|
|
` ${action} ${g.grantedOrganizationName.padEnd(30)} current=[${current.join(",")}]`
|
|
);
|
|
}
|
|
|
|
let fixed = 0;
|
|
for (const g of grants) {
|
|
const current = Array.isArray(g.grantedRoleKeys)
|
|
? g.grantedRoleKeys
|
|
: [];
|
|
if (desired.every((k) => current.includes(k))) continue;
|
|
// Preserve any extra roles the grant already has on top of the
|
|
// desired set (e.g. someone manually added `viewer` for a special
|
|
// case). Set semantics: union.
|
|
const merged = [...new Set([...current, ...desired])];
|
|
try {
|
|
await updateProjectGrant(g.grantedOrganizationId, merged);
|
|
console.log(
|
|
` updated ${g.grantedOrganizationName} -> [${merged.join(",")}]`
|
|
);
|
|
fixed++;
|
|
} catch (err) {
|
|
console.error(
|
|
` FAIL ${g.grantedOrganizationName}: ${err.message}`
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone. ${fixed} grant(s) updated.`);
|
|
}
|
|
|
|
const [, , cmd, ...rest] = process.argv;
|
|
|
|
const commands = {
|
|
diagnose,
|
|
apply,
|
|
"migrate-auth": () => migrateAuth(rest[0]),
|
|
"migrate-grants": migrateGrants,
|
|
};
|
|
|
|
const fn = commands[cmd];
|
|
if (!fn) {
|
|
console.error(
|
|
"Usage: zitadel-roles.mjs <diagnose|apply|migrate-auth <userId>|migrate-grants>"
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
fn().catch((err) => {
|
|
console.error(err.message ?? err);
|
|
if (err.body) console.error("body:", err.body);
|
|
process.exit(1);
|
|
});
|