Documentation/Managing Your App/Access Control (RBAC)
Access Control (RBAC)
How Supero's two-layer RBAC model works: coarse role permissions plus fine-grained, per-schema access policies you configure without writing code.
Overview
Every request into Supero is checked by two independent layers. Layer 1 is a coarse permission check keyed off a fixed role catalog (can this role call crud:create at all?). Layer 2 is a per-schema access_policy / access_rule object that narrows things further per entity, per record (owner-scoping), and per field (hide or lock specific columns) — configured as plain data, not application code.
This page covers both layers: the real role catalog, how object-level policies are structured and resolved, how to configure them, and how to check access from your app's frontend. It replaces any documentation describing a generic policy object with free-form conditions — that model does not exist in Supero.
ℹ️ Two layers, one role string
Both layers key off the same
role on the authenticated user or API key. Layer 1 decides whether the role can touch a resource type at all; Layer 2 (only checked for your own app schemas, not platform objects) decides which rows and fields it can see or write.The Role Catalog
Roles are a fixed, closed set — there is no free-form role string. Lower hierarchy level means more privileged; a role can only ever be assigned to users at a strictly lower level than the assigner.
| Role | Level | Scope | Typical use |
|---|---|---|---|
| domain_admin | 1 | domain | Owns a domain: every project, tenant, user, and schema in it |
| domain_user | 2 | domain | Domain-wide read plus full CRUD, no admin/management rights |
| project_admin | 3 | project | Manages every tenant in a project; owns schema management |
| project_user | 4 | project | Project-wide read plus full CRUD, no admin rights |
| developer | 5 | project | Technical role: schema/SDK management and CRUD, for building and testing. Not cross-tenant — see the note below |
| tenant_admin | 6 | tenant | Manages one tenant's users and settings; reads schemas but doesn't define them |
| tenant_user | 7 | tenant | Regular business user — full CRUD inside their own tenant, no admin rights |
| viewer | 8 | tenant | Read-only |
platform_admin and platform_user sit above domain_admin as global, Supero-staff-only roles — they are not customer-assignable and you should not offer them as an option in your app's user management.
⚠️ developer is nominally project-scoped but is not cross-tenant
The cross-tenant allowlist the server checks names project_admin, project_user, domain_user and the domain/platform admin roles. developer is in neither that list nor the admin bypass, so a developer account in an ordinary tenant is clamped to that one tenant, exactly like tenant_user. Do not build a cross-tenant test harness or ops tool on a developer credential — use project_user.
A user's assigned role is stamped on login as both
role and base_role. On the wire, an API key carries the same role/permission model as a logged-in user — send it as the X-API-Key header (never as a Bearer token).Layer 1: Role Permission Strings
Each role maps to a flat list of resource:action permission strings, e.g. crud:create, tenant:list, access_policy:update, service:configure. These are resolved once at login and shipped to the client — every subsequent request is checked against that list server-side (a stale client-side list can't grant anything).
http
POST https://api.supero.dev/api/v1/auth/login
Content-Type: application/json
{
"domain": "acme-corp",
"project": "default-project",
"tenant": "northwind",
"email": "[email protected]",
"password": "secret123"
}The response carries everything downstream checks need: the effective role, the permission list, which UI surface to render, the computed scope, and the resolved object-level policy.
json
{
"auth": {
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 28800,
"session_id": "..."
},
"user": {
"role": "tenant_admin",
"base_role": "tenant_admin",
"permissions": ["crud:create", "crud:read", "crud:update", "crud:delete", "crud:list", "user:read", "..."],
"ui_view": "admin"
},
"context": {
"scope": { "level": "tenant", "tenant_uuid": "..." }
},
"policy": {
"default_access": "full",
"is_admin": false,
"entities": { "appointment": { "can_create": true, "can_read": true, "can_update": true, "can_delete": false } }
}
}Three roles bypass every Layer 1 permission check unconditionally: platform_admin, platform_user, and domain_admin. Everyone else is checked against their stamped permission list on every request.
Layer 2: Object-Level Access Policies
For the complete set, see Roles & Permissions Reference.
For your own app schemas (not platform objects like tenant, project, or user_account), a second layer narrows access per entity, per record, and per field. It's built from two ordinary CRUD object types — access_policy and access_rule — that you configure like any other data, not from code you deploy.
| Object | Parent | Key fields |
|---|---|---|
| access_policy | project | role, default_access (full | read | none), is_active |
| access_rule | access_policy | entity, can_create / can_read / can_update / can_delete, filter_field, filter_match, hidden_fields, readonly_fields |
🚨 Default is permissive, not deny
Object-level policy defaults to permissive when nothing is configured for a role: viewer defaults to read-only, every other role defaults to full access. It only becomes restrictive once you create an access_policy row with default_access set to none (or read) for that role, and/or access_rule rows narrowing specific entities. Don't assume an unconfigured schema is locked down.
An access_rule replaces the policy default for its entity wholesale. Once a rule exists for an entity, that rule alone decides the four verbs: all of can_create, can_read, can_update, and can_delete are mandatory on the object, and an omitted flag resolves to its schema default, never to the policy's default_access. Four roles bypass object-level policy entirely and always get full access regardless of what's configured: platform_admin, platform_user, domain_admin, and project_admin. Every other role — including tenant_admin — is subject to whatever policy is configured.
🚨 Set all four flags on every rule
The schema defaults are not symmetric: can_read defaults to true, while can_create, can_update, and can_delete default to false. A partial rule therefore fails open on read — a rule that omits can_read grants read on that entity even under a policy with default_access set to none — and fails closed on writes, denying create under a default_access of full. Write all four flags explicitly on every access_rule, including the ones you intend to be false.
⚠️ tenant_admin is not an automatic bypass
tenant_admin looks like an admin role but is NOT in the object-level bypass set. Its coded default is full access, but a configured access_policy row can still restrict it like any other role.
Resolved policies are cached for 5 minutes per (project, role). Writing an access_policy or access_rule clears that cache — but only for one project, and not necessarily the right one.
🚨 A revocation is not immediate
The cache is invalidated for the editing admin's own JWT project, not for the project the policy belongs to. If you edit a policy for any project other than the one your session is currently scoped to, affected users keep their stale policy for the rest of its 5-minute TTL — a permission you just revoked keeps working for that whole window. To make a change take effect at once, switch your session into the policy's own project with POST /api/v1/auth/switch-project before writing it. Otherwise treat 5 minutes as the worst case, and never report to an operator that access has been revoked on the strength of a 200 from the write.
Configuring Access Policies
Policies and rules are plain CRUD writes against your domain, so you configure them with the same create/update calls you'd use for any other object. Writing them requires the access_policy:{action} permission — project_admin, domain_admin, and platform_admin/platform_user have it by default.
Create the policy (one per role, per project)
http
POST https://api.supero.dev/api/v1/crud/acme-corp/access-policy
Authorization: Bearer <jwt>
Content-Type: application/json
{
"parent_type": "project",
"parent_uuid": "<project-uuid>",
"role": "tenant_user",
"default_access": "none",
"is_active": true
}Add a rule scoping one entity
http
POST https://api.supero.dev/api/v1/crud/acme-corp/access-rule
Authorization: Bearer <jwt>
Content-Type: application/json
{
"parent_type": "access_policy",
"parent_uuid": "<access-policy-uuid>",
"entity": "appointment",
"can_create": true,
"can_read": true,
"can_update": true,
"can_delete": false,
"filter_field": "customer_email",
"filter_match": "$user.email",
"hidden_fields": "internal_notes",
"readonly_fields": "status"
}Two read-only helper endpoints support building an admin UI on top of this: GET /api/v1/crud/{domain}/access-policy/me returns the caller's own resolved policy (drives show/hide in your UI), and GET /api/v1/crud/{domain}/access-policy/roles?parent_id={project_uuid} returns the full role roster with configured/default status per role.
Owner-Scoping and Field Restrictions
There is no generic conditions engine with arbitrary template expressions. Record-level restriction is a single, specific mechanism: filter_field plus filter_match on an access_rule. filter_match accepts exactly three values, resolved against the calling user at request time.
| filter_match | Resolves to |
|---|---|
| $user.email | The caller's email address |
| $user.uuid | The caller's user UUID |
| $user.name | The caller's username |
Setting filter_field="customer_email", filter_match="$user.email" on the appointment rule above means: list/query reads automatically add customer_email=<caller's email> to the filters; a create auto-stamps that field to the caller's own email, overriding anything the client sent; and a get/update/delete by UUID on someone else's row returns 404 (not 403, so it doesn't confirm the row exists).
hidden_fields (a comma-separated list) strips those keys from every read response. readonly_fields strips those keys from the incoming payload on create/update before it's persisted — note that stamping a field via filter_field does not automatically make it immutable; list it in readonly_fields too if it must not be editable after creation.
Owner-scoping is opt-in per entity — there's no platform-wide "users only see their own rows" default. Every entity that needs it needs its own filter_field/filter_match rule.
Custom Roles
You can register a custom role name (for example "patient" or "dentist") so your app can use domain-specific labels in its UI and schemas. A custom role is not a new privilege level — it's a named alias that points at a base_role used for every actual access decision: permissions, UI surface, and policy defaults all resolve through the base_role, not the custom name.
json
{
"parent_type": "project",
"parent_uuid": "<project-uuid>",
"name": "dentist",
"base_role": "tenant_admin"
}base_role must be one of tenant_admin, tenant_user, or viewer — that's the privilege ceiling for any custom role, so a custom role can never be more powerful than a built-in tenant_admin. You also can't shadow a built-in role name, and a creator can't base a new role on something more privileged than their own role.
Checking Access From Your App
Your app's frontend never re-derives permissions — it renders and gates strictly off the permissions and policy the server shipped at login. There is no simulator UI and no API endpoint to "test" a hypothetical policy decision; the only way to know what a role can do is to check the login response, or query access-policy/me as that role.
javascript
// client.has() checks the coarse Layer-1 permission list shipped at login
if (client.has('crud:delete')) {
// show the delete button
}
// client.can(action, schema) checks the resolved Layer-2 object policy
// for a specific entity
if (client.can('update', 'appointment')) {
// allow editing this record type
}
// canWrite is a convenience for create-or-update
if (client.canWrite('appointment')) {
// show the save button
}hasAny([...]) and hasAll([...]) do the same strict lookup against multiple permissions at once. Always pair a client-side check with the server enforcing the same rule — the client check only controls what your UI shows, never what the API accepts.
Next Steps
- •Authentication — how login, JWTs, API keys, and session refresh work end to end
- •Schema Reference — defining the entities your access rules will apply to
- •CRUD Endpoints — the create/read/update/delete/list/query endpoints access policies enforce
- •Users & Roles — assigning built-in and custom roles to accounts
On this page