Access Control

  1. The two axes
  2. The algorithm
  3. Step 1: Entitlement status
  4. Step 2: Effective permissions
    1. Worked example
  5. Organization vs. User precedence
    1. Worked example: multi-org, one Application
  6. What apps should actually call

The two axes

Every access decision in this system factors into two independent questions, checked in order:

  1. Is the app itself live for this user right now? → the Entitlement.
  2. If it is, what are they allowed to do? → Roles, resolved into Permissions.

These are independent on purpose. A support agent can hold a platform Role that lets them view every user’s entitlements without holding a single entitlement themselves — the ability to administer access is not the same axis as the ability to use an app. Conversely, a user can own an app (active entitlement) but hold no special role inside it beyond the implicit default member role — most users, most of the time.

The algorithm

allow(user, application, permission) :=
    resolved_entitlement_status(user, application) == "active"
    AND permission ∈ effective_permissions(user, application)

Where:

resolved_entitlement_status(user, application) :=
    "active" if personal_entitlement(user, application).status == "active"
    "active" if ∃ org ∈ organizations(user) :
                   org_entitlement(org, application).status == "active"
                   AND member_included(org_entitlement(org, application), user)
    else the most relevant non-active status found, or "none" if no path exists at all

effective_permissions(user, application) :=
    permissions(platform_roles(user))
    ∪ permissions(app_roles(user, application))

resolved_entitlement_status is a union across every path the user has to this one Application — their own personal Entitlement, plus every Organization they belong to that holds an org-wide grant that includes them — not a single row lookup. See Step 1 for why this is a union rather than a single answer, and Organization vs. User precedence for how a conflict between an Organization’s decision and a User’s own standing resolves. effective_permissions itself is unaffected by any of this — Role is a separate axis from how the entitlement was resolved.

This is the function exposed directly as GET /v1/users/{id}/applications/{appId}/effective-permissions — any service, including a downstream app, can ask the hub for the resolved answer instead of re-implementing the union above.

Step 1: Entitlement status

An Entitlement is the join between a User (or Organization) and an Application, carrying a status. A given User can have more than one path to the same Application at once — their own personal Entitlement, and/or an org-wide grant through any Organization they belong to — so “the entitlement” for a (user, application) pair is really the union of every path, not guaranteed to be a single row. Each individual path still carries one of these statuses:

Status Meaning Can the user reach the app?
active Owned and switched on. Yes.
disabled Owned, but an admin/support agent turned it off. No.
expired A trial or subscription window lapsed. No.
revoked Ownership itself was removed (refund, chargeback, ToS action). No.

“Turn an app on/off for a user” is a write to status, nothing more. It does not touch Roles, Profile, or Settings — those are preserved so that re-enabling an app restores the user’s exact prior configuration rather than re-provisioning from scratch.

A disabled entitlement is a soft, reversible toggle (support can flip it back to active). A revoked one implies the purchase itself is gone — re-granting access means a new Entitlement, not reinstating the old one.

Step 2: Effective permissions

Platform roles apply everywhere and typically govern the hub itself rather than any one product: superadmin, support, billing_admin, member (the default every user gets on signup, granting nothing beyond managing their own account).

App roles are scoped to one Application and defined by whoever owns that app’s catalog entry — one app might define admin / editor / viewer; another might only need admin / member. The hub stores and enforces the assignment; the app defines the vocabulary.

A user can hold any number of app roles across different apps, and they’re independent of each other — being an admin of one owned app says nothing about their role in another.

Worked example

User usr_01JAG... has:

  • Platform role: member (default, no special permissions)
  • Entitlement to app_timetrack: active
  • App role on app_timetrack: admin → grants app.timetrack.export, app.timetrack.manage_members
  • Entitlement to app_invoicer: disabled (support turned it off after a billing dispute)
  • App role on app_invoicer: member → grants app.invoicer.view

Calling allow(user, app_timetrack, "app.timetrack.export") → entitlement is active and the permission is in the union → allowed.

Calling allow(user, app_invoicer, "app.invoicer.view") → entitlement is disabled → denied, regardless of the role held. The role assignment is untouched and will apply again the moment support re-enables the entitlement.

Organization vs. User precedence

Organizations are in scope from Phase 2 onward (see Decisions). Once a User can belong to an Organization that itself holds an org-wide Entitlement, a real question follows: if the Organization’s decision and the User’s own standing could point different ways, which wins? Resolved in Decisions → Organization-vs-User entitlement precedence:

  1. Within one Organization’s own grant, the Organization’s decision is final. An org admin’s member_scope (see Entitlements → Org-wide entitlements) decides who among the org’s members actually receives that grant — a member can’t opt themselves in or out of it.
  2. A User’s own personal Entitlement to the same Application is a separate, untouched path. Being excluded from one Organization’s seat grant never revokes a personal Entitlement held some other way — see the worked example below.
  3. Effective access is the union of every active path, so a User in multiple Organizations never hits a real conflict between them — each Organization’s grant only ever speaks for itself. resolved_entitlement_status above is this union, formally.
  4. Attribution is always visible: a User’s own entitlements list shows which Organization a given org_seat path came from, and whether member_scope included or excluded them — never a bare allow/deny with no source. See Entitlements → Attribution.

Worked example: multi-org, one Application

User usr_jordan is:

  • A member of org_acme, which holds an active org-wide Entitlement to app_invoicer with member_scope: all_members (no exclusions).
  • A member of org_beta, which holds an active org-wide Entitlement to app_invoicer with member_scope: denylist, and usr_jordan is on that list.
  • The holder of their own personal (source: admin_grant) Entitlement to app_timetrack — unrelated to either Organization.

Resolving allow(usr_jordan, app_invoicer, "app.invoicer.view"): org_beta excludes them from its grant, but that only removes org_beta’s path — org_acme’s grant still includes them, so resolved_entitlement_status is active via org_acme, and the call is allowed. There’s no “which Organization wins” conflict to resolve, because org_beta’s exclusion was never a global deny — it only ever governed org_beta’s own grant.

If usr_jordan were not a member of org_acme at all, the same call would resolve to denied — org_beta’s exclusion is the only path to app_invoicer, and it says no. Their personal Entitlement to app_timetrack is unaffected either way; it was never part of either Organization’s decision.

What apps should actually call

Don’t re-derive this algorithm inside a downstream app. Three options, see Trust Model for when to use which:

  • Decode the JWT issued at login/SSO — carries a pre-resolved entitlement_status and effective_permissions claim for that one app, cheap but can go stale within its TTL.
  • Call GET /v1/users/{id}/applications/{appId}/effective-permissions for a live answer.
  • Subscribe to the entitlement.* and role.* webhooks to react immediately rather than poll.

Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.