Skip to content

PR 6 — Spaces + Groups Foundation

This is the largest authorization PR. It introduces the Spaces + Groups model that lets a customer (Google) delegate scoped access to a partner company (Accenture) without giving partner users any first-class portal role. Permissions live on customer-defined groups; partner-org admins ("space admins") only manage which of their people sit in which exposed groups.

This PR builds the schema, resolver, and admin-side mechanics. The partner-side UI and Zitadel Org Grants integration are PR7.

Depends on PR 1, PR 5. PR1 supplies the customer_grants table (extended by polymorphic subjects). PR5 supplies the DB-backed permission catalog (extended with two tiers: system + customer).


1. Goal

  • Schema: spaces, space_admins, groups, group_roles, group_scopes, space_group_grants, group_memberships.
  • Two-tier role catalog: keep system roles from PR5; add customer-defined custom roles scoped to one customer.
  • Resolver: effective_permissions(user, customer) walks groups → roles → permissions and intersects with group_scopes.
  • Backend: customer-admin-only endpoints to create/edit groups, bind roles, expose to spaces.
  • Audit: every space, group, grant, exposure, membership change logged.
  • Test: full E2E — Google admin creates Accenture-Space, defines TenantEditor group, exposes to space, appoints Accenture admin → Accenture admin adds Accenture user → Accenture user successfully reads tenant.

Non-goals

  • Partner-side UI (PR7).
  • Zitadel Org Grants integration (PR7).
  • Group inheritance / nested groups.
  • Time-bound memberships.
  • Direct user grants (use a one-member group instead — explicit + audit-friendly).

2. Conceptual model

                                   ┌───────────────────────────────┐
Customer (Google)                  │  Permission catalog (PR5)     │
  ▲                                │  - system permissions         │
  │                                │  - tenant.read, billing.*, ...│
  │ owns                           └─────────────┬─────────────────┘
  │                                              │
  │     defines       ┌──────────────┐    bound  │
  ├──────────────────▶│  Roles       │◀──────────┘
  │                   │  - system    │
  │                   │  - custom    │
  │                   └──────┬───────┘
  │                          │
  │     defines       ┌──────▼───────┐
  ├──────────────────▶│  Groups      │── scope ──▶ tenants/instances
  │                   │ (customer-   │
  │                   │  level)      │
  │                   └──────┬───────┘
  │                          │
  │     creates              │ exposes to
  │     ┌───────┐            │
  ├────▶│ Space │◀───────────┘   ┌───────────────────┐
  │     └───┬───┘                │ Membership:       │
  │         │                    │ user ∈ group      │
  │         │ space_admins       │                   │
  │         │ (Accenture)        │ Managed by:       │
  │         └────────────────────┤  - customer admin │
  │                              │    (direct users) │
  │                              │  - space admin    │
  │                              │    (partner users)│
  │                              └───────────────────┘

Strict boundary

Customer admin (Google) controlsSpace admin (Accenture) controls
Permission catalog (PR5)
Custom roles + role↔permission
Groups + group↔role binding
Group resource scope
Which groups exposed to space
Space creation + admin appointment
Add/remove their own org's users to/from exposed groups

Compromised space admin's blast radius: shuffles their own users among groups already authorized. No escalation. No lateral access.


3. Schema

New migration: internal/db/migrations/008_spaces_and_groups.sql

sql
-- Two-tier role catalog. PR5 created roles with scope='system'.
-- Custom roles scope to a specific customer.
ALTER TABLE roles
  ADD COLUMN customer_id UUID REFERENCES customers(id) ON DELETE CASCADE,
  ADD CONSTRAINT roles_scope_consistency
    CHECK ((scope = 'system'   AND customer_id IS NULL)
        OR (scope = 'customer' AND customer_id IS NOT NULL));

CREATE INDEX idx_roles_customer ON roles(customer_id) WHERE scope = 'customer';

-- Spaces — partner containers under a customer.
CREATE TABLE spaces (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id     UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  partner_org_id  UUID,                         -- Zitadel org of partner; null until PR7
  name            TEXT NOT NULL,
  description     TEXT,
  created_by      UUID REFERENCES internal_users(id),
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  archived_at     TIMESTAMPTZ,
  UNIQUE (customer_id, name)
);

CREATE INDEX idx_spaces_customer ON spaces(customer_id) WHERE archived_at IS NULL;

-- Space admins — partner-org users who manage memberships.
CREATE TABLE space_admins (
  space_id    UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL,                    -- references whichever user table; resolved via Zitadel sub
  added_by    UUID REFERENCES internal_users(id),
  added_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (space_id, user_id)
);

-- Groups — customer-level permission buckets.
CREATE TABLE groups (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  description TEXT,
  created_by  UUID REFERENCES internal_users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  archived_at TIMESTAMPTZ,
  UNIQUE (customer_id, name)
);

CREATE INDEX idx_groups_customer ON groups(customer_id) WHERE archived_at IS NULL;

-- Group → role binding (one group can hold multiple roles).
CREATE TABLE group_roles (
  group_id  UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  role_name TEXT NOT NULL REFERENCES roles(name),
  PRIMARY KEY (group_id, role_name)
);

-- Group resource scope — which tenants/instances/customers this group covers.
CREATE TABLE group_scopes (
  group_id      UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  resource_type TEXT NOT NULL CHECK (resource_type IN ('customer', 'tenant', 'instance')),
  resource_id   UUID NOT NULL,
  PRIMARY KEY (group_id, resource_type, resource_id)
);

CREATE INDEX idx_group_scopes_resource ON group_scopes(resource_type, resource_id);

-- Space-group grant — customer admin's decision to expose a group to a space.
CREATE TABLE space_group_grants (
  space_id   UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  group_id   UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  granted_by UUID REFERENCES internal_users(id),
  granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (space_id, group_id)
);

-- Group memberships — user ∈ group.
CREATE TABLE group_memberships (
  group_id    UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL,
  added_by    UUID,                              -- customer admin OR space admin
  added_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (group_id, user_id)
);

CREATE INDEX idx_group_memberships_user ON group_memberships(user_id);

-- Activate polymorphic subject in customer_grants from PR1 §5.
-- (No DDL change; PR1 already created the column. PR6 starts writing 'group' rows.)

4. Resolver

Single function called by middleware after PR1's customer-scope resolver:

go
// EffectiveGrants returns customer/instance scope and effective permissions
// for a user, walking groups + roles. Combines with PR1 direct grants.
type EffectiveGrants struct {
    CustomerIDs   []uuid.UUID    // union of direct + group-derived
    InstanceIDs   []uuid.UUID    // union of direct + group-derived
    Permissions   []string       // union of role permissions across all groups
    Source        string         // diagnostic: which paths contributed
}

func ResolveEffectiveGrants(ctx context.Context, db *sql.DB, userID uuid.UUID) (*EffectiveGrants, error)

Resolution algorithm

1. direct_customer_grants = SELECT customer_id FROM customer_grants
                            WHERE grantee_type='user' AND grantee_id=$user

2. direct_instance_grants = SELECT instance_id FROM instance_grants
                            WHERE grantee_type='user' AND grantee_id=$user

3. user_groups            = SELECT group_id FROM group_memberships
                            WHERE user_id=$user

4. group_customer_grants  = SELECT customer_id FROM customer_grants
                            WHERE grantee_type='group'
                              AND grantee_id IN user_groups

5. group_scope_resources  = SELECT resource_type, resource_id FROM group_scopes
                            WHERE group_id IN user_groups
                            -- expand 'customer' to all its tenants/instances
                            -- expand 'tenant' to its instance via tenants.instance_id

6. role_permissions       = SELECT permission_action FROM role_permissions
                            WHERE role_name IN (
                              SELECT role_name FROM group_roles
                               WHERE group_id IN user_groups
                            )

7. effective.customers = direct_customer_grants ∪ group_customer_grants
   effective.instances = direct_instance_grants ∪ group-scope-derived instances
   effective.permissions = direct_role_permissions ∪ role_permissions

Caching

Per-request only (resolver runs once per request). Cross-request cache invalidates on group membership / scope / grant changes — too many write paths; not worth caching now. If hot path becomes a bottleneck, add a materialized effective_user_access view as a follow-up.

Composition with PR1 / PR3

go
// In middleware (after PR1, PR3 plumbing):
eg := ResolveEffectiveGrants(ctx, db, claims.UserID)
claims.Scope         = &CustomerScope{CustomerIDs: eg.CustomerIDs, ...}
claims.InstanceScope = &InstanceScope{InstanceIDs: eg.InstanceIDs, ...}
claims.Permissions   = eg.Permissions

The repository helpers from PR1 §5 / PR3 §5 unchanged — they read scope from claims; they don't care whether scope came from direct grant or group membership.


5. Customer-admin endpoints

Gated to customer owner / admin (and account_manager since AM holds platform_admin scope on assigned customers).

MethodPathPurpose
POST/api/v1/customers/:id/spacesCreate space
GET/api/v1/customers/:id/spacesList spaces
PATCH/api/v1/customers/:id/spaces/:sidRename / archive
POST/api/v1/customers/:id/spaces/:sid/adminsAppoint space admin
DELETE/api/v1/customers/:id/spaces/:sid/admins/:uidRevoke space admin
POST/api/v1/customers/:id/groupsCreate group
GET/api/v1/customers/:id/groupsList groups
PATCH/api/v1/customers/:id/groups/:gidRename / archive
PUT/api/v1/customers/:id/groups/:gid/rolesSet bound roles (replace)
PUT/api/v1/customers/:id/groups/:gid/scopesSet resource scopes (replace)
POST/api/v1/customers/:id/spaces/:sid/grantsExpose group to space
DELETE/api/v1/customers/:id/spaces/:sid/grants/:gidRevoke exposure
POST/api/v1/customers/:id/groups/:gid/membersAdd member (customer admin path; for direct users)
DELETE/api/v1/customers/:id/groups/:gid/members/:uidRemove member (customer admin)
POST/api/v1/customers/:id/rolesCreate custom role
PUT/api/v1/customers/:id/roles/:rid/permissionsSet role permissions (replace)

All gated by:

  • customer_id ∈ caller's customer scope (AM)
  • Caller is customer owner / admin (portal user)
  • requirePlatformAdmin for sensitive ops? No — owner/admin/AM is enough; this is customer-scoped.

Space-admin endpoints (read-only here; PR7 adds write)

MethodPathPurpose
GET/api/v1/me/spacesSpaces I admin
GET/api/v1/spaces/:sid/exposed-groupsGroups exposed to this space
GET/api/v1/spaces/:sid/membersUsers in groups exposed here

PR7 adds POST /spaces/:sid/groups/:gid/members for partner admin membership management.


6. Frontend (customer-admin side)

PR7 covers the partner-admin surface. PR6 frontend is just the customer admin's space/group management.

New pages under web/src/pages/customer/:

  • /customers/:id/spaces — list, create
  • /customers/:id/spaces/:sid — space detail; admins, exposed groups
  • /customers/:id/groups — list, create
  • /customers/:id/groups/:gid — group detail; bound roles, scopes, members
  • /customers/:id/roles — custom-role catalog; create role with permission picker

Uses existing Table, ServerTable, Modal, ChipMultiSelect, SelectCombobox components per CLAUDE.md.

Sidebar gains a "Partner Access" section for owner/admin/AM:

ItemCondition
Spacesalways for owner/admin/AM
Groupsalways for owner/admin/AM
Custom Rolesalways for owner/admin/AM

7. Audit log

Every administrative action writes a row.

ActionSubjectTarget
customer.space.createdcustomer adminspace
customer.space.archivedcustomer adminspace
customer.space_admin.grantedcustomer admin(space, user)
customer.space_admin.revokedcustomer admin(space, user)
customer.group.createdcustomer admingroup
customer.group.archivedcustomer admingroup
customer.group.roles_setcustomer admin(group, [roles])
customer.group.scopes_setcustomer admin(group, [scopes])
customer.space_group_grant.createdcustomer admin(space, group)
customer.space_group_grant.revokedcustomer admin(space, group)
customer.group.member_addedcustomer admin OR space admin(group, user)
customer.group.member_removedcustomer admin OR space admin(group, user)
customer.role.createdcustomer adminrole
customer.role.permissions_setcustomer admin(role, [permissions])

actor.user_type distinguishes customer admin from space admin in the audit trail.


8. Tests

LayerTest
DBMigration up/down idempotency
DBCustom role with customer_id set; system role rejects customer_id (CHECK constraint)
ResolverUser in 1 group with 1 role — gets role's permissions
ResolverUser in 2 groups — permissions unioned, scopes unioned
ResolverGroup has scope customer:X — expands to tenants and instances under X
ResolverUser has direct customer_grant + group customer_grant — both contribute
EndpointsCustomer admin creates space → succeeds
EndpointsNon-admin (viewer) attempts space create → 403
EndpointsAM (assigned to this customer) creates group → succeeds
EndpointsAM (not assigned to this customer) attempts space create → 403
IntegrationE2E: Google admin sets up Accenture-Space, exposes TenantEditor → Accenture user reads tenant via Console API
DriftEvery list endpoint that PR1 §5 / PR3 §5 already covers continues to work with group-derived scope

9. Implementation order

StepLayerDescription
1DBMigration 008_spaces_and_groups.sql (also alters roles)
2Backendroles table extended; custom-role create/update endpoints
3BackendModels for spaces, groups, scopes, grants, memberships
4BackendResolveEffectiveGrants resolver
5BackendWire resolver into middleware (replace direct lookups from PR1/PR3)
6BackendCustomer-admin endpoints (Section 5)
7BackendSpace-admin read-only endpoints
8BackendAudit log writers
9TestsResolver, endpoints, integration
10FrontendPages under web/src/pages/customer/spaces, groups, roles
11FrontendSidebar — Partner Access section
12DocsUpdate docs/roles-and-permissions.md with full Spaces/Groups section

Steps 1–9 are backend + tests, can land before any UI — the existing single-org plumbing keeps working.


10. Risk register

RiskMitigation
Resolver hot path becomes slowIndexes (Section 3); follow-up materialized view if metrics show >P99 50ms
Customer admin grants a system role that exposes internal-infra to partner userSystem roles intended for internal staff (platform_admin, infra_ops) blocked from being bound to customer-level groups via API check
Group archival leaves orphan membershipsArchive cascade nulls memberships out of the resolver via archived_at IS NOT NULL filter
Space admin user themselves is also in a group and gains permissions they shouldn'tSpace admin role is purely a delegation flag; granting them group membership is a separate explicit act
Custom role created with both system and customer permissions, then customer deletedcustomer_id cascade deletes custom roles; system roles unaffected
customer_grants(grantee_type='group', grantee_id=group) plus group's own group_scopes double-countsResolver dedupes at union step; treated as set, not multiset
Partner admin promoted to customer admin somehow gains write powers across customerSpace admin endpoint set is bounded; promoting requires internal_users row, separate path