S
supero.docs
Documentation/Managing Your App/Access Policies (Entity, Record & Field)

Access Policies (Entity, Record & Field)

The full reference for object-level access policies: how access_policy and access_rule records restrict a role per schema entity, per record (owner-scope), and per field, with no application code.

Overview

An access policy is a second, finer-grained layer on top of a role's coarse permissions (whether the role can call crud:create at all). It restricts a role's access to your own app schemas at three levels, purely through data you configure:
  • •Entity-level — can this role create, read, update, or delete a given schema type at all?
  • •Record-level (owner-scope) — of the rows that exist, which ones can this role see or touch?
  • •Field-level — within a row it can see, which fields are hidden on read, and which are locked on write?
Both building blocks — access_policy and access_rule — are ordinary CRUD objects that live in your domain like any other data. There is no separate policy language, no template-expression engine, and no wildcard role or entity: everything is expressed with a fixed set of fields on those two object types, described below.

ℹ️ Who this applies to

Access policies apply to your own app schemas (entities like appointment, customer, order). They do not gate platform objects such as tenant, project, or user_account — those are governed purely by the role's coarse permission list.

Which roles are affected

Of the roles you can assign to your own users, domain_admin and project_admin bypass access policies entirely and always have full access. Every other assignable role — tenant_admin, tenant_user, viewer, developer, project_user, and domain_user, plus any custom role — is subject to whatever policy is configured for it.
RoleScopeSubject to access policies?Can configure them?
domain_admindomainNo — bypasses entirelyYes
domain_userdomainYesRead-only
project_adminprojectNo — bypasses entirelyYes
project_userprojectYesRead-only
developerprojectYesRead-only
tenant_admintenantYesYes
tenant_usertenantYesRead-only
viewertenantYesRead-only

⚠️ tenant_admin is not a bypass role

tenant_admin looks like an admin role but is fully subject to access policies like tenant_user or viewer — it just happens to default to full access when nothing is configured (see Default Behavior below), and it is one of the roles allowed to configure policies for others.

Data Model

access_policy and access_rule are plain CRUD object types — you write, read, and query them exactly like any schema in your domain, via the standard /api/v1/crud/{domain}/{type} endpoints.

access_policy — parent: project

One access_policy row applies to a single (project, role) pair. Create at most one active row per role per project — if more than one active row exists for the same role, the most recently updated one is used.
FieldTypeDescription
rolestring, requiredThe role this policy applies to, e.g. tenant_user, viewer, or a registered custom role.
default_accessstring, requiredThe fallback for any entity with no matching access_rule: full, read, or none.
is_activebool, requiredOnly active policies are resolved; a row with is_active=false is ignored, same as if it didn't exist.
descriptionstring, optionalFree-text note for your own admin UI.
priorityinteger, optionalStored on the record for your own bookkeeping.

access_rule — parent: access_policy

Each access_rule narrows exactly one entity (schema type) under a policy. An entity with no access_rule falls back to the policy's default_access.
FieldTypeDescription
entitystring, requiredThe schema name this rule applies to, e.g. "appointment". Matched case-insensitively against the bare name, so CartItem matches cartitem. Namespace-qualify it (myapp:appointment) whenever two namespaces in the project define a schema with the same bare name — see the warning below.
can_createbool, requiredWhether this role can create new records of this entity.
can_readbool, requiredWhether this role can read/list/query records of this entity.
can_updatebool, requiredWhether this role can update records of this entity.
can_deletebool, requiredWhether this role can delete records of this entity.
filter_fieldstring, optionalThe field name used for record-level owner-scope (see below). Omit for no record-level restriction.
filter_matchstring, optionalOne of $user.email, $user.uuid, or $user.name — required if filter_field is set.
hidden_fieldsstring (comma-separated), optionalField names to strip from every read response for this role.
readonly_fieldsstring (comma-separated), optionalField names to silently drop from create/update payloads for this role.

🚨 An ambiguous entity name makes the rule silently not apply

Entity matching is a lookup, and it refuses to guess. If a bare name matches more than one stored rule key, the resolver logs a warning, applies no rule at all, and falls through to the policy default_access. It does the same when the request carries a namespace and the only stored rule that shares the bare name lives in a different one — a sibling app's rule is excluded rather than borrowed. Either way, a restrictive rule you wrote quietly does not run, and the entity gets whatever default_access says instead. Namespace-qualify the entity on any rule whose bare name is not unique across the project, and after configuring a policy read it back with GET /api/v1/crud/{domain}/access-policy/me as that role to confirm the entity actually appears in the resolved entities map.

💡 Both objects also carry the standard system fields

uuid, fq_name, parent_type, parent_uuid, created_at, and updated_at behave exactly as on any other CRUD object — records key on uuid, never on a separate id field.

Level 1: Entity Access

For a given (project, role), Supero resolves entity access in two steps: if an access_rule exists for that exact entity, its can_create/can_read/can_update/can_delete flags apply exactly as set. If no rule exists for that entity, the policy's default_access applies instead.
default_accessEffective permissions for entities with no rule
fullCreate, read, update, and delete all allowed.
readOnly read/list/query allowed; create, update, delete denied.
noneNo default access — create, update, and delete are denied (403) unless an access_rule grants them. For reliably restricting reads, see the note below.
A denied create, update, or delete returns a 403.

⚠️ Restricting reads reliably

For reads, the dependable controls are hidden_fields (strip sensitive fields from every read response for the role) and owner-scoping (limit a role to its own rows) — both described below — plus keeping data a role must never see in a separate entity that role cannot query. Set default_access alongside those; don't rely on entity-level default_access on its own to keep an entity's records unreadable by a role that can otherwise reach the entity.

Default Behavior When No Policy Exists

🚨 The default is permissive, not deny

If a role has no access_policy row at all in a project, Supero does NOT lock that role out. viewer defaults to read-only; every other subject-to-policy role (tenant_user, tenant_admin, developer, project_user, domain_user, and custom roles based on any of them) defaults to full CRUD on everything. Access only becomes restrictive once you explicitly create an access_policy row — typically with default_access set to none or read — for that role.
Role (no access_policy row configured)Effective default_access
viewerread
tenant_user, tenant_admin, developer, project_user, domain_user (or a custom role based on one of these)full
domain_admin, project_adminnot applicable — these bypass policy resolution entirely
This matters most the first time you lock down an app: don't assume an entity is protected just because you haven't written a rule for it yet. To deny by default and open up specific entities, create an access_policy with default_access: "none" for the role, then add an access_rule per entity you want to allow.

Level 2: Record Access (Owner-Scope)

Record-level restriction — "a user can only see and modify their own rows" — is expressed with exactly two fields on an access_rule: filter_field (the field to filter/stamp) and filter_match (which value to compare it against). There is no generic conditions engine and no {{user.id}}-style templating; filter_match is limited to three resolvable values.
filter_matchResolves to
$user.emailThe calling user's email address
$user.uuidThe calling user's UUID
$user.nameThe calling user's username
Setting filter_field: "customer_email", filter_match: "$user.email" on an appointment rule means:
  • •List and query reads automatically add customer_email=<caller's email> to the filters, so the role only ever sees its own rows.
  • •A create automatically stamps customer_email to the caller's own email — a client-supplied value for that field is overridden, not merely validated.
  • •A get, update, or delete by UUID on a row belonging to someone else returns 404, not 403, so the response doesn't confirm the row even exists.

⚠️ Owner-scope is opt-in, per entity

There is no platform-wide "users only see their own rows" default. Every entity that needs owner-scoping needs its own access_rule with filter_field and filter_match set — an entity with no rule (or a rule with neither field set) is not owner-filtered at all.

Level 3: Field Access

Hiding fields on read — hidden_fields

hidden_fields removes those fields from read responses for this role. If a value must also not be inferable indirectly (for example via query filters, sorting, or aggregation), don't place it on an entity this role can query — hold it in a separately access-controlled entity instead.

Locking fields on write — readonly_fields

readonly_fields lists field names to silently drop from the incoming payload on create and update, before the write is persisted. The client's attempted value for that field is discarded, not rejected — the request still succeeds, just without that field taking effect.

⚠️ readonly_fields is not implied by owner-stamping

A field named in filter_field is auto-stamped on create, but that alone does not make it immutable afterward. If it must stay fixed after creation (so a caller can't re-point their own rows to someone else's owner value), list it in readonly_fields as well.

💡 Comma-separated or a list

hidden_fields and readonly_fields are stored as a comma-separated string ("secret,internal_notes"), but the CRUD write API also accepts a JSON array of field names — both are normalized the same way.

Configuring Policies

For the complete set, see Roles & Permissions Reference.
Policies and rules are plain CRUD writes, so you configure them with the same create/update/delete calls you'd use for any other object — either directly, or through your Admin UI's Access Policies screen, which calls the same endpoints. Writing access_policy or access_rule requires the access_policy:create / access_policy:update / access_policy:delete permission, which tenant_admin and the project/domain admin roles hold by default; roles that are only subject to policy (tenant_user, viewer, developer, project_user, domain_user) can read but not write them.

Create the policy — one per role, per project

bash
curl -X POST https://api.supero.dev/api/v1/crud/acme-corp/access_policy \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "tenant-user-appointments",
    "parent_type": "project",
    "parent_uuid": "<project-uuid>",
    "role": "tenant_user",
    "default_access": "none",
    "is_active": true
  }'
The response includes the new policy's uuid — you'll need it as parent_uuid for each access_rule under it.

Add a rule scoping one entity

bash
curl -X POST https://api.supero.dev/api/v1/crud/acme-corp/access_rule \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "tenant-user-appointment-rule",
    "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"
  }'

Python SDK

python
import supero

org = supero.login(domain_name="acme-corp", email="[email protected]", password="...")

policy = org.crud.create(
    "access_policy",
    name="tenant-user-appointments",
    parent_type="project",
    parent_uuid=project_uuid,
    role="tenant_user",
    default_access="none",
    is_active=True,
)

org.crud.create(
    "access_rule",
    name="tenant-user-appointment-rule",
    parent_type="access_policy",
    parent_uuid=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",
)

Reading the resolved policy

Two read-only helper endpoints exist for building an admin UI or for your app to adapt its own rendering to the caller's access:
http
GET /api/v1/crud/{domain}/access-policy/me
Authorization: Bearer <jwt>
Returns the caller's own fully resolved policy — useful for deciding what buttons or nav items to show:
json
{
  "success": true,
  "policy": {
    "role": "tenant_user",
    "default_access": "none",
    "has_policy": true,
    "is_admin": false,
    "entities": {
      "appointment": {
        "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"]
      }
    }
  }
}
http
GET /api/v1/crud/{domain}/access-policy/roles?parent_id={project_uuid}
Authorization: Bearer <jwt>
Returns the full role roster for a project, one entry per role, showing whether each has a configured policy and what the effective default is if not:
FieldDescription
roleThe role identifier, e.g. "tenant_user".
label / scope / descriptionHuman-readable display metadata for an admin UI.
is_adminTrue for roles that bypass policies entirely (no row, no default applies).
configuredTrue iff an active access_policy row exists for this role in this project.
default_accessThe row's default_access if configured; otherwise the backward-compatible default (see Default Behavior above).
policy_uuidThe access_policy uuid, present only when configured is true.
Resolved policies are cached for 5 minutes per (project, role). Creating, updating, or deleting an access_policy or access_rule clears that cache — but only for one project, and not necessarily the one the policy belongs to.

🚨 A revocation is not immediate

The cache is invalidated for the editing admin's own JWT project, not for the project that owns the policy. Edit a policy for any project other than the one your session is scoped to and 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 do not report access as revoked on the strength of a 200 from the write.

Worked Example

A tenant_user should only see and edit their own appointment rows, never see internal_notes, and never be able to change status after creation.
json
// access_policy
{
  "role": "tenant_user",
  "default_access": "none",
  "is_active": true
}

// access_rule (parent = the policy above)
{
  "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"
}
Resulting behavior for a tenant_user:
  • •Creating an appointment auto-stamps customer_email to their own email address, ignoring any value they send for that field.
  • •Listing or querying appointments only ever returns rows where customer_email matches their own email.
  • •Fetching, updating, or deleting another user's appointment by UUID returns 404.
  • •internal_notes never appears in any response returned to them.
  • •A PUT that attempts to change status has that key silently dropped; the stored value is unchanged.
  • •Deleting any appointment — even their own — is denied, since can_delete is false.
  • •Any other entity in the project with no matching access_rule is denied for writes, because the policy's default_access is none. Reads are a different matter — see the warning below.

🚨 access "none" does not make records unreadable

This is the most consequential asymmetry in the access model, so plan around it rather than discovering it. The object access policy is evaluated on create, update, delete and reference-linking. It is not consulted on a read. Setting an entity to access "none" for a role stops that role writing it — the records remain readable. If a value must not be seen by a role, it must not live on an entity that role can reach at all. See Architecture for where this sits in the two-layer model.

    Next Steps