Ir al contenido

Permissions, teams, and workflow states

Esta página aún no está disponible en tu idioma.

The decision recorded here is fully implemented as of the 1.17 arc landing on dev:

  • 1.17.A — User approval states + admin approval workflow ✅ PR #138. Typed state machine, single-gate authn, session-cascade invalidation, last-admin invariant.
  • 1.17.B — Capability grants with expiry ✅ PR #139. Typed grant rows, deny precedence, scoped subject lookup, best-effort audit.
  • 1.17.C — Capability sweeper ✅ PR #140. Background job retires expired grants, NOTIFY-broadcasts cap-cache evictions across instances.
  • 1.17.D — Profile-update audit ledger ✅ PR #141. Reflective diff helper, changeset-only metadata, fail-open audit recording.
  • 1.17.E — Resource request workflow ✅ PR #142. Typed request lifecycle (open → approved/denied/expired), cache broadcast, owner + approver gate split.
  • 1.17.F — Self-service profile editing + operator gates ✅ PR #143. Per-field users.allow_self_edit.* gates, new profile.update_self capability, 422 reason shape (field_disabled_by_operator), cross-instance cache invalidation. Closes issue #20.

The design held end-to-end through the arc: typed catalogues per ADR 0042, NOTIFY/LISTEN cache invalidation per ADR 0013, and the audit ledger anchored on ADR 0044 — no design changes against implementation reality.

Related companion ADRs that share the typed-vocabulary discipline: ADR 0042 (typed constants per package) + ADR 0044 (activity ledger as audit substrate).

The authorization model laid down in migration 00002_capabilities_roles.sql (Phase 1.3) gave us three of the seven layers a real production permissions system needs:

  • Capabilities as atomic permission codes (posts.admin, users.write, …).
  • Roles as named bundles of capabilities with single-inheritance parent chains (BASE → Artist → Art Director).
  • Per-user grants and revokes on top of the role-derived set.

The role chain works today — TestEffectiveCapabilities_ResolvesRoleChain in app/internal/auth/capabilities_test.go proves it. What’s missing became obvious once we started designing the upload modal and asking “who can upload to which collection, who can see whose posts, who approves a draft”:

  1. A user can only hold one role. The user_role PK on rs_user_id forces every multi-hat scenario into a combinatorial "ArtDirector + SecurityAuditor" role explosion.
  2. There are no teams. A studio installing artist-alley has sub-orgs (Aurora R&D, Aurora Live, R&D Character Art, Cross-Studio Art Review). The typical DAM doesn’t model this and neither do we.
  3. There is no way to scope a role to a subset of resources. “X is a Director within team Aurora R&D” can’t be expressed; today posts.admin is global or absent.
  4. There are no per-resource or per-collection ACLs. A typical DAM has resource_custom_access and usergroup_collection; we have neither.
  5. There are no workflow states. A configurable archive column (-2 pending submit / -1 pending review / 0 active / 1-2 archived / 3 deleted) with capability codes that gate transitions is a real feature we need, even if existing implementations are rough.
  6. Anonymous browse has no first-class principal. Industry practice often encodes anonymous as a real user row named “guest”, which scatters username == $anonymous_login special cases throughout the codebase.

The survey of existing DAM permission code surfaced eight design choices we will explicitly not copy:

  • CSV-in-text-column for permissions (no FK integrity, no enum, typos silently no-op).
  • usergroup.parent as CSV varchar with FIND_IN_SET lookups.
  • config_options text column that gets eval’d.
  • 400-line get_resource_access() mixing eight orthogonal concerns.
  • Mixed grant/deny semantics (v grants, g/X/z/T restrict, XE-{id} re-allows under XE).
  • update_archive_status() not gating transitions itself — every caller has to remember the checkperm.
  • Anonymous as a real user row.
  • approved as a boolean on user with no audit trail.

This ADR locks in the design before Phase 1.7.B implements it, and before Phase 1.13.D-2b (upload modal) which is the first surface that needs team-aware checks on day one.

LayerWhat it answersStatus
1. Capabilities”What verbs exist?“exists (00002)
2. Roles with parent chain”What does this role mean?“exists (00002)
3. Multi-role per user”Who is this user?“change: widen to many-to-many
4. Teams (DAG via closure table)“What organisational scope exists?“new
5. Team-scoped role assignments”Where does this user have this role?“new
6. Per-resource / per-collection ACLs”Has someone been granted an explicit exception?“new
7. Workflow states (per resource type)“Where is this asset in its lifecycle, and who can move it?“new

A request is allowed iff any layer grants it after revokes are applied. There is one capability resolution function. Handlers call it; they do not implement their own permission logic.

Rename user_roleuser_roles (plural) and widen to many-to-many with optional team scope (see Layer 5). A user’s effective capabilities are the union of:

  • Every assigned role’s chain (transitive via roles.parent_id)
  • Every row in user_capability_grants
  • Minus every row in user_capability_revokes

Each of those rows can itself carry a team_id (Layer 5) which constrains where the grant applies. The chain walk is unchanged from today’s implementation — it just runs once per assigned role instead of once per user.

A team can have multiple parents. Aurora R&D Character Art may belong to both Aurora R&D and Cross-Studio Art Review. The storage:

  • teams — the node table.
  • team_parents — direct parent edges. Multiple rows per team allowed.
  • team_closure — materialised (ancestor, descendant, depth) tuples maintained by trigger on team_parents. Self-row (depth 0) per team makes “is X in team Y or any descendant” a single indexed lookup.

A cycle-detection trigger on team_parents rejects any insert that would create one (SELECT 1 FROM team_closure WHERE ancestor = NEW.child AND descendant = NEW.parent).

team_memberships is a flat (team_id, rs_user_id) table. Membership in a team does not propagate to descendants — you join each team you’re a member of explicitly. (Capability grants propagate downward through the closure; membership doesn’t.)

Layer 5: Team-scoped role and capability grants

Section titled “Layer 5: Team-scoped role and capability grants”

user_roles, user_capability_grants, and user_capability_revokes all gain a nullable team_id UUID REFERENCES teams(id):

  • team_id IS NULL → global assignment (“Sarah is an Art Director everywhere”).
  • team_id = X → scoped assignment (“Sarah is an Art Director within team X and all its descendants”). Descendant propagation is what the closure table makes cheap.

Uniqueness:

  • PRIMARY KEY (rs_user_id, role_id, team_id) handles uniqueness for team-scoped rows.
  • CREATE UNIQUE INDEX … WHERE team_id IS NULL prevents duplicate global assignments (Postgres treats NULL ≠ NULL, so the partial index is required).

The single check the handler layer calls:

id.Can(capability string, scope ...TeamScope) bool

Resolution:

  1. Compute the user’s effective capability set on session load (cached in the Identity). Each entry is (code, team_id_or_nil).
  2. For an unscoped check (id.Can("users.read")): allow iff any entry has the code with team_id_or_nil = NULL.
  3. For a scoped check (id.Can("posts.edit", InTeam(post.TeamID))): allow iff any entry has the code with either team_id_or_nil = NULL (global grant) or team_id_or_nil is an ancestor of the target team per team_closure.
  4. system.admin continues to satisfy every check (global wildcard).

Revokes apply at the entry level — a revoke of posts.delete scoped to team X removes only the team-X entry, not the global one.

Layer 6: Per-resource and per-collection ACLs

Section titled “Layer 6: Per-resource and per-collection ACLs”

ACLs are additive exceptions, not the primary access mechanism. The primary path is:

  • Public visibility → readable per the resource’s visibility column.
  • Team-scoped resource → readable per Layer 5 (capability + team-closure check).
  • Owner → always readable and writable.

ACLs grant additional access beyond those defaults. They never restrict below them. Schema:

CREATE TABLE post_acls (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
principal_type TEXT NOT NULL CHECK (principal_type IN ('user','role','team')),
principal_id TEXT NOT NULL,
permission TEXT NOT NULL CHECK (permission IN ('read','write','admin')),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by_rs_user_id BIGINT NULL,
expires_at TIMESTAMPTZ NULL,
PRIMARY KEY (post_id, principal_type, principal_id, permission)
);
CREATE INDEX post_acls_principal_idx ON post_acls (principal_type, principal_id);

collection_acls is identical with collection_id. The polymorphic principal_type + principal_id shape loses per-table FK integrity in exchange for a single query path; the trigger-maintained referential cleanup (when a user/role/team is deleted, sweep matching ACL rows) covers the gap.

expires_at enables time-boxed shares (“Marketing has read access to this post for 7 days”) without a separate share-links table.

Layer 7: Workflow states (configurable per resource type)

Section titled “Layer 7: Workflow states (configurable per resource type)”

Each resource type owns its own state list and its own transition graph. A concept-art pipeline can run idea → wip → review → final; a final-render pipeline runs draft → qa → published → archived.

CREATE TABLE workflow_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_type_id UUID NOT NULL REFERENCES asset_types(id) ON DELETE CASCADE,
code TEXT NOT NULL,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_initial BOOLEAN NOT NULL DEFAULT FALSE,
is_terminal BOOLEAN NOT NULL DEFAULT FALSE,
visible_by_default BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (asset_type_id, code)
);
CREATE TABLE workflow_transitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_state_id UUID NULL REFERENCES workflow_states(id) ON DELETE CASCADE,
to_state_id UUID NOT NULL REFERENCES workflow_states(id) ON DELETE CASCADE,
required_capability TEXT NULL REFERENCES capabilities(code) ON DELETE SET NULL,
requires_team_scope BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (from_state_id, to_state_id)
);
  • from_state_id IS NULL represents the create-from-nothing transition. Exactly one row per resource type has both from_state_id IS NULL and a to_state_id whose row has is_initial = TRUE.
  • required_capability is the cap a caller must hold to fire this transition. NULL means “any authenticated user” (owner is always allowed by the central helper regardless).
  • requires_team_scope = TRUE means the capability must be held within the resource’s team scope (Layer 5), not just globally.

The state lives on the resource (posts.state_id, assets.state_id). All state changes go through one central helper — workflow.Transition(ctx, resource, toState) — which:

  1. Looks up the workflow_transitions row for (from, to). Rejects if absent.
  2. Checks the required capability (scoped if the row demands it).
  3. Records the transition in workflow_audit (who, when, from, to, note).
  4. Updates the resource row.

This is the explicit fix for the “every call site has to remember checkperm('e{N}')” anti-pattern common in existing DAM tooling.

A single seeded role, Anonymous, with no capabilities by default. The auth middleware, when no session is present, injects an Identity{Roles: [Anonymous]} instead of returning 401. Handlers that should allow anonymous access (/posts with public visibility, once system.anonymous_browse_enabled is set) ask the same id.Can(...) question — the middleware just made Anonymous a real principal.

Enabling anonymous browse becomes one config flag plus one capability grant: INSERT INTO role_capabilities (role_id, capability_code) SELECT id, 'posts.read.public' FROM roles WHERE name = 'Anonymous'.

Federation: per-site teams mirrored on demand

Section titled “Federation: per-site teams mirrored on demand”

When a remote site sends us an asset, any team references it carries become local mirror rows:

teams(id=<new local UUID>, slug='aurora.rnd', name='Aurora R&D',
origin_server_id=<remote site UUID>)

Our local users and our local ACLs only ever reference local team IDs. The federation layer (post-MVP) translates between remote team references and local mirror rows. This matches how we already mirror roles (roles.origin_server_id) and matches ActivityPub-style federation patterns.

Permissions never cross sites: a grant authored by Site B applies only to Site B’s local users. Our users see federated content per our ACLs, applied to our mirrored team rows.

-- Layer 3 widening: drop old user_role, create user_roles many-to-many.
DROP TABLE user_role;
CREATE TABLE user_roles (
rs_user_id BIGINT NOT NULL,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
team_id UUID NULL REFERENCES teams(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by_rs_user_id BIGINT NULL,
PRIMARY KEY (rs_user_id, role_id, team_id)
);
CREATE UNIQUE INDEX user_roles_global_unique
ON user_roles (rs_user_id, role_id) WHERE team_id IS NULL;
CREATE INDEX user_roles_user_idx ON user_roles (rs_user_id);
CREATE INDEX user_roles_team_idx ON user_roles (team_id) WHERE team_id IS NOT NULL;
-- user_capability_grants and user_capability_revokes gain team_id
-- (NULL = global) with the same partial-index trick.
-- Layer 4: teams.
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
origin_server_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (origin_server_id, slug)
);
CREATE TABLE team_parents (
child_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
PRIMARY KEY (child_id, parent_id),
CHECK (child_id <> parent_id)
);
CREATE TABLE team_closure (
ancestor_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
descendant_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
depth INTEGER NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
CREATE INDEX team_closure_descendant_idx ON team_closure (descendant_id);
CREATE TABLE team_memberships (
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
rs_user_id BIGINT NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by_rs_user_id BIGINT NULL,
PRIMARY KEY (team_id, rs_user_id)
);
CREATE INDEX team_memberships_user_idx ON team_memberships (rs_user_id);
-- Layer 6: ACLs (post_acls + collection_acls, identical shape; one shown).
CREATE TABLE post_acls (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
principal_type TEXT NOT NULL CHECK (principal_type IN ('user','role','team')),
principal_id TEXT NOT NULL,
permission TEXT NOT NULL CHECK (permission IN ('read','write','admin')),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by_rs_user_id BIGINT NULL,
expires_at TIMESTAMPTZ NULL,
PRIMARY KEY (post_id, principal_type, principal_id, permission)
);
CREATE INDEX post_acls_principal_idx ON post_acls (principal_type, principal_id);
-- Layer 7: workflow.
CREATE TABLE workflow_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_type_id UUID NOT NULL REFERENCES asset_types(id) ON DELETE CASCADE,
code TEXT NOT NULL,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_initial BOOLEAN NOT NULL DEFAULT FALSE,
is_terminal BOOLEAN NOT NULL DEFAULT FALSE,
visible_by_default BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (asset_type_id, code)
);
CREATE TABLE workflow_transitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_state_id UUID NULL REFERENCES workflow_states(id) ON DELETE CASCADE,
to_state_id UUID NOT NULL REFERENCES workflow_states(id) ON DELETE CASCADE,
required_capability TEXT NULL REFERENCES capabilities(code) ON DELETE SET NULL,
requires_team_scope BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (from_state_id, to_state_id)
);
CREATE TABLE workflow_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_kind TEXT NOT NULL, -- 'post' | 'asset' | future
resource_id UUID NOT NULL,
from_state_id UUID NULL REFERENCES workflow_states(id),
to_state_id UUID NOT NULL REFERENCES workflow_states(id),
actor_rs_user_id BIGINT NULL,
note TEXT NOT NULL DEFAULT '',
transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX workflow_audit_resource_idx ON workflow_audit (resource_kind, resource_id, transitioned_at DESC);
-- Posts and assets gain state_id and (optional) team_id.
ALTER TABLE posts ADD COLUMN state_id UUID NULL REFERENCES workflow_states(id);
ALTER TABLE posts ADD COLUMN team_id UUID NULL REFERENCES teams(id);
ALTER TABLE assets ADD COLUMN state_id UUID NULL REFERENCES workflow_states(id);
ALTER TABLE assets ADD COLUMN team_id UUID NULL REFERENCES teams(id);
CREATE INDEX posts_state_idx ON posts (state_id);
CREATE INDEX posts_team_idx ON posts (team_id) WHERE team_id IS NOT NULL;
CREATE INDEX assets_state_idx ON assets (state_id);
CREATE INDEX assets_team_idx ON assets (team_id) WHERE team_id IS NOT NULL;
GET /teams — list (filterable by ancestor)
POST /teams — create
GET /teams/{id} — fetch
PATCH /teams/{id} — rename / re-parent
DELETE /teams/{id} — delete (rejects if non-empty)
GET /teams/{id}/members — list memberships
POST /teams/{id}/members — add member
DELETE /teams/{id}/members/{user_ref} — remove member
GET /teams/{id}/roles — list role assignments scoped to this team
GET /posts/{id}/acls — list ACL rows
POST /posts/{id}/acls — add ACL row
DELETE /posts/{id}/acls/{principal_type}/{principal_id}/{permission}
GET /collections/{id}/acls — same shape
GET /asset-types/{id}/workflow — list states + transitions for this type
POST /asset-types/{id}/workflow/states
POST /asset-types/{id}/workflow/transitions
POST /posts/{id}/transitions — { to_state_id, note? } — fires workflow.Transition
GET /posts/{id}/transitions — audit history

Capability codes added by this ADR:

teams.read teams.admin teams.create
posts.read.public posts.read.team posts.review posts.publish
collections.read collections.write collections.admin
workflow.admin

This lands as Phase 1.7.B in five migrations:

  • 00015_teams.sql — teams, team_parents, team_closure, the cycle-rejection + closure-maintenance triggers, team_memberships.
  • 00016_user_roles_widen.sql — drop user_role, create user_roles with team_id, copy any existing assignment over. Same team_id column added to grants/revokes.
  • 00017_acls.sqlpost_acls, collection_acls, plus the trigger that sweeps ACL rows when a referenced principal is deleted.
  • 00018_workflow.sqlworkflow_states, workflow_transitions, workflow_audit. Seeds the default state set for the image resource type (draft → pending_review → published → archived) to bootstrap the upload modal.
  • 00019_anonymous_role.sql — seed the Anonymous role and the middleware path that injects it on sessionless requests.

Each migration carries its own goose Down. Pre-MVP wipe-and-reseed remains the working assumption — we don’t engineer a data-preserving backfill.

Positive:

  • Multi-hat users without combinatorial role explosion.
  • Team-scoped admins are a first-class concept — id.Can("posts.admin", InTeam(t)) is the same one-liner today’s code already writes.
  • DAG-shaped teams handle cross-cutting org structures that trees can’t (Cross-Studio Art Review claiming sub-teams from multiple game teams).
  • ACLs as additive exceptions, not the primary path, keeps reads fast: the common case never touches the ACL tables.
  • Workflow transitions are gated centrally; we cannot regress to the “every caller remembers checkperm” failure mode.
  • Anonymous is a real principal, not a magic username string — one code path handles authenticated and anonymous reads.
  • Federation hooks (origin_server_id on teams) mirror the pattern already on roles and resources.

Negative:

  • Migration 00016 rewrites user_role — but Phase 1.13.D-3 has basically two real role assignments in dev DBs (the seeded admin and the test user), so cost is near zero.
  • The polymorphic principal_type + principal_id ACL shape loses per-table FK integrity. We mitigate with sweep triggers on user/role/team delete. If that proves brittle we split into post_user_acls / post_role_acls / post_team_acls later (one-shot table split, no API change).
  • Closure-table maintenance is one more trigger to reason about. The pattern is well-trodden (SELECT ancestor_id, NEW.descendant_id, depth+1 FROM team_closure WHERE descendant_id = NEW.parent_id …), but it’s still infrastructure we own.

Deferred:

  • Auditable role/cap grants (who gave Sarah posts.admin and when) — user_capability_grants has granted_by_rs_user_id but no historical log. Add when an audit surface needs it.
  • Approval workflows for team membership (does joining a team need the team admin’s approval, or is it self-serve when invited?). Default: invite-based, admin-approved.
  • Quota-style constraints (“Sarah’s posts.create is limited to 100/day”). Out of scope; treated as a future rate-limit feature built on the audit log.
  • Whether team_memberships should carry a role_id of its own to support a “team contributor” role implicitly. Current design says no — role assignment is in user_roles with a team_id, and membership is a separate “I’m a person in this team” fact. Revisit if real installs want one-row “add Sarah as a Reviewer in team X” shortcut.
  • Whether ACLs should also support a principal_type = 'access_link' for share-link tokens, or whether share-links get their own table like collections did. Lean toward the latter: links are cryptographic principals with different lifecycle, not the same thing as user/role/team grants.
  • Whether posts.team_id and assets.team_id should be NOT NULL with a default 'public' team, or remain nullable. Nullable is simpler today; making it NOT NULL would close the gap where a resource has no team and falls back to visibility-only checks. Default to nullable; revisit if a real install demands every resource have a team.