Skip to content

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.

MechanismAnswersExample
RolesWhat actions can this subject perform?account_manager can invite users
Subject ScopeWhich resources is this subject restricted to?This AM only sees customers Acme + Globex
Spaces + GroupsWhat 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 helper

This 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 caseMechanismPR
[1] Customer adminPortal admin (exists). Tenant CREATE removed — see §2.1.
[2] Customer ownerPortal owner (exists). Tenant CREATE removed — see §2.1.
[3] Account manager — assigned customers, all customer-side actions, billingInternal 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 scopePR3
[5] Infra ops — releases, env vars, instance create/update, server monitoringInternal infra_ops (rename from ops_engineer)(no separate PR; rename folded into PR1/PR3)
[6] Auditor — read all + receive worker failure notificationsInternal compliance_admin (exists) + notification subscriptionsPR8
[7] Reader — read-only across platformInternal reader (exists)
[8] Customer viewer — read-only on customer's tenants/instances/usersPortal viewer (exists)
[9] AM/QA — migrations restricted to assigned scopeWire scope into migrations repoPR1 §5
[10] platform_admin + infra_ops — all migrationsExisting unscoped bypass
[11] Finance admin — plans, billing, customer finance dashboardInternal finance_admin (exists)
[12] Customer usage visible to anyone in scope — but billed amounts only to financeField-level redactionPR4
[13] Non-finance users see usage but not billed unitsSame as [12]PR4
[future] Customer Focus Mode — temporary "lens" on one customer for screen-sharesSubject scope (session source)PR2 (drafted)
[future] Partner companies (Accenture for Google) — Spaces + GroupsNew: Spaces + Groups + MembershipsPR6, 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.

Actionplatform_adminaccount_managerqa_admincustomer 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 ID
  • urn: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):

RoleSourcePR
platform_adminexists
account_managernew, customer-scopedPR1
qa_adminnew, customer + instance-scopedPR3
infra_opsrename of ops_engineerPR5
finance_adminexists
compliance_adminexists
readerexists

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 scopecustomer_grants table (PR1). AM/QA see only assigned customers.
  • Instance scopeinstance_grants table (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) controlsPartner 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.

Actionplatform_adminaccount_managerqa_admininfra_opsfinance_admincompliance_adminreadercustomer ownercustomer admincustomer billingcustomer 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, membersRRRR
Trigger migration on tenant✅ (assigned)✅ (assigned)
User lifecycle
Invite/manage customer userRR
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, creditsRR
Manage billing setup, issue credits
View usage with billed amountsRR
View usage units only (no billed amount)
Plan / meter catalog (system)RR
Infra (internal)
Releases, environment varsRR
Instance create/updateR
Resolve DB URI (vault)
System workersRR
Internal users managementRR
User attribute definitions
Audit / observability
Audit log, email logs✅ (scope)✅ (scope)RR (own customer)R (own customer)R (own customer)R (own customer)
Discrepancies inboxRR
Receive worker-failure notificationsopt-inopt-inopt-inopt-inopt-inn/an/an/an/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):

  1. Create customer — only platform_admin
  2. Cross-customer tenant transfer — only platform_admin
  3. 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)
PRTitleDepends onStatus
PR1Account Manager + Customer Scope Foundationdrafted
PR2Customer Focus ModePR1drafted
PR3QA Role + Instance ScopePR1planned
PR4Field-Level Redaction (Billed Units)planned
PR5Permission Catalog (DB-Backed)PR1planned
PR6Spaces + Groups FoundationPR5deferred
PR7Partner Onboarding (Space Admin UI + Zitadel)PR6deferred
PR8Notification Subscriptionsplanned

4.1 PR6 / PR7 revival triggers

Don't restart PR6/7 work until one of:

  1. Signed contract with a partner company (concrete revenue, tenant count).
  2. Repeated workaround: sales creates sub-customers to fake partner separation 3+ times.
  3. Repeated customer ask: customer admin asks "give vendor X scoped access" 3+ times unprompted.
  4. 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_grants

Reason: 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 = $1

PR6 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:

  1. exto-go owns spaces/groups, Console reads. Fits "customer-level data." Console UI calls exto-go API for partner setup.
  2. Console owns, exto-go reads. Console is already the customer admin surface; concentrate authz.
  3. 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

TermDefinition
SubjectWho is acting. User today; group in PR6.
RoleNamed bundle of permissions. Internal roles are platform-defined; customer roles in PR6.
PermissionA single action verb (tenant.read, billing.invoice.create).
Customer scopeSet of customers a subject is restricted to. Empty/nil = unscoped.
Instance scopeSet of instances a subject is restricted to. Layered AND with customer scope.
SpacePartner container under a customer. One per customer↔partner relationship.
GroupCustomer-level bucket of users with bound roles. Exposed to spaces.
Space-group grantCustomer admin's explicit decision to allow a space's admins to assign users into a group.
MembershipA user ∈ group relation, managed by space admins for partner users or customer admins for direct users.
Effective permissionsWhat a user can actually do, given roles + scope + group memberships, computed per-request.
Strict scopeOut-of-scope :id returns 403 (security boundary). AM, QA.
Soft scopeOut-of-scope :id returns 404 (UX hide). Focus mode.