ADR-014: Project Role Resolution — Highest-Privilege Wins Across Multi-Path Membership¶
Date: 2026-04-15 Status: Accepted
Context¶
ADR-011 (party archetype) and ADR-012 (materialized closure) together describe how a user's
project-scoped role is looked up: walk the party_group_closures table from the user's
Person party to collect all ancestor group IDs, then find project_member relationships
from any of those parties to the target project.
What neither ADR specifies is the tie-break rule when a user reaches the same project via multiple paths with different roles. Example:
- Alice is a
memberof groupplatform. - Alice is a
memberof groupsre. - Group
platformis aproject:developeron projectorion. - Group
sreis aproject:vieweron projectorion.
The closure resolution returns two rows for alice → orion: one with role project:developer
and one with role project:viewer. What is alice's effective role on orion?
This question comes up in two places:
- The
GET /api/v1/auth/me/projectsendpoint introduced in my-projects-context-design.md must return exactly one role per project for the "My projects" view. - Future project-scoped permission checks (deferred from Spec 2) will need the same semantic — a permission check must answer "does alice have enough role on orion?" with a single effective role, not a set.
Without an explicit rule, every callsite invents its own — guaranteed drift.
Decision¶
When a user reaches a project via multiple paths with different roles, the user's effective role is the highest-privilege role across all paths. Rank:
project:owner = 2
project:developer = 1
project:viewer = 0
(unknown role) = -1
Ties at the same rank collapse to a single value (they're equal). Unknown roles never win over known roles; they are logged at warn level so operators can spot misconfigured relationships.
Implementation location: A single function in the store layer,
PartyStore.ResolveUserProjects, applies the rank map. Future permission-check code paths
MUST reuse this function (or a shared helper that applies the same rank map) rather than
re-implementing the tie-break.
Direct-vs-transitive does not affect the rule. A direct project_member relationship is
treated identically to a transitive one — if alice is personally project:viewer on orion
and her group is project:owner on orion, she is project:owner on orion. Direct
membership is not privileged over transitive membership; the rank alone decides.
Consequences¶
Positive¶
- No silent downgrade. A user never sees the UI offer them less access than the permission system would grant on a real request. "Highest wins" is the only tie-break choice that keeps the displayed role and the effective permission in sync.
- Standard RBAC semantics. Unix file permissions, AWS IAM, Kubernetes RBAC, and most enterprise systems resolve multi-path access by taking the most permissive result. Matches user expectation.
- One function, one rule. Future permission-check code can't accidentally disagree with the "My projects" view — both paths go through the same rank map.
- Safe for nested groups. Adding a user to a broad-scope group that has a high role on a project grants them that access immediately; removing them revokes it immediately (closure rebuild, ADR-012). No surprise where the "direct" role lingers with a different value.
Negative / Trade-offs¶
- A user cannot be intentionally restricted on a project via a narrow-scope relationship
if a broader-scope one grants more. Example: if you want alice to be only a viewer on
orion despite her group being an owner, you cannot achieve that by adding a direct
project:vieweredge — the group-derivedownerstill wins. Workaround: remove alice from the broader group, or use project-scoped deny lists (not currently modelled). We accept this limitation — additive/permissive models are the industry norm and inverting to "most restrictive wins" has its own surprising failure modes. - Unknown roles are silently never-wins. A typo like
project:deveoperwill log a warn but the user may see no effective role for that project while a clean fix-forward goes unnoticed. Mitigated by backend validation on the AddMember and PATCH member-role handlers — unknown roles are rejected before insertion, so the(unknown role)branch exists only as defense-in-depth for historical data.
Neutral¶
- Role rank is hard-coded in Go, not DB-driven. Moving ranks to a table would add runtime configurability we don't need — the three roles are fixed by the party archetype and change only with schema migration. Hard-coded is simpler and typo-proof.
- Sort stability. Returned memberships are sorted by project name ASC so the same inputs produce the same output — important for snapshot tests and for any future response-ETag caching.
Alternatives considered¶
| Option | Why rejected |
|---|---|
Most-restrictive wins (viewer beats owner on tie) |
Surprising failure mode: adding a user to a low-role group silently downgrades their effective access across all projects that group touches. Not how operators expect groups to behave. |
| Direct membership beats transitive | Two sources of truth for "effective role" — the direct edge when present, the closure rank otherwise. Every callsite would have to re-implement the two-tier check. Opens the door to "did we remember to check direct first?" bugs. |
| Return all paths and their roles; let the caller decide | Punts the problem — we'd just get per-callsite tie-break logic, which is exactly the drift this ADR is preventing. |
| Forbid multi-path memberships at write time | Inverts the party graph's value: flexible relationships are the whole point. Many legitimate scenarios involve a user in multiple overlapping groups. |
| First-found wins (arbitrary order from the DB) | Non-deterministic; snapshot tests would flake; auditability suffers. |