#!/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 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 "); 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 |migrate-grants>" ); process.exit(2); } fn().catch((err) => { console.error(err.message ?? err); if (err.body) console.error("body:", err.body); process.exit(1); });