Skip to content

44 — RBAC, Teams & Permissions — the authorization redesign (envisioning)

Envisioning doc — needs confirmation. Governed by 00-ROADMAP.md. Created 2026-06-27 (owner-raised). Supersedes RBAC-DESIGN.md (the older, pre-numbering planning note — kept for history, no longer the spec). The ask, verbatim: "we need to enforce / add RBAC … handle teams and roles per team, inside the team, parent-cascade, and stop inheriting roles per resource." This is the platform's authorization model, surfaced by the mgmt.ctrl (doc-42) bulk write — the first feature that mutates many nodes reached by aggregation, so it can't lean on the implicit "you only know IDs you can see" that masks today's gaps.

Six decisions locked at envisioning (2026-06-27, owner):

  1. One enforcement chokepoint, default-deny. A central PermissionService.can(user, action, resource) + a Nest guard (@RequirePermission), callable from services too (for bulk). No grant ⇒ no access. Replaces today's scattered, partial, default-allow checks.
  2. Computed-live + cache (ReBAC / Zanzibar model). Permissions resolve at access time from team membership + scope assignments + grants, cached per-request (and a short-TTL per-user cache, invalidated on change). Stop materializing per-resource roles — retire the team→collaborators[] fan-out as the source of truth.
  3. Unified grant model, three principal kinds. Grant = (principal, role, scope) where principal ∈ { User, Team, Link/Guest }. Teams = the scaling mechanism (labels/studios); direct user grants stay for solo artists + one-off external shares; links are guest principals (doc-26/38).
  4. Cascade with override. A grant at a scope applies to all descendants; resolution = most-specific scope wins → highest role within a scope → explicit deny beats allow.
  5. Two role axes, kept separate. Team role (govern the team: add/remove/disband — today's TeamMember.teamRole) vs Access role (capability on resources: owner/admin/editor/viewer). Never conflate them.
  6. Migrate via shadow mode. Backfill grants from existing collaborators[] + TeamShare; run the new resolver in compare-only mode (log divergences, don't enforce) before flipping; non-breaking and reversible. First consumer = doc-42 bulk write.

What triggered this

doc-42's mgmt.ctrl adds bulk writes (set status / approve / archive across many nodes at once). A bulk action over an aggregated, cross-catalog selection cannot rely on the client only surfacing actionable IDs — it must answer "can this user write here?" per node. There is no shared answer to call, and the per-resource checks that exist are inconsistent. That forced the authorization model into the open, and the owner chose to fix the model rather than paper over it in one endpoint.

Finding — current state (verified 2026-06-27)

AreaTodayIssue
AuthNJwtMiddleware per route + @GetUser()✅ fine
Access modelPer-resource collaborators[{userId, role}] (owner/admin/editor/viewer), cascade Artist→Project→TrackThe model shape is ReBAC (per-resource relationship) — correct for the domain, but…
TeamsTeam + TeamMember{teamRole, status} + TeamShare exist — but sharing to a team fans out the grant into each resource's collaborators[]Teams are a bulk-add convenience, not a live permission; membership changes don't re-resolve
EnforcementImperative, in services, partialupdateReleaseStatus gates (owner/admin), track.update does not (track.service.ts:1365)Default-allow gaps; every new mutation must remember to check; no chokepoint
ReadsQuery-scoped (findAllByUser / findAllSharedWithUser)Safe today, but scoping is separate logic from write checks — they can drift
Resolvernone (grep: no PermissionService / canUserPerform / getCollaboratorRole)No single source of truth for "who can do what"

The tell: PUT /track/:id writes with no role check — protected only by clients not surfacing un-actionable IDs. IDs are shareable (shortId/slug), so that's obscurity, not authorization. A central default-deny guard closes this class of bug at once.

The model — one unified grant

Grant = (principal, role, scope)

  principal  : User              (a person — solo / one-off / external share)
             | Team              (a reusable group — the scaling unit)
             | Link/Guest        (anonymous principal: public/password/registered — doc-26/38)

  role       : owner | admin | editor | viewer        ← ACCESS role
  scope      : Artist ▸ Project ▸ Track ▸ File/version ← a node in the hierarchy
  cascade    : applies to all descendants of `scope`, unless a closer grant overrides
  effect     : allow (default) | deny (explicit override)

Direct collaborators, team assignments, and share links all become grants — one schema, one resolver. A team also carries its own team role axis (admin/member) governing the team, which is independent of the access role a team's grant confers on a resource.

Resolution — can(user, action, node)

1. Gather grants reaching `node` for `user`:
     • direct User grants on node or any ancestor
     • grants of every Team the user is an ACCEPTED member of, on node/ancestor
     • (for guests) the Link grant on node/ancestor
2. Rank by scope specificity: node > project > artist  (closest wins).
3. Within the most-specific scope that has a grant: highest role wins;
   an explicit DENY at that scope beats any allow.
4. Map the resolved role → allowed actions via the ACTION MATRIX (below).
5. Default DENY if no grant reaches the node.

Resolution is computed live and cached (per request; optionally a short-TTL per-user effective set, invalidated on membership/assignment/grant change). Reference model: Google Zanzibar / ReBAC.

Action matrix (role → capability)

ActionOwnerAdminEditorViewer
View
Comment
Edit / Upload
Approve / set release-status
Delete / Archive
Share / manage grants
Transfer ownership

Controllers declare the action (@RequirePermission('track:approve')), not a role. The matrix is data-shaped so per-workspace custom roles can layer on later (doc-43) without touching call sites.

Enforcement architecture

  • PermissionServicecan(userId, action, node) + resolveRole(userId, node) + accessibleIds(userId, type) (for reads). The single source of truth.
  • @RequirePermission(action) guard on every mutating route — default-deny. The same service is injected into services (bulk write, mgmt.ctrl) so non-HTTP call paths are gated identically.
  • Reads converge on the same resolver. Replace ad-hoc findAllByUser scoping with PermissionService.accessibleIds() so "what I can see" and "what I can change" never drift. Ancestry is resolved via the already-denormalized project / artistSpace refs on Track (and on Comment) — no new closure table for v1.

Operational realities to design (good-practice checklist)

  • Ownership invariants: exactly one ultimate owner per resource; last-admin protection (can't remove/demote the final owner/admin of a team or resource); ownership transfer; orphan handling on account deletion / team disband.
  • Override & deny: per-member / per-scope overrides ("team = editor, but Bob = viewer here") via a closer grant or explicit deny. Decide how aggressively to use deny (powerful, adds resolution cost).
  • Audit log (RBAC Phase 4 + doc-41): every grant / revoke / role-change recorded (who · when · what) — table stakes for B2B trust.
  • Internal vs external boundary (doc-26 lens): a team belongs to one owner/org; resolution must know when a grant crosses the tenant boundary (external collaborators, A&R, legal).
  • Scope to File/version (doc-26 Wave 1): grants should reach the version, not stop at Track; today TeamShare excludes File — close that.
  • Guest principals (doc-38): link/guest plays attribute to the Link grant + coarse location, not raw IP — the same grant model carries it.
  • Pricing (doc-31): Teams = premium, direct user grants = free. Gate team create/assign on user.subscription (Free/Explorer/Pro/Diamond); never gate basic per-user collaboration.
  • Custom roles (doc-43): the action matrix is the first thing a workspace would customize — keep it a managed catalog, fixed defaults now.

Migration (non-breaking, reversible)

  1. Backfill: generate Grants from every existing collaborators[] entry and every TeamShare (one-time, idempotent script).
  2. Shadow mode: run PermissionService alongside the current checks — compute + compare + log divergences, do not enforce. Tune until parity.
  3. Flip: enable the guard (default-deny); keep collaborators[] written for one release as a read-fallback, then stop materializing (retire the fan-out).
  4. Retire: team grants become live (membership change re-resolves with no fan-out); collaborators[] becomes a derived/legacy view.

Each step is independently shippable and reversible.

Relationship to other docs

  • Supersedes RBAC-DESIGN.md (Phases 1–4 folded in + extended: teams become live, not fan-out; computed not materialized; central enforcement).
  • doc-42 mgmt.ctrl — the first consumer: bulk write calls PermissionService.can per node; partial-failure reporting = "skipped, insufficient role." mgmt.ctrl reads already shipped are safe (query-scoped) and need no change until the read-path converges in step 3.
  • doc-26 identity/sharing/teams — this is the enforcement layer under its lenses; TeamShare evolves into team grants.
  • doc-41 audit — grant changes ride the audit spine. doc-43 — custom roles/permission catalogs. doc-31 — teams=premium. doc-38 — guest/link principals.

Open questions / to confirm

  1. deny grants — support explicit negative overrides in v1, or only "closer allow wins" (simpler)?
  2. Team-of-org nesting — flat teams under an owner now, or anticipate Org → Label → Team nesting (doc-17 fantasy)? Recommend flat now, design the scope field to allow it later.
  3. Cache invalidation grain — per-user effective-set TTL vs event-driven bust on membership/assign change (or both)?
  4. Read-path cutover timing — converge reads onto accessibleIds() in the same project, or as a fast-follow after writes are gated?
  5. Fans as a future principal (post-MVP, owner-flagged 2026-06-27) — a consumer/fan audience will eventually have some access (follow / listen / limited view). Not MVP. The unified grant model already accommodates it: add a fan principal (or a low-privilege role below viewer) on the Link/Guest seam — design the role ladder so a sub-viewer tier slots in without reshaping grants. Keep it out of scope now; just don't foreclose it.
  6. Global capability/license vs per-resource role (owner-flagged 2026-06-27) — the access roles here are per-resource (you're owner of a track); there is no global "this user is a manager" role, and a fresh user is owner of their own stuff. So capability gates (e.g. "can use mgmt.ctrl") should not key off an aggregated per-resource role (the userRole scalar from /mobile/dashboard is an aggregate, not an identity attribute). A real "manager license" — a capability granted to a user (by a label/admin or a paid plan, doc-31) independent of any one resource — is a separate axis from grants and needs its own envisioning. Interim decision: mgmt.ctrl is shown to everyone as the lens over their own catalog (no fake global gate); the license-gated "manage others' work" tier rides this future axis.

Sequencing

Build order (each shippable): (1) Grant schema + PermissionService (resolver + cache) + action matrix; (2) backfill script; (3) shadow mode (compare-only) — no behavior change; (4) @RequirePermission guard on mutating routes, default-deny (closes the track.update gap); (5) converge reads onto accessibleIds; (6) retire the fan-out, teams go live; (7) audit log. doc-42 bulk write resumes at step 4 (its first real use). Teams-as-premium gating rides doc-31 when monetization opens.


Status: envisioning. Decisions 1–6 locked 2026-06-27. This is foundational platform work — sequence it before the mgmt.ctrl bulk write and any further mutation surface. Supersedes RBAC-DESIGN.md.

Ctrl-Audio Platform Documentation