From 542a607b53da3d5dbc59a3e5c39861fa4b9a3edb Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 29 Apr 2026 09:36:36 +0200 Subject: [PATCH] Fix zitadel role issues --- src/lib/zitadel.ts | 112 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index 2ae1439..35668ec 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -156,6 +156,18 @@ export interface ProjectGrantResult { /** * Grant the "OpenClaw Platform" project to a customer organization. + * + * The grant's `roleKeys` whitelist what authorizations the customer org + * may self-manage: a grant containing only "owner" prevents the customer + * from inviting members in the `user` role, because ZITADEL rejects + * `CreateAuthorization` for any role outside the grant with + * `Errors.Project.Role.NotFound`. + * + * Default is therefore `["owner", "user"]` — the full set of customer + * roles defined in `types/index.ts::CustomerRole`. Platform roles are + * intentionally NOT granted; those are administered separately and + * should never be assignable from inside a customer org. + * * Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant */ export async function createProjectGrant( @@ -168,11 +180,44 @@ export async function createProjectGrant( { projectId: ZITADEL_PROJECT_ID, grantedOrganizationId: grantedOrgId, - roleKeys: roleKeys || ["owner"], + roleKeys: roleKeys || ["owner", "user"], } ); } +/** + * List the role keys defined on the OpenClaw Platform project. + * + * Used by the instrumentation self-check on startup to warn loudly if + * the canonical role keys (owner / user / platform_admin / platform_operator) + * are missing — a misconfiguration that silently breaks team management + * and customer registration. See `scripts/zitadel-roles.mjs` for repair. + * + * Returns [] on any error (network, auth, shape drift) so callers can + * decide what to do without inheriting a thrown exception during boot. + * + * Connect RPC: zitadel.project.v2.ProjectService/ListProjectRoles + */ +export async function listProjectRoles(): Promise { + try { + const data = await connectRpc<{ projectRoles?: any[] }>( + "zitadel.project.v2.ProjectService", + "ListProjectRoles", + { projectId: ZITADEL_PROJECT_ID } + ); + if (!data?.projectRoles || !Array.isArray(data.projectRoles)) return []; + return data.projectRoles + .map((r: any) => (typeof r?.key === "string" ? r.key : "")) + .filter(Boolean); + } catch (err) { + console.warn( + `Failed to list project roles for ${ZITADEL_PROJECT_ID} (returning empty):`, + err + ); + return []; + } +} + // --------------------------------------------------------------------------- // v2 Authorization API — Connect RPC // --------------------------------------------------------------------------- @@ -305,6 +350,24 @@ export interface OrgAuthorization { * * Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations * + * Implementation note (filter shape & response parsing) + * ----------------------------------------------------- + * The v2 AuthorizationService accepts a `filters` array of oneof variants + * (project_id, organization_id, role_key, …) but the JSON-over-Connect + * wrapper naming differs between ZITADEL versions and isn't well-documented + * for ID filters. Rather than chase a moving target, we fetch all + * authorizations the SA can see and narrow client-side by project+org. + * At pilot scale this is a single sub-100-row query — well within budget. + * + * Response shape (v2 stable, confirmed against ZITADEL v4.12): + * authorizations: [{ + * id, state, + * project: { id, name, organizationId }, + * organization: { id, name }, + * user: { id, displayName, preferredLoginName, … }, + * roles: [{ key, displayName, group }], + * }] + * * Returns [] on any error so the team page can render a degraded view * (members visible, roles blank) rather than blowing up entirely. */ @@ -315,32 +378,29 @@ export async function listOrgAuthorizations( const data = await connectRpc<{ authorizations?: any[] }>( "zitadel.authorization.v2.AuthorizationService", "ListAuthorizations", - { - filters: [ - { organizationId: orgId }, - { projectId: ZITADEL_PROJECT_ID }, - ], - // Cap at 500 — far more than a pilot org should ever need - pagination: { limit: 500 }, - } + { pagination: { limit: 1000 } } ); if (!data?.authorizations || !Array.isArray(data.authorizations)) { return []; } - return data.authorizations.flatMap((row: any) => { - const userId = row?.userId ?? ""; - if (!userId) return []; - return [ - { - authorizationId: row.id ?? row.authorizationId ?? "", - userId, - organizationId: row.organizationId ?? orgId, - projectId: row.projectId ?? ZITADEL_PROJECT_ID, - roleKeys: Array.isArray(row.roleKeys) ? row.roleKeys : [], - } as OrgAuthorization, - ]; - }); + return data.authorizations + .filter( + (row: any) => + row?.project?.id === ZITADEL_PROJECT_ID && + row?.organization?.id === orgId + ) + .map((row: any) => ({ + authorizationId: row.id ?? "", + userId: row.user?.id ?? "", + organizationId: row.organization?.id ?? orgId, + projectId: row.project?.id ?? ZITADEL_PROJECT_ID, + roleKeys: Array.isArray(row.roles) + ? row.roles + .map((r: any) => (typeof r?.key === "string" ? r.key : "")) + .filter(Boolean) + : [], + })); } catch (err) { console.warn( `Failed to list authorizations for org ${orgId} (returning empty):`, @@ -402,8 +462,12 @@ export async function registerCustomer(params: { ); } - // 4. Grant project to org - const grant = await createProjectGrant(org.organizationId, ["owner"]); + // 4. Grant project to org with both customer roles so the org's + // owner can invite users in either `owner` or `user` role afterwards. + const grant = await createProjectGrant(org.organizationId, [ + "owner", + "user", + ]); // 5. Assign "owner" role to user await createAuthorization({