S
supero.docs
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.
RoleLevelScopeTypical use
domain_admin1domainOwns a domain: every project, tenant, user, and schema in it
domain_user2domainDomain-wide read plus full CRUD, no admin/management rights
project_admin3projectManages every tenant in a project; owns schema management
project_user4projectProject-wide read plus full CRUD, no admin rights
developer5projectTechnical role: schema/SDK management and CRUD, for building and testing. Not cross-tenant — see the note below
tenant_admin6tenantManages one tenant's users and settings; reads schemas but doesn't define them
tenant_user7tenantRegular business user — full CRUD inside their own tenant, no admin rights
viewer8tenantRead-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.
ObjectParentKey fields
access_policyprojectrole, default_access (full | read | none), is_active
access_ruleaccess_policyentity, 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_matchResolves to
$user.emailThe caller's email address
$user.uuidThe caller's user UUID
$user.nameThe 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