Appearance
Authorization Roadmap
This document is the binding architectural plan for Console + exto-go authorization. It frames the full target model, maps every known use case to a concrete PR, and locks foundation decisions today so later phases (partner companies, Spaces, Groups) drop in without rewriting earlier work.
Audience: Console + exto-go contributors who need to know which authz mechanism fits a new feature, and which PR to land it in.
Status: Living plan. Each PR doc (1, 2, 3, …) is the source of truth for its scope; this doc is the index + the rationale.
1. Vision
Console authorization will compose three orthogonal mechanisms. None of them is "the" authz model — each fits a different question.
| Mechanism | Answers | Example |
|---|---|---|
| Roles | What actions can this subject perform? | account_manager can invite users |
| Subject Scope | Which resources is this subject restricted to? | This AM only sees customers Acme + Globex |
| Spaces + Groups | What did the resource owner delegate to a partner? | Google exposes Tenant-Editors group to Accenture-Space |
The decision spine:
Request comes in
│
├─▶ Who is the user? → identity (Zitadel)
│
├─▶ What roles do they hold? → roles (Console DB)
│
├─▶ What scope are they in? → subject scope OR group memberships
│
├─▶ What does the role/group permit? → permission catalog
│
└─▶ Apply scope filter to query → repository helperThis is scoped RBAC + delegated group membership. Industry-equivalent shapes: AWS IAM + RAM, GCP IAM + Resource Manager, GitHub Orgs + Teams + Outside Collaborators. We build minimum viable today, generalize as contracts demand.
2. Use cases → PRs
Maps the 13 known access patterns to PRs. Numbers in [brackets] reference the use case ID.
| Use case | Mechanism | PR |
|---|---|---|
| [1] Customer admin | Portal admin (exists). Tenant CREATE removed — see §2.1. | — |
| [2] Customer owner | Portal owner (exists). Tenant CREATE removed — see §2.1. | — |
| [3] Account manager — assigned customers, all customer-side actions, billing | Internal account_manager + customer scope. Acts as platform_admin scoped to assigned customers — see §2.2. | PR1 (drafted; needs amendments per §5) |
| [4] QA — assigned customers + limited instance set, manage tenants & users (no billing/SSO) | Internal qa_admin + customer scope + instance scope | PR3 |
| [5] Infra ops — releases, env vars, instance create/update, server monitoring | Internal infra_ops (rename from ops_engineer) | (no separate PR; rename folded into PR1/PR3) |
| [6] Auditor — read all + receive worker failure notifications | Internal compliance_admin (exists) + notification subscriptions | PR8 |
| [7] Reader — read-only across platform | Internal reader (exists) | — |
| [8] Customer viewer — read-only on customer's tenants/instances/users | Portal viewer (exists) | — |
| [9] AM/QA — migrations restricted to assigned scope | Wire scope into migrations repo | PR1 §5 |
| [10] platform_admin + infra_ops — all migrations | Existing unscoped bypass | — |
| [11] Finance admin — plans, billing, customer finance dashboard | Internal finance_admin (exists) | — |
| [12] Customer usage visible to anyone in scope — but billed amounts only to finance | Field-level redaction | PR4 |
| [13] Non-finance users see usage but not billed units | Same as [12] | PR4 |
| [future] Customer Focus Mode — temporary "lens" on one customer for screen-shares | Subject scope (session source) | PR2 (drafted) |
| [future] Partner companies (Accenture for Google) — Spaces + Groups | New: Spaces + Groups + Memberships | PR6, PR7 |
2.1 Tenant lifecycle is platform-side only
Tenant creation, deletion, and cross-customer transfer cannot be performed by any customer-portal user. Customers consume tenants — they do not spawn, remove, or move them.
| Action | platform_admin | account_manager | qa_admin | customer owner/admin |
|---|---|---|---|---|
| Create tenant | ✅ | ✅ | ✅ (assigned instances) | ❌ |
| Delete tenant | ✅ | ✅ | ✅ (assigned scope) | ❌ |
| Transfer tenant (cross-customer) | ✅ | ❌ | ❌ | ❌ |
Reasons: tenant lifecycle consumes platform capacity (instance allocation, plan attachment, billing seat). Letting customers self-spawn invites uncontrolled growth and plan circumvention. Cross-customer transfers touch billing attribution + data ownership — concentrate at top to keep the audit story clean.
UI consequence: customer portal pages render existing tenants and let admins manage members/settings, but the "Create Tenant" affordance is removed. Empty-state copy directs the user to their account manager.
2.2 Account manager = platform_admin scoped to assigned customers
AM is not "above customer admin" or "below platform_admin" in a capability subset — AM holds the full platform_admin action set, restricted by customer_grants to assigned customers, minus internal infra routes (releases, env vars, instance writes, internal user management, system workers, plans/meters catalog, vault secrets).
This means AM can do everything a customer owner can do (SSO/IdP, webhooks, service accounts, customer settings, full billing) and more (create tenants, run migrations on assigned tenants) — within scope.
The two cross-customer transfers (tenant transfer, user home-org transfer) remain platform_admin only. Even AM cannot smuggle a tenant or user out of an assigned customer to one they don't manage.
Owner-transfer within a customer (changing who is owner from one portal user to another) is a customer-internal political decision — reserved for the existing customer owner. AM observes but doesn't trigger.
The full role × action matrix is §3.6 below.
3. Authorization model — layers
3.1 Identity (Zitadel)
sub— user IDurn:zitadel:iam:user:resourceowner:id— home org- Roles not stored in Zitadel claims; resolved per-request from Console DB
- For partner story: Zitadel's Org Grants feature provides org→org primitive (deferred — see PR7)
3.2 Roles
Internal roles (Console employees):
| Role | Source | PR |
|---|---|---|
platform_admin | exists | — |
account_manager | new, customer-scoped | PR1 |
qa_admin | new, customer + instance-scoped | PR3 |
infra_ops | rename of ops_engineer | PR5 |
finance_admin | exists | — |
compliance_admin | exists | — |
reader | exists | — |
Portal roles (customer users) — unchanged: owner / admin / billing / viewer / member.
Partner roles (partner-org users in a customer's space) — defined per-customer via Groups → Roles (PR6).
3.3 Subject scope
A subject (user) is constrained to a set of resources. Two axes:
- Customer scope —
customer_grantstable (PR1). AM/QA see only assigned customers. - Instance scope —
instance_grantstable (PR3). QA sees only QA-fleet instances. Layered with customer scope (AND).
Both axes implemented as grant tables, not user columns. Subject is polymorphic-ready (user today, group tomorrow). Resolution is a function on request claims, not a pre-joined view.
3.4 Permission catalog
Today: portalPermissions map in internal/app/permissions.go (code). PR5 moves it to a DB table role_permissions(role, action) so:
- New role asks become INSERT, not deploy
- Audit query "what can finance_admin do?" is
SELECT * WHERE role = 'finance_admin' - Catalog can be seeded into customer-defined roles (PR6) without duplication
Roles stay enum-in-code (rare change). Permissions become data (frequent).
3.5 Spaces + Groups (partner delegation)
Deferred to PR6/PR7 but foundation choices land in PR1.
Strict separation:
| Customer (Google) controls | Partner space admin (Accenture) controls |
|---|---|
| Permission catalog (PR5) | — |
| Roles (system + customer-defined) | — |
| Groups + group↔role binding | — |
| Group resource scope | — |
| Which groups exposed to which space | — |
| Space creation + space-admin appointment | — |
| — | Add/remove their company users to exposed groups |
User effective access = ⋃ (group.role.permissions × group.scope) over groups the user is a member of, intersected with grants exposed to the user's space.
3.6 Canonical capability matrix
The single source of truth for what each role can do. PR docs reference back to this table; if the table and a PR doc disagree, the table wins.
| Action | platform_admin | account_manager | qa_admin | infra_ops | finance_admin | compliance_admin | reader | customer owner | customer admin | customer billing | customer viewer |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer lifecycle | |||||||||||
| Create customer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Delete customer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (own) | ❌ | ❌ | ❌ |
| Tenant lifecycle (platform-side only) | |||||||||||
| Create tenant | ✅ | ✅ | ✅ (assigned instances) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Delete tenant | ✅ | ✅ | ✅ (assigned scope) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Transfer tenant cross-customer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Manage tenant settings, members | ✅ | ✅ | ✅ | ❌ | ❌ | R | R | ✅ | ✅ | R | R |
| Trigger migration on tenant | ✅ | ✅ (assigned) | ✅ (assigned) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| User lifecycle | |||||||||||
| Invite/manage customer user | ✅ | ✅ | ✅ | ❌ | ❌ | R | R | ✅ | ✅ | ❌ | ❌ |
| Suspend/reactivate user | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Promote/demote within customer (owner-transfer) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Transfer user home-org cross-customer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Customer-side configuration | |||||||||||
| SSO / IdP config | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Webhooks | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Service accounts | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Customer settings (legal name, branding) | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Billing | |||||||||||
| View invoices, payments, credits | ✅ | ✅ | ❌ | ❌ | ✅ | R | R | ✅ | ✅ | ✅ | ❌ |
| Manage billing setup, issue credits | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
| View usage with billed amounts | ✅ | ✅ | ❌ | ❌ | ✅ | R | R | ✅ | ✅ | ✅ | ❌ |
| View usage units only (no billed amount) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Plan / meter catalog (system) | ✅ | ❌ | ❌ | ❌ | ✅ | R | R | ❌ | ❌ | ❌ | ❌ |
| Infra (internal) | |||||||||||
| Releases, environment vars | ✅ | ❌ | ❌ | ✅ | ❌ | R | R | ❌ | ❌ | ❌ | ❌ |
| Instance create/update | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | R | ❌ | ❌ | ❌ | ❌ |
| Resolve DB URI (vault) | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| System workers | ✅ | ❌ | ❌ | ✅ | ❌ | R | R | ❌ | ❌ | ❌ | ❌ |
| Internal users management | ✅ | ❌ | ❌ | ❌ | ❌ | R | R | ❌ | ❌ | ❌ | ❌ |
| User attribute definitions | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Audit / observability | |||||||||||
| Audit log, email logs | ✅ | ✅ (scope) | ✅ (scope) | ✅ | ✅ | ✅ | R | R (own customer) | R (own customer) | R (own customer) | R (own customer) |
| Discrepancies inbox | ✅ | ❌ | ❌ | ✅ | ❌ | R | R | ❌ | ❌ | ❌ | ❌ |
| Receive worker-failure notifications | opt-in | opt-in | opt-in | ✅ | opt-in | ✅ | opt-in | n/a | n/a | n/a | n/a |
Legend: ✅ read+write · R = read only · ❌ no access · (scope) = restricted by customer_grants / instance_grants · (own) = within their own customer only · opt-in = available via PR8 notification subscription.
The three platform-only floors (highlighted across the matrix):
- Create customer — only
platform_admin - Cross-customer tenant transfer — only
platform_admin - Cross-customer user home-org transfer — only
platform_admin
These cannot be granted to AM, QA, or any customer role. They sit above the scope axis entirely.
3.7 Field-level redaction
Some fields hide based on role even when the row is in scope (use case [12] [13]: billed amounts on usage rows).
PR4 introduces a single redactor pattern at the serializer layer:
go
func RedactUsageRow(row *Usage, rc *app.RequestContext) {
if !app.CanPerform(rc, "view_billed_units") {
row.BilledAmount = nil
}
}Not field-level ACL in DB. Just a serializer step keyed off the existing permission catalog. One pattern, reused for any future redaction.
3.7 Notification subscriptions
Auditor [6] requires worker-failure notifications. Not an authz concern — modeled as notification_subscriptions(user_id, event_type) (PR8). Kept orthogonal so any role can subscribe; subscription doesn't grant permissions.
4. PR sequence
In-flight scope: PR1 → PR2, PR3, PR4, PR5, PR8. All 13 documented use cases are covered by these six PRs.
Deferred: PR6 and PR7 (partner companies / Spaces + Groups). No contract today; YAGNI. Forward-compat already paid in PR1 (customer_grants.grantee_type) so revival is a clean schema add, not a rewrite. Docs kept in repo as the design memo for when revival happens.
PR1 ─┬─▶ PR3
├─▶ PR2
├─▶ PR5 (PR6 / PR7 deferred — see §4.1)
└─▶ PR4
PR8 (independent)| PR | Title | Depends on | Status |
|---|---|---|---|
| PR1 | Account Manager + Customer Scope Foundation | — | drafted |
| PR2 | Customer Focus Mode | PR1 | drafted |
| PR3 | QA Role + Instance Scope | PR1 | planned |
| PR4 | Field-Level Redaction (Billed Units) | — | planned |
| PR5 | Permission Catalog (DB-Backed) | PR1 | planned |
| PR6 | Spaces + Groups Foundation | PR5 | deferred |
| PR7 | Partner Onboarding (Space Admin UI + Zitadel) | PR6 | deferred |
| PR8 | Notification Subscriptions | — | planned |
4.1 PR6 / PR7 revival triggers
Don't restart PR6/7 work until one of:
- Signed contract with a partner company (concrete revenue, tenant count).
- Repeated workaround: sales creates sub-customers to fake partner separation 3+ times.
- Repeated customer ask: customer admin asks "give vendor X scoped access" 3+ times unprompted.
- Compliance ask: auditor requires cross-org grant visibility and current model can't answer.
5. PR1 forward-compat amendments
PR1 as drafted is correct for single-org. Two naming/shape tweaks make it extend cleanly into PR3, PR6, PR7 without rewriting:
5.1 Rename table
diff
- internal_user_customer_scopes
+ customer_grantsReason: PR3 will add instance_grants (parallel shape). PR6 makes the subject polymorphic (grantee_type 'user' | 'group'). Naming the table around the resource (customer) — not the subject — keeps a consistent family: customer_grants, instance_grants, etc.
5.2 Generalize subject column today
diff
CREATE TABLE customer_grants (
- internal_user_id UUID NOT NULL REFERENCES internal_users(id) ON DELETE CASCADE,
+ grantee_type TEXT NOT NULL DEFAULT 'user' CHECK (grantee_type IN ('user', 'group')),
+ grantee_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
granted_by UUID REFERENCES internal_users(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (internal_user_id, customer_id)
+ PRIMARY KEY (grantee_type, grantee_id, customer_id)
);PR1 only writes rows with grantee_type = 'user'. PR3 adds the parallel instance_grants table with the same shape. PR6 starts writing 'group' rows. Resolver in PR1:
go
SELECT customer_id FROM customer_grants
WHERE grantee_type = 'user' AND grantee_id = $1PR6 extends:
go
SELECT customer_id FROM customer_grants
WHERE (grantee_type = 'user' AND grantee_id = $user)
OR (grantee_type = 'group' AND grantee_id IN (user's groups))No migration cost. No rewrite. ~5 extra lines in PR1.
5.3 CustomerScope.Source documents lineage
PR1 already includes Source string. Values today: "account_manager" | "focus_mode" | "intersection". Future values: "group_membership", "space_grant". No type change.
5.4 AM = scoped platform_admin (route gating reframe)
PR1 §6 currently lists routes AM does reach. After the use-case clarification, AM holds the platform_admin action set on customer-side routes — the cleaner model is to list routes AM is denied from:
go
// Internal-infra routes — denied to any scoped internal role
// (AM, QA). platform_admin and infra_ops still reach them.
var internalInfraRoutes = []string{
"/api/v1/internal-users/*",
"/api/v1/system-workers/*",
"/api/v1/releases/*",
"/api/v1/environment/*",
"/api/v1/plans/*",
"/api/v1/meters/*",
"/api/v1/user-attributes/*",
"/api/v1/instances/:id/connection/*", // vault, redirect URIs
"/api/v1/instances", // POST / PUT writes
// discrepancies + system migrations system view
}
// Platform-only floors — even platform_admin scopes don't apply;
// no scoped role passes.
var platformOnlyRoutes = []string{
"POST /api/v1/customers", // create customer
"POST /api/v1/tenants/:id/transfer", // cross-customer transfer
"POST /api/v1/users/:id/transfer-home-org", // cross-customer transfer
}PR1 doc must reflect: AM's allowed-route list expands to include SSO, IdP, webhooks, service accounts, customer settings, full billing. Sidebar items expand correspondingly. The denial list above + the existing :id membership check (PR1 §4) is the security boundary.
5.5 Owner-transfer within customer stays customer-side
POST /api/v1/customers/:id/transfer-ownership keeps requirePortalRole(owner). Do not add an AM bypass — owner-transfer is a customer-internal political act. AM observes via the audit log; does not initiate.
6. Decision log
Why scoped RBAC and not full RBAC
Real systems mix mechanisms. Picking "full RBAC" forces wrong patterns (e.g. inventing a customer-specific role per assignment). Picking "ACL" forces resource owners to manage grants on every resource. Scoped RBAC fits 90% of asks; we add ABAC (field redaction) and group memberships where the simple model breaks.
Why grant table, not user column
Subject scope as a column on internal_users doesn't extend to group-as-subject (PR6) or session-as-subject (focus mode, PR2). Grant table is the universal shape.
Why DB permission catalog (PR5), not config file
Catalog will be queried ("what can role X do?") for audit, customer-defined roles (PR6 custom roles), and UI ("show all groups that grant this permission"). Code-only catalog can't answer those.
Why permissions on role, not direct on group
50–100 fine-grained actions in a mature catalog. Direct group→permission becomes ungovernable. Role = named bundle = vocabulary. Production systems (GCP, AWS, GitHub Enterprise, Salesforce) all chose this. Optional inline-permission-on-group as escape hatch (PR6); document as anti-pattern.
Why customer owns access definitions, partner owns membership
Strict delegation boundary. Compromised partner admin can shuffle their own users among groups customer already authorized — cannot escalate. Same shape as AWS RAM, Slack Connect, GitHub Outside Collaborators.
Why Zitadel for identity, Console DB for authorization
Zitadel's roles/orgs are coarse and enterprise-focused. Console's authz is product-domain (customers, tenants, instances). Mixing the two locks us to Zitadel's data model. Identity in Zitadel; authz state in our DB. Org Grants (PR7) is the one Zitadel feature we adopt — for partner→home-org boundaries that must exist outside our DB.
Why no field-level ACL in DB
Field redaction is rare and predictable (billed amounts, PII). Keep it simple: serializer-time check against existing permission catalog. Don't build column-grants infrastructure for one use case.
7. Cross-system: what Console vs exto-go own
Today Console is the authz authority. exto-go does its own per-tenant role enforcement. PR6 changes this: Spaces + Groups + Memberships are customer-level (one customer can have multiple tenants under multiple spaces) — they belong in a shared service, not Console-only.
Options:
- exto-go owns spaces/groups, Console reads. Fits "customer-level data." Console UI calls exto-go API for partner setup.
- Console owns, exto-go reads. Console is already the customer admin surface; concentrate authz.
- Shared library + shared DB schema. Both services hit same tables.
PR6 will pick. Likely 2 — Console is already where customer admins live, and exto-go calls Console's /me/effective-permissions endpoint already (or can). Decision deferred to PR6 design.
8. Out of scope for this roadmap
- Time-bound assignments / scheduled access. "Grant Acme to Alice for 4 hours then auto-revoke." Add when concrete need lands.
- Approval workflows. "Bob requests access to Acme; Carol approves." Significant UI + state machine. Deferred.
- Per-row ACL. "User can see tenant T1 but not T2 in same customer." Workaround: split tenants across customers. If a real ask appears, consider then.
- Customer-self-service partner onboarding. PR7 lets customer admins set up spaces. Self-service signup of partners (Accenture employee signs up directly to Google's space) is further out.
- Cross-customer aggregates for partner users. Accenture works for both Google and Microsoft — does Bob see "all my work across customers"? Out of scope. Per-space context only.
9. Glossary
| Term | Definition |
|---|---|
| Subject | Who is acting. User today; group in PR6. |
| Role | Named bundle of permissions. Internal roles are platform-defined; customer roles in PR6. |
| Permission | A single action verb (tenant.read, billing.invoice.create). |
| Customer scope | Set of customers a subject is restricted to. Empty/nil = unscoped. |
| Instance scope | Set of instances a subject is restricted to. Layered AND with customer scope. |
| Space | Partner container under a customer. One per customer↔partner relationship. |
| Group | Customer-level bucket of users with bound roles. Exposed to spaces. |
| Space-group grant | Customer admin's explicit decision to allow a space's admins to assign users into a group. |
| Membership | A user ∈ group relation, managed by space admins for partner users or customer admins for direct users. |
| Effective permissions | What a user can actually do, given roles + scope + group memberships, computed per-request. |
| Strict scope | Out-of-scope :id returns 403 (security boundary). AM, QA. |
| Soft scope | Out-of-scope :id returns 404 (UX hide). Focus mode. |

