Role + tier model
Lumen has three orthogonal dimensions of access. Every user has values on all three; combined, they determine what the user can see and do.
1. Platform role
Who you are to the Lumen platform itself.
| Role | What it means | Who gets it |
|---|---|---|
| superadmin | Bootstrap owner. Bypasses every permission check. Singleton, non-transferable. | The first person who runs the wizard. |
| admin | Platform manager. Can manage users, departments, projects, share requests. Cannot touch LLM providers. | Assigned by superadmin. |
| engineer | Platform engineer. Manages LLM providers, workers, logs, system config. Does not manage users. | Assigned by superadmin. |
| none | Regular user. Access comes from CEO/owner/grant only. | Default. |
Superadmin is created once during bootstrap and cannot be assigned via POST /users — the API rejects it with forbidden_role. This is intentional: we want exactly one root account.
2. Org position
Your position in the org chart. Independent of platform role.
| Position | Meaning |
|---|---|
| ceo | Cross-department observer. Read-only use tier on every project regardless of department. Singleton. |
| manager | Leads a department. Full use + edit + full in granted projects. Can create groups under their dept. |
| member | Team member. Access via dept, groups, and direct grants. |
A single user has one position. ceo and superadmin are both singletons — enforced by partial unique indexes (single_ceo, single_superadmin).
3. Access tier
Tier on a specific project.
| Tier | Means |
|---|---|
| use | Read docs, chat, view project — can't edit settings or docs. |
| edit | Everything in use, plus add/remove docs, edit project instructions. |
| full | Everything in edit, plus grant access to others, change settings, delete project. |
Tiers are monotonic: full ⊇ edit ⊇ use. Helper: hasTier(tier, "edit") returns true for edit and full.
How tiers are obtained
A user's tier on a project resolves through six sources in priority order:
- Platform role —
adminorsuperadminorengineer→full - CEO →
use(cross-dept observer) - Owner —
project.ownerId === user.id→full - Direct grant —
ProjectGrantrow withuserId = user.id - Group grant — user is in a group that has a grant
- Department grant — user's department has a grant
- Public baseline —
project.isPrivate = false→use
The first matching source wins. Direct beats group beats dept. Public is a baseline only — any explicit grant upgrades it.
See Resolver priority for the full decision table and source code pointer.
Where it's enforced
| Surface | Enforcement |
|---|---|
| Frontend | Sidebar nav gated by platformRole (in components/layout/sidebar.tsx). Hides Admin / Engineer areas from regular users. |
| API | requirePlatformRole([…]), requireProjectAccess(param, tier), requireCeoOrManager(param) middleware in apps/api/src/middleware/access.ts. |
| DB | Partial unique indexes enforce singletons. CHECK constraint grant_one_target on project_grants ensures exactly one of (userId, groupId, departmentId) is set per row. |
Never trust the client — the source of truth for tier is the resolver, called server-side on every protected read or write.
Bootstrap singleton flows
The wizard at /onboarding/first-login creates the superadmin and CEO in a single transaction, then flips bootstrap_state.is_initialized. After that:
POST /bootstrap/initalways 409srequireNotBootstrappedmiddleware blocks any endpoint that assumes a fresh DB- Middleware
requireBootstrappedgates every user-facing API
This means you cannot run two instances of bootstrap concurrently or re-bootstrap an existing DB without a manual DB reset.