Skip to content

PR 7 — Partner Onboarding (Space Admin UI + Zitadel Org Grants)

This PR lights up the partner-side experience built on PR6's schema. It delivers:

  • The space-admin UI used by partner-org admins (Accenture admin) to add their own users to groups exposed by the customer (Google).
  • Zitadel Org Grants integration so a partner-org user logging in can acquire an acting_for token claim scoped to the customer.
  • An organization-switcher in Console for users who hold memberships across multiple customers' spaces.

Depends on PR 6. Schema and resolver are already in place; this PR is mostly UI + auth-token plumbing + onboarding workflow.


1. Goal

  • Space admin UI: list users in their partner org, drag/drop into exposed groups, view their org's effective access.
  • Zitadel Org Grant lifecycle: when customer admin appoints Accenture-org as a space, automate the Zitadel grant API call so Accenture users can authenticate to Console.
  • acting_for token claim: user's home org is Accenture but their authorization context is Google (the customer). Token carries both; middleware uses acting_for for resource scoping.
  • Org switcher in Console top bar for users who admin multiple spaces.
  • Onboarding wizard: customer admin creates space + invites partner admin
    • bootstraps first group exposure in one flow.

Non-goals

  • Partner-org admin self-creating new partner orgs.
  • Cross-customer aggregates for partner users ("show me all my work across Google + Microsoft").
  • Customer admin remotely managing partner-org user roster (that's the partner's HR system).
  • Multi-IdP per partner (each partner = one Zitadel org).

2. Auth flow — partner user login

1. Accenture user opens console.exto.com, clicks Login.
2. Zitadel IdP-discovery routes them to Accenture's IdP (their corporate SSO).
3. Zitadel issues token with:
     sub: accenture_user_id
     urn:zitadel:iam:user:resourceowner:id: accenture_org_id   (home org)
     urn:zitadel:iam:org:project:roles: { ... }                (per-grant)
4. Console reads token. Middleware looks up:
     - Is this user a member of group_memberships in any customer? Yes.
     - Which customers? → derived list.
5. If 1 customer → set acting_for automatically.
6. If >1 customer → org-switcher prompt: "You have access via Accenture
     to: Google, Microsoft. Choose:"
7. Once acting_for chosen → set as request claim, resolver runs against it.

The acting_for is session-level, like Focus Mode (PR2) but for partner users it's not optional — they always act for one customer at a time.

Token claim shape

Add to RequestClaims:

go
type RequestClaims struct {
    // existing
    Sub          string
    HomeOrgID    string

    // PR7
    ActingForCustomerID *uuid.UUID  // nil for direct customer users / internal
}

Resolution in internal/auth/middleware.go:

go
if isPartnerUser(claims) {       // home_org != customer_org and != admin_org
    actingFor := readActingForCookie(c.Request, claims.Sub)
    if actingFor == nil {
        // Redirect to /switch-customer
        c.AbortWithStatusJSON(409, gin.H{"error": "select customer"})
        return
    }
    claims.ActingForCustomerID = &actingFor.CustomerID
}

Effective grants resolver from PR6 then runs scoped to ActingForCustomerID.


3. Zitadel Org Grants

Zitadel's Org Grants feature: an org can grant another org a project role. We use it to model the Customer→Partner relationship.

When a customer admin creates a space and appoints Accenture as the partner org (PR6 endpoint POST /spaces/:sid/admins), Console:

  1. Calls Zitadel API:
    POST /management/v1/orgs/{customer_org_id}/grants
    Body: {
      granted_org_id: accenture_org_id,
      project_id:     console_project_id,
      role_keys:      ["space_admin"]    // Console-defined Zitadel role
    }
  2. Stores Zitadel grant ID in spaces.zitadel_grant_id for cleanup.
  3. On space archival or partner-org change, Console deletes the Zitadel grant.

Without this grant, Accenture users cannot authenticate to Console for Google's customer context — Zitadel rejects them at the IdP layer. With the grant, Accenture users see "Console" as an authorized app in their Zitadel app catalog.

Why use Zitadel for this and not just Console DB

Zitadel handles the cross-org consent, IdP federation, and revocation at the identity layer. Building the same in Console = duplicating SSO plumbing. Zitadel feature exists; use it.

Schema addition

sql
ALTER TABLE spaces
  ADD COLUMN zitadel_grant_id TEXT,
  ADD COLUMN partner_org_zitadel_id TEXT;

Filled when partner-org-binding is set; null until then. Allows creating a space with no partner yet (for staging) and binding later.


4. Space-admin UI

Pages

/spaces — list of spaces I admin (across customers if applicable).

/spaces/:id — single space view:

  • Header: customer name + partner-org name + count of groups exposed
  • Tab 1: Members — table of all users in groups exposed to this space. Filter by group. Bulk add/remove.
  • Tab 2: Groups — read-only list of groups exposed to this space: group name, what permissions it grants (catalog-derived display), what resources it covers (scope summary).
  • Tab 3: My organization — partner org's user list (sourced from Zitadel for the partner_org_id). For each user: which groups they're in.

Drag-and-drop / picker

The primary action is membership management. UI:

  • Two-column layout: left = my org users (filterable), right = groups.
  • Drag user → group OR multi-select user(s) + click "Add to group".
  • Remove via row-level menu.

Boring on purpose. No role/permission visibility. Space admin sees what they need to do their job (memberships) and nothing else.

Partner users see a stripped sidebar:

ItemVisibility
Switch CustomerIf user has multiple acting_for options
My WorkspaceRoutes to /customers/:acting_for/...
My OrganizationVisible only to space admins
AccountAlways (own profile)

The "My Workspace" routes are filtered by the partner user's resolved permissions — same code path as direct customer users, the resolver just sourced from groups instead of portal_role.


5. Onboarding wizard (customer-admin side)

/customers/:id/spaces/new is a 4-step wizard:

  1. Name the space. "Accenture", "AWS Pro Services", etc.
  2. Pick or invite partner org.
    • If Accenture already exists in Zitadel and has done business with us before → pick from list.
    • Otherwise → invite by domain: enter accenture.com, email an admin contact, Console creates a Zitadel org placeholder and triggers verification. Until partner accepts, space is pending.
  3. Expose initial groups. Multi-select existing groups; or "Create new group" inline shortcut.
  4. Invite first space admin. Email + name. On accept, they're added to space_admins and provisioned via Zitadel grant.

After step 4, customer admin sees a summary card on the space detail page with "Pending" state until partner accepts.

Pending-state lifecycle

StateTriggerVisible to
pendingSpace created, partner invite sent, not yet acceptedCustomer admin only
activePartner admin accepts invite + Zitadel grant succeedsCustomer admin + space admin
archivedCustomer admin archives the spaceCustomer admin (read-only)
revokedCustomer admin revokes Zitadel grant; partner cuts offCustomer admin + audit only

Add state enum column to spaces.


6. Endpoints

Space admin (partner)

MethodPathPurpose
GET/api/v1/me/spacesMy admined spaces (already in PR6)
GET/api/v1/spaces/:sid/my-org-usersUsers in partner org (from Zitadel)
POST/api/v1/spaces/:sid/groups/:gid/membersAdd member (must be from partner org)
DELETE/api/v1/spaces/:sid/groups/:gid/members/:uidRemove member

Boundary checks in POST /spaces/:sid/groups/:gid/members:

  • Caller is in space_admins for this space → 403 if not
  • Group is in space_group_grants for this space → 403 if not
  • Target user's home_org matches spaces.partner_org_zitadel_id → 403 otherwise (cannot add a Google user to Accenture-Space)

These three checks together = the entire delegation security boundary. Test them individually; integration-test the conjunction.

MethodPathPurpose
GET/api/v1/me/acting-forCurrent acting_for + list of options
POST/api/v1/me/acting-forSet acting_for to {customerId} (writes cookie)
DELETE/api/v1/me/acting-forClear (re-prompts switcher)

Cookie shape mirrors PR2 focus cookie:

console_acting_for=v1.<base64(customerID|expiresAt|hmac)>
HttpOnly; Secure; SameSite=Strict; Path=/api
Max-Age: 86400  (24h, longer than focus mode since it's a primary working context)

Key difference vs PR2: focus mode is optional narrowing for any internal user; acting-for is mandatory selection for partner users.


7. Audit log

ActionSubjectTarget
partner.space.zitadel_grant_createdsystem + customer admin(space, partner_org_id)
partner.space.zitadel_grant_revokedsystem + customer admin(space, partner_org_id)
partner.space.invite_sentcustomer admin(space, email)
partner.space.invite_acceptedpartner admin (first login)space
partner.acting_for.enteredpartner usercustomer
partner.acting_for.exitedpartner usercustomer
partner.acting_for.switchedpartner usernew customer
partner.group.member_added (already in PR6)space admin(group, user)

Acting-for transitions are higher-noise than focus-mode transitions (every partner user does it on every login). Sample at 1/N or batch by session if log volume becomes a concern.


8. Tests

LayerTest
ZitadelMocked grant create/delete on space lifecycle
MiddlewarePartner user without acting_for cookie → 409 "select customer"
MiddlewarePartner user's acting_for → resolver runs scoped to that customer
EndpointsSpace admin can add member from partner org → 200
EndpointsSpace admin tries to add user from a different org → 403
EndpointsSpace admin tries to add to a group not exposed to their space → 403
EndpointsRandom partner user (not space admin) tries to add member → 403
EndpointsPOST /me/acting-for with customer the user has no membership for → 403
E2E manualFull onboarding: customer admin invites Accenture → Accenture accepts → Accenture admin adds Bob → Bob logs in, picks Google, can read tenant
E2E manualPartner user with multiple acting_for options sees switcher; choosing one isolates queries
E2E manualCustomer admin archives space → partner users next request gets 403; subsequent login gets "no spaces"

9. Implementation order

StepLayerDescription
1DBMigration 009_spaces_state_and_zitadel.sql (state enum, zitadel ids)
2BackendZitadel client wrapper for org-grants (create / delete / lookup)
3Backendinternal/auth/middleware.go — partner-user detection + acting_for cookie
4Backend/me/acting-for endpoints
5BackendSpace-admin write endpoints + boundary checks
6Backend/spaces/:sid/my-org-users proxying Zitadel for partner-org user list
7BackendSpace lifecycle integration: state transitions + Zitadel grant calls
8BackendAudit log entries
9TestsMocked Zitadel + boundary-check matrix
10FrontendActing-for switcher + cookie wiring
11FrontendPartner-user sidebar layout
12FrontendSpace-admin UI (members, groups read-only, my-org)
13FrontendCustomer-admin onboarding wizard
14DocsUpdate docs/roles-and-permissions.md with partner section
15OpsRunbook: how to invite a partner, how to revoke, how to debug a stuck Zitadel grant

10. Risk register

RiskMitigation
Zitadel grant API call fails after space row created → orphan spaceTwo-phase commit: create space in pending, only mark active after grant succeeds; cleanup job for stale pending
Partner user's home org changes (rebrand, reorg) — old grants point to dead orgZitadel emits org events; consume webhook + mark spaces revoked automatically
Partner admin adds the same user to many groups → permission union surprises customerCustomer-admin UI shows effective permissions per partner user (computed); audit-friendly
acting_for cookie HMAC drift if user's sub changes (impossible in Zitadel? confirm)HMAC bound to sub; if mismatch, force re-pick
Partner user with no acting_for chosen yet hits a deep-linked URL409 redirects to /switch-customer; preserves return-to
Zitadel rate limits on org-grant creation during bulk customer onboardingQueue + retry; surface "pending" state honestly to customer admin
Customer admin revokes a space while partner user mid-sessionResolver re-runs each request → next request 403/redirected to switcher; cookie expiry caps blast radius
Partner user simultaneously a customer-portal user of another customerToken has home_org; resolver picks correct path. Both shouldn't grant unintended cross-access — covered by drift test