Lumen Docs

Resolver priority

The access resolver is the single function that decides whether a user can read/write a project and at what tier. Source: apps/api/src/lib/access.ts.

Entry points

// Per-project check
resolveAccess(userId, projectId): Promise<AccessResolution | null>

// For listing endpoints (e.g. /projects GET)
listAccessibleProjects(userId): Promise<Array<{ projectId, tier, source }>>

AccessResolution = { tier: "use" | "edit" | "full"; source: AccessSource }.

Priority ladder

The resolver walks these in order. First match wins.

| # | Source | Tier | Condition | |---|---|---|---| | 1 | platform | full | user.platformRole ∈ {admin, engineer, superadmin} | | 2 | ceo | use | user.orgPosition === "ceo" | | 3 | owner | full | project.ownerId === user.id | | 4 | direct | grant.tier | ProjectGrant row where userId = user.id | | 5 | group | grant.tier | user ∈ any group with grant.groupId = group.id | | 6 | department | grant.tier | user.departmentId matches grant.departmentId | | 7 | public | use | project.isPrivate === false | | — | null | — | none matched → 403 |

Example return:

{ tier: "use", source: "public" }
{ tier: "edit", source: "direct" }
{ tier: "full", source: "owner" }

Semantics

Public doesn't downgrade. If a user has a direct edit grant AND the project is public, they stay at edit (priority 4 wins before priority 7).

CEO capped at use. CEO is always observer; never edits. If a CEO also owns a project (can happen if CEO creates a project), priority 3 (owner, full) wins over priority 2 (ceo, use).

Platform staff capped at nothing. Admin/engineer/superadmin are at priority 1 and get full. You cannot "downgrade" them with a grant — priority 1 always wins.

What's cached

Nothing. Every protected request hits the DB. This is intentional during the dev phase — correctness over speed. If you add caching later:

  • Invalidate on projectGrant writes
  • Invalidate on user.platformRole changes
  • Invalidate on project.ownerId changes
  • Invalidate on project.isPrivate changes

Where it's called

| Site | Purpose | |---|---| | GET /projects | Filters list to accessible projects + attaches accessTier/accessSource | | GET /projects/:id | Gate for reading project detail | | requireProjectAccess(param, tier) middleware | Gates write endpoints (upload doc, edit settings, etc.) | | Any chat endpoint | Validate before allowing a question |

Reading the source

// apps/api/src/lib/access.ts
export async function resolveAccess(
  userId: string,
  projectId: string,
  client: PrismaClient = prisma
): Promise<AccessResolution | null> { ... }

export async function listAccessibleProjects(
  userId: string,
  client: PrismaClient = prisma
): Promise<Array<{ projectId: string; tier: AccessTier; source: AccessSource }>> { ... }

Both use a single Promise.all to fetch user + project in parallel. The computation itself is pure (computeAccess) — easy to unit-test.

Test fixtures

apps/api/src/lib/access.test.ts uses a mockPrisma factory with plain JS fixtures. Every priority step has a test. When you add a new resolution rule, add a fixture test covering:

  1. Rule matches → returns expected tier/source
  2. Rule loses to higher-priority rule → returns higher-priority tier/source
  3. Rule beats lower-priority rule