Appearance
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)walksgroups → roles → permissionsand intersects withgroup_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) controls | Space 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_permissionsCaching
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.PermissionsThe 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).
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/customers/:id/spaces | Create space |
| GET | /api/v1/customers/:id/spaces | List spaces |
| PATCH | /api/v1/customers/:id/spaces/:sid | Rename / archive |
| POST | /api/v1/customers/:id/spaces/:sid/admins | Appoint space admin |
| DELETE | /api/v1/customers/:id/spaces/:sid/admins/:uid | Revoke space admin |
| POST | /api/v1/customers/:id/groups | Create group |
| GET | /api/v1/customers/:id/groups | List groups |
| PATCH | /api/v1/customers/:id/groups/:gid | Rename / archive |
| PUT | /api/v1/customers/:id/groups/:gid/roles | Set bound roles (replace) |
| PUT | /api/v1/customers/:id/groups/:gid/scopes | Set resource scopes (replace) |
| POST | /api/v1/customers/:id/spaces/:sid/grants | Expose group to space |
| DELETE | /api/v1/customers/:id/spaces/:sid/grants/:gid | Revoke exposure |
| POST | /api/v1/customers/:id/groups/:gid/members | Add member (customer admin path; for direct users) |
| DELETE | /api/v1/customers/:id/groups/:gid/members/:uid | Remove member (customer admin) |
| POST | /api/v1/customers/:id/roles | Create custom role |
| PUT | /api/v1/customers/:id/roles/:rid/permissions | Set role permissions (replace) |
All gated by:
customer_id∈ caller's customer scope (AM)- Caller is customer
owner/admin(portal user) requirePlatformAdminfor sensitive ops? No — owner/admin/AM is enough; this is customer-scoped.
Space-admin endpoints (read-only here; PR7 adds write)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/me/spaces | Spaces I admin |
| GET | /api/v1/spaces/:sid/exposed-groups | Groups exposed to this space |
| GET | /api/v1/spaces/:sid/members | Users 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:
| Item | Condition |
|---|---|
| Spaces | always for owner/admin/AM |
| Groups | always for owner/admin/AM |
| Custom Roles | always for owner/admin/AM |
7. Audit log
Every administrative action writes a row.
| Action | Subject | Target |
|---|---|---|
customer.space.created | customer admin | space |
customer.space.archived | customer admin | space |
customer.space_admin.granted | customer admin | (space, user) |
customer.space_admin.revoked | customer admin | (space, user) |
customer.group.created | customer admin | group |
customer.group.archived | customer admin | group |
customer.group.roles_set | customer admin | (group, [roles]) |
customer.group.scopes_set | customer admin | (group, [scopes]) |
customer.space_group_grant.created | customer admin | (space, group) |
customer.space_group_grant.revoked | customer admin | (space, group) |
customer.group.member_added | customer admin OR space admin | (group, user) |
customer.group.member_removed | customer admin OR space admin | (group, user) |
customer.role.created | customer admin | role |
customer.role.permissions_set | customer admin | (role, [permissions]) |
actor.user_type distinguishes customer admin from space admin in the audit trail.
8. Tests
| Layer | Test |
|---|---|
| DB | Migration up/down idempotency |
| DB | Custom role with customer_id set; system role rejects customer_id (CHECK constraint) |
| Resolver | User in 1 group with 1 role — gets role's permissions |
| Resolver | User in 2 groups — permissions unioned, scopes unioned |
| Resolver | Group has scope customer:X — expands to tenants and instances under X |
| Resolver | User has direct customer_grant + group customer_grant — both contribute |
| Endpoints | Customer admin creates space → succeeds |
| Endpoints | Non-admin (viewer) attempts space create → 403 |
| Endpoints | AM (assigned to this customer) creates group → succeeds |
| Endpoints | AM (not assigned to this customer) attempts space create → 403 |
| Integration | E2E: Google admin sets up Accenture-Space, exposes TenantEditor → Accenture user reads tenant via Console API |
| Drift | Every list endpoint that PR1 §5 / PR3 §5 already covers continues to work with group-derived scope |
9. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | DB | Migration 008_spaces_and_groups.sql (also alters roles) |
| 2 | Backend | roles table extended; custom-role create/update endpoints |
| 3 | Backend | Models for spaces, groups, scopes, grants, memberships |
| 4 | Backend | ResolveEffectiveGrants resolver |
| 5 | Backend | Wire resolver into middleware (replace direct lookups from PR1/PR3) |
| 6 | Backend | Customer-admin endpoints (Section 5) |
| 7 | Backend | Space-admin read-only endpoints |
| 8 | Backend | Audit log writers |
| 9 | Tests | Resolver, endpoints, integration |
| 10 | Frontend | Pages under web/src/pages/customer/spaces, groups, roles |
| 11 | Frontend | Sidebar — Partner Access section |
| 12 | Docs | Update 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
| Risk | Mitigation |
|---|---|
| Resolver hot path becomes slow | Indexes (Section 3); follow-up materialized view if metrics show >P99 50ms |
| Customer admin grants a system role that exposes internal-infra to partner user | System roles intended for internal staff (platform_admin, infra_ops) blocked from being bound to customer-level groups via API check |
| Group archival leaves orphan memberships | Archive 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't | Space 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 deleted | customer_id cascade deletes custom roles; system roles unaffected |
customer_grants(grantee_type='group', grantee_id=group) plus group's own group_scopes double-counts | Resolver dedupes at union step; treated as set, not multiset |
| Partner admin promoted to customer admin somehow gains write powers across customer | Space admin endpoint set is bounded; promoting requires internal_users row, separate path |

