Access Control
- The two axes
- The algorithm
- Step 1: Entitlement status
- Step 2: Effective permissions
- Organization vs. User precedence
- What apps should actually call
The two axes
Every access decision in this system factors into two independent questions, checked in order:
- Is the app itself live for this user right now? → the Entitlement.
- 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→ grantsapp.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→ grantsapp.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:
- 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. - 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.
- 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_statusabove is this union, formally. - Attribution is always visible: a User’s own entitlements list shows which Organization a given
org_seatpath came from, and whethermember_scopeincluded 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 toapp_invoicerwithmember_scope: all_members(no exclusions). - A member of
org_beta, which holds an active org-wide Entitlement toapp_invoicerwithmember_scope: denylist, andusr_jordanis on that list. - The holder of their own personal (
source: admin_grant) Entitlement toapp_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_statusandeffective_permissionsclaim for that one app, cheap but can go stale within its TTL. - Call
GET /v1/users/{id}/applications/{appId}/effective-permissionsfor a live answer. - Subscribe to the
entitlement.*androle.*webhooks to react immediately rather than poll.