Access model — role + participation
Neon Law Navigator separates what a person is (system-wide tier) from what a person sees (per-project scope).
Both answers live in the database, both flow into OPA, neither lives in the IdP token. The IdP supplies only identity
(sub, email).
Role decides the tier; participation decides the scope.
The two columns:
| Column | Table | Decides |
|---|---|---|
role | persons | The tier: client, staff, or admin. Anonymous = no row. |
participation | person_project_roles | The matter-side role on a Project (attorney, paralegal, client). |
The two columns are independent. A paralegal who is also a client of the firm for their own LLC carries the staff role
on the persons row (their day job) and a person_project_roles row on their personal matter with the client
participation. The system answers "what can this person do" by reading both.
The three tiers
persons.role is a text column with CHECK (role IN ('client','staff','admin')). SeaORM models it as an
ActiveEnum.
client
A person the firm represents on at least one matter. The client lens sees only projects where the person is recorded as
the client-side participant, either through a person_project_roles.participation = 'client' row, the canonical
client_dri participation, or projects.client_dri_person_id. Client matter access is portal-native: documents,
Engagements/Notations, invoices, and reviewed artifacts are rendered through web after the Project visibility check.
Clients do not receive Git clone URLs, Git PATs, branch names, commit SHAs, or direct GCS bucket credentials.
staff
A firm employee — attorney, paralegal, support. The staff lens sees projects where the person is assigned from the firm
side, either through a non-client person_project_roles.participation row or projects.staff_dri_person_id. Staff may
also be clients on their own matters, but that is a separate client-lens fact: /portal shows only their client-side
matters, while /staff shows the matters they work on for the firm.
admin
A firm employee with system-administration authority — manage the persons table, rotate keys, archive projects. Bypasses project-scoping entirely. Sees every project, silently, without writing an audit row.
anonymous
No row in persons at all. Sees only the public marketing surface (homepage, /foundation/*), the public API
documentation (/openapi.json and /api-docs), and /auth/login. The doc surfaces describe the API but are not the
API, so web serves them outside the OPA gate (web::api::doc_routes) — their public-ness lives in routing, not in a
policy rule that would have to redeploy in lockstep with the binary.
admin is a superset of staff, not a separate axis. A firm administrator is, by definition, someone who could be
assigned to any matter; making them ask for participation rows on every project they need to touch buys nothing.
Concrete people in the seed data
- Nick (
nick@neonlaw.com, lowercase) — the firm administrator. Roleadmin; sees every project. The lowercase spelling is exact:store::seed::require_firm_domainrejects mixed-case staff/admin seeds at load time. - Staff — firm employees, role
staff, by convention lowercase*@neonlaw.comemails. The KIND-only Keycloak fixturestaff@neonlaw.comis rolestaff(perdocs/RUNBOOK.mdstep 3) — exactly one role, staff, not admin. - Clients — any seeded non-firm person, role
client. Email is the client's real address; no domain restriction.
Participation
person_project_roles.participation is a free-form text column. The values currently in use (from
store/seeds/PersonProjectRole.yaml and live writes):
attorney— lead attorney on the matter.paralegal— supporting paralegal.client— the natural-person client.co_counsel— outside counsel collaborating on the matter.
The matter-side vocabulary is open: new participation kinds (translator, guardian_ad_litem) arrive as the firm takes
on new kinds of work without needing a migration.
Every row carries inserted_at + updated_at (the workspace timestamp convention). Those answer "is this still true
right now and how stale is the fact." They do not answer "was Libra ever an attorney on this matter." If you need
participation history, append a row to relationship_logs — that's what the table exists for
(m20260526_create_provenance_tables.rs).
Each person has at most one current participation row for a Project. Changing the participation updates that row; it does not create a second, competing assignment. Creating, changing, or removing a participation row is an admin-only membership operation because it changes who can see the matter. Staff assigned to the matter can read the ledger but cannot alter it.
What participation is NOT
It is not the disclosures table. Disclosures are formal records the firm keeps about conflicts of interest and
related-party relationships — information flowing from the client to the firm about who the client is connected
to. Project membership is the opposite direction: an internal record of who the firm has put on the matter. The two
concepts share the same English word in casual speech ("Libra is disclosed on the Acme matter") but they're different
columns in different tables answering different questions.
If someone should see a Project's portal files, add or remove the person_project_roles row; do not grant them GCS IAM
or expose the git repository.
If you find yourself reaching for disclosures to decide whether someone can see a project, stop — you want
person_project_roles. See glossary entry "Disclosure".
How OPA decides
The web middleware (web::policy::require_policy) posts an input document to OPA on every request:
{
"path": ["admin", "projects", "9a..."],
"method": "GET",
"session": {
"sub": "<idp subject>",
"email": "libra@example.com",
"role": "staff"
},
"project_id": "9a..."
}
project_id is populated by the route handler when the URL is project-scoped (/portal/projects/:id,
/staff/projects/:id, and document subroutes). Routes without a project parameter leave it absent.
OPA's allow rules in priority order:
- Admin bypass —
session.role == "admin"allows every authenticated request. No project-membership check, no per-read audit. The trust call is that admin already implies a fiduciary duty audited elsewhere (Drive activity, DB write logs). Admin-only operational surfaces such as/adminand/admin/analyticsalso enforce the admin role in their handlers, so the broader/staff/*staff-tier gate cannot expose them. - Staff-tier writes —
/staff/persons,/staff/templates, and other firm-internal CRUD gate onsession.rolebeing either"staff"or"admin". - Portal client lens —
/portaland/portal/projects/:id/...allow any authenticated caller through OPA because a staff or admin user may also be a client. The handler then applies the client-lens Project ACL. A staff-only assignment does not make the Project appear in/portal. - Staff firm lens —
/staff/*requiressession.roleto be"staff"or"admin"at OPA. Project handlers then apply the staff-lens Project ACL. A client-only participation row does not make the Project appear in/staff. - API project reads —
/api/projects/:id/...allow if there is aperson_project_rolesrow withperson_id = session.person_idandproject_id = input.project_id. The participation value is not checked at OPA; action-level distinctions live in the route layer.
The git transport adds that route-layer distinction explicitly. A PAT alone is not enough: web::git_http resolves the
token to a persons row, requires staff or admin, then applies the staff-lens Project visibility check (staff
must be assigned from the firm side; admin bypasses). That defense keeps a mistakenly minted client PAT from becoming
a clone credential.
The web side ships lens-specific helpers for the visibility query:
// web/src/access.rs
pub async fn visible_projects_as_client(
db: &Db,
person_id: Option<Uuid>,
) -> Result<Vec<Project>, DbErr>;
pub async fn visible_projects_as_staff(
db: &Db,
person_id: Option<Uuid>,
role: Role,
) -> Result<Vec<Project>, DbErr>;
Every project-list and project-detail handler funnels through the helper for its route lens. Inlining the SQL into individual handlers is the failure mode we are explicitly avoiding — it's how authz quietly drifts.
The route layer carries a second gate for the project-write surface: store::entity::person::Role::is_staff_tier
(true for staff and admin). A client who reaches /staff/projects/:id/edit and friends is stopped by OPA before
the handler; if a handler-level Project ACL fails, it returns 404 so unrelated matters do not announce themselves.
Related
docs/oidc.md— Authorization Code + PKCE login flow and how the persons row is upserted.docs/glossary.md— Person, Project, Disclosure, Participation.k8s/base/opa/opa.yaml— the live Rego policy.web::policy— therequire_policymiddleware that posts to OPA.