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
projectGrantwrites - Invalidate on
user.platformRolechanges - Invalidate on
project.ownerIdchanges - Invalidate on
project.isPrivatechanges
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:
- Rule matches → returns expected tier/source
- Rule loses to higher-priority rule → returns higher-priority tier/source
- Rule beats lower-priority rule