RBAC
Ragz gates every action behind an explicit permission check, resolved fresh on
every request from the caller's role, org, workspace memberships, and (if
assigned) a custom role template. There is no ambient trust: a request either
carries the permission the route declares, or it gets a 403 — and that
denial is written to the audit log alongside every allow.
The three org-level tiers
Every user has exactly one role on their account, scoped to their
organization:
| Role | What it means |
|---|---|
| superadmin | Platform operator. Not scoped to a single org — manages organizations, SSO config, model catalog, and the global role-template library. Bypasses every permission check. |
| admin | Org administrator. Holds every permission in the system except a short list of explicit carve-outs (below). Manages workspaces, users, and org-wide settings for their own organization. |
| user | A plain member. Gets either the built-in read-only default, or — far more commonly in practice — an assigned custom role template that grants exactly the permissions their job needs. |
admin and superadmin are coarse, all-or-nothing tiers by design: they're
for the people who run the org or the platform. Everyone else — the actual
day-to-day users — is meant to be governed by a role template, not by
being promoted to admin. See Organizations & Workspaces
for how superadmin and admin map onto the org/workspace hierarchy, and
Roles & Permissions for the full permission
reference and how templates are composed.
Declarative checks at the route boundary
Every route that needs authorization declares it as a FastAPI dependency —
never as an if statement buried in a handler. Two flavors:
# Coarse: caller must hold one of these roles (superadmin always passes)
ConfigureDep = Annotated[TenantContext, Depends(require_role("admin"))]
# Granular: caller must hold this specific permission
MembersManageDep = Annotated[
TenantContext, Depends(require_action("workspace.members.manage"))
]require_action(action) is the central authorization decision point: it
resolves the caller's TenantContext (their role, org, workspaces, and
computed permission set), checks action against that set, and — on denial —
writes a structured result="denied" audit event before raising 403. The
handler body never runs on a deny; there's nothing to accidentally leak.
Deny-by-default
A plain user account with no custom role assigned gets a narrow read-only
floor — browse, search, and chat, nothing that uploads, deletes, or
mutates. Every other capability, including the ability to upload a
document or delete a chat message, requires an explicit grant through a
role template. Nothing is ambiently on "just because you're logged in."
The admin/superadmin auto-grant, and its carve-outs
To avoid re-declaring dozens of permissions for every admin account,
admin and superadmin are auto-granted the entire permission catalog —
minus three actions that must always come from an explicit, named
role-template grant, never from holding a role tier alone:
| Permission | Why it's carved out |
|---|---|
documents.acl.bypass | Lets a caller read a document regardless of its ACL group. Being an org admin should not silently mean "can read every restricted document" — that has to be a deliberate, visible grant. |
audit.read | Reading the org's audit trail is a distinct duty from running IAM (NIST AC-5 separation of duties). An admin doesn't automatically get to read the audit log just for being an admin. |
audit.export | Same reasoning as audit.read, for bulk export specifically. |
An admin who needs one of these gets it by being assigned a role template that includes it (the seeded Audit Reader or Content Manager templates cover the common cases — see Roles & Permissions).
Custom role templates
Beyond the three org-level tiers, superadmins author role templates: a
name plus a flat list of dotted permission flags (workspace.members.manage,
reports.view.department, audit.read, …), drawn from the same catalog
admins auto-hold. An org admin assigns a template to a user-tier account —
that account's effective permissions become exactly the template's list, no
more. Templates are versioned (draft → active → archived) with rollback, so a
bad template change doesn't strand every assigned user on a broken
permission set. Full mechanics in
Roles & Permissions.
Iron rule: document ACLs are enforced inside the vector query, never post-filtered
Permission checks (require_action) gate whether you can call a route at
all. Document-level access control is a separate, stricter guarantee:
every retrieval — chat citations, search, chunk fetches — passes the
caller's ACL group membership straight into the Qdrant filter itself,
through the single filter-building function in modules/retrieval/.
Restricted content is never fetched and then filtered out in Python after
the fact. This means an answer can never cite a document the asking
user isn't allowed to open — there is no code path where it's fetched into
memory and only excluded afterward. The only way to see a restricted
document's contents is the explicit documents.acl.bypass carve-out above.
Restricted documents still appear in workspace document listings for
plain members — existence is visible, Drive-style — but their
acl_group_ids field is blanked to null for anyone below admin, and
their content/citations stay ACL-enforced regardless.
Where to go next
- Organizations & Workspaces — the tenancy hierarchy, and how a superadmin sets up a new org, its admin, and its first department.
- Roles & Permissions — the full reference: every org role, every workspace role, every seeded role template, and the reporting-scope model.
- Audit Log — where every allow and deny above ends up, and how to read or export it.