RFC: RBAC — Access & Authorization
Summary
Today a single object — the user's role — is simultaneously the commercial tier a customer buys, the permission bundle enforcement checks, and the identity admins reason about. This proposal splits it apart and rebuilds authorization on a small set of primitives:
- Seat License Type — today's "role", renamed. Remains the commercial object; stops being an authorization object.
- Permission — a granular capability, named
<domain>.<resource>.<action>. Every authorization check is against a permission — never a role, never a seat license type. - Role — a named bundle of permissions. Builtin (global) or custom (per-vendor).
- Role Assignment — grants a role to a security principal at a scope.
- User Group — a set of users, addressable as a principal. The initial groups are builtin and resolve from user attributes; group management is deferred.
- Workspace — a new container for Projects and Playbooks; the mid-level scope. A Workspace holds objects, never users, and is not a tenant.
- Permission Boundary — an internal, per-seat-license-type policy that clamps effective permissions. Never exposed through any interface.
A principal's effective permissions are the union of their role assignments, scope-clamped so an instance-scoped assignment contributes only instance-grain capabilities, then filtered through the permission boundary bound to their seat license type. A project membership carries an access level — a role the member holds on that project — defaulting to the builtin Project Editor. Every vendor starts with two assignments (see Default access): one grants every internal user the maximum their boundary allows inside the Default Workspace, so effective access on day one equals today's access; the other keeps Super Admins in control of authorization configuration. A new Workspace carries no grant for internal users, which is how Workspaces segregate access.
Mocks: RBAC mockups. The mocks predate the Access Control UI described below.
Seat License Type
Today's "Role" (user_role: ONRAMP_ADMIN, OWNER, CREATOR, CONTRIBUTOR, COLLABORATOR, INTEGRATOR, API_USER, INTEGRATION_USER, CUSTOMER_EMPLOYEE) is renamed Seat License Type across the product and API vocabulary. Commercial semantics are unchanged: it is the unit customers buy, and the existing license caps and max_users enforcement — which today bind the five vendor human seat types — carry forward as-is. The OWNER code presents as Super Admin in every customer-facing surface; this document uses Super Admin for the seat license type and its holders.
What changes is its relationship to authorization. No enforcement site ever checks a seat license type. Its influence on access flows through exactly one mechanism: each seat license type is bound 1:1 to a permission boundary that clamps what its holders' role assignments can ever grant. The Integrator seat license type's boundary is identical to the Creator's, because automation running under that seat — CRM- and workflow-triggered project and task work — reaches exactly as far as a human Creator does.
Workspaces
A Workspace is a container for resource instances, scoped to a vendor.
A Workspace holds object instances and nothing else. It is not a tenant, and no user lives in one: nothing assigns a user to a Workspace, a Workspace has no members, and a Workspace has no roster to read. A user reaches the objects in a Workspace through a role assignment, whose scope is either the Workspace itself or one object inside it. The vendor stays the only tenant.
| Field | Notes |
|---|---|
id | UUID |
name | string; trimmed on write; unique per vendor among live rows, compared case-sensitively; Default is reserved in every letter case |
Workspaces hold Projects and Playbooks. Containment of further object types (library modules, automations, Workflows, Views) is deferred (see Deferred).
Every vendor gets an automatic Default Workspace, which cannot be renamed, deleted, demoted, or impersonated by a second workspace claiming its name. All existing Projects and Playbooks are migrated to the Default Workspace, and new instances are created there unless directed otherwise, so existing usage is unaffected.
The new capabilities Workspaces add:
- Assign a Project to a different Workspace.
- Assign a Playbook to a different Workspace.
- A Playbook determines which Workspace a project created from it lands in (defaults to the Playbook's own Workspace).
Moving an instance to another Workspace rewrites the workspace segment of every role assignment scoped to that instance — implicit and explicit — in the same transaction, so instance-scoped access travels with the instance.
Workspace-scoped access does not travel, and that is the point. The Default Workspace holds an assignment granting every internal user broad access (see Default access); a new Workspace holds no such assignment. Moving a Project out of the Default Workspace therefore revokes it from everyone who reached it through that assignment, leaving only the project's own members and whoever the Super Admin grants explicitly. The Access Control UI states this consequence on the move.
Workspace create, update, and delete are gated by permissions like any other operation (workspaces.workspace.write). Only a Super Admin holds that permission, because only the Super Admin boundary allows it.
Permissions
A Permission is a single capability, identified by a string in the format <domain>.<resource>.<action>. Each segment is lower-case, hyphen-separated: only a-z and - are allowed (segments match [a-z]+(-[a-z]+)*). Examples: projects.project.read, projects.status.update. The action segment draws from the catalog's closed verb set (read, write, create, update, delete, manage, execute, publish, export, import); new verbs require catalog review. write is the coarse default covering all mutations of a resource; the fine-grained verbs appear only when a role needs one mutation but not another.
| Field | Type | Notes |
|---|---|---|
id | UUID | environment-local |
name | string | the <domain>.<resource>.<action> identifier; unique |
description | string | human-readable English stating what the permission grants |
effective_scope | enum | exactly one of GLOBAL, WORKSPACE, INSTANCE |
restricted | bool | OnRamp-vendor only |
effective_scope declares the grain at which the capability operates:
GLOBAL— gates access to things that have no resource instances, or where instances don't apply (org settings, user management).WORKSPACE— can be effective on one workspace but not another (e.g. creating a project in a workspace).INSTANCE— can be effective on one instance (a project, a playbook) but not others.
restricted — when true, the permission can only be assigned to OnRamp users in the special OnRamp vendor. Restricted permissions are never shown outside the OnRamp vendor: they are omitted from list responses, and a direct get returns 404.
The permission catalog is global (not vendor-scoped), defined in code, and seeded into the database at deploy. Ids are environment-local — the same lookup-table rule as or_object_type — so enforcement sites and role definitions reference permissions by name, never by id.
The * permission
* is one reserved catalog entry standing for every non-restricted permission. It is the only name exempt from the <domain>.<resource>.<action> format. The authorization engine expands it at evaluation time: a role containing * contributes every non-restricted permission in the live catalog, each carrying its own effective_scope. A permission added to the catalog is covered the moment it ships, with no re-expansion step and no migration.
* never appears through an interface. The catalog listing omits it, a direct get returns 404, and role writes reject it — the same visibility treatment restricted permissions get. Only the builtin All Permissions role carries it. Restricted permissions fall outside the expansion by definition, so * never reaches an OnRamp-only capability.
Roles
A Role is a named list of permissions. Two types:
- Builtin — a global singleton, shipped with the application. Read-only for customers. The initial set is five roles: Project Viewer, Project Editor, Playbook Viewer, Playbook Editor, and All Permissions, the one role carrying
*. - Custom — per-vendor, authored by Super Admins.
| Field | Type | Notes |
|---|---|---|
id | UUID | |
name | string | A-Za-z0-9 plus , -, _, .; up to 128 characters |
description | string | up to 1024 characters; no charset restrictions |
permissions | list | the permissions the role bundles |
restricted | bool | same semantics as on permissions |
Invariants, enforced at create/update:
- Builtin role names are globally unique. Custom role names are unique within their vendor and must not conflict with any builtin name.
- A role with
restricted: false(the default) cannot contain restricted permissions. - A role with
restricted: trueis never available for assignment outside the OnRamp vendor, and is never shown outside it (list omission, 404 on get) — the same visibility rule as restricted permissions. - A role write never accepts
*. All Permissions is the only role holding it, and it ships with the application.
Scopes
A scope names where an assignment applies. Three forms:
| Scope | Notation |
|---|---|
| Global | * |
| Workspace | /workspaces/<workspace_uuid> |
| Instance | /workspaces/<workspace_uuid>/<instance_type>/<instance_uuid> |
Instance types at launch: projects and playbooks.
Scopes are hierarchical. A scope covers another scope when it is equal to it or an ancestor of it: * covers everything; a workspace scope covers itself and every instance scope beneath it; an instance scope covers only itself. Coverage is one-directional — an instance-scope grant never satisfies a workspace-scope check.
Two distinct dimensions share the GLOBAL / WORKSPACE / INSTANCE vocabulary; keep them apart:
- A permission's
effective_scopeis the grain of the capability itself — fixed on the permission. - An assignment's scope class is the classification of where the grant sits:
*is GLOBAL,/workspaces/<id>is WORKSPACE, anything deeper is INSTANCE. Permission boundaries filter on this dimension.
Role Assignments
A Role Assignment associates a security principal with a role at a scope:
{
"id": "<uuid>",
"principal": { "type": "USER", "id": "<principal_uuid>" },
"role_id": "<role_uuid>",
"scope": "/workspaces/<workspace_uuid>",
"implicit": false
}principal.typeis one ofUSER,USER_GROUP, orSERVICE_ACCOUNT;principal.idis the principal's UUID.USERandUSER_GROUPare both active;SERVICE_ACCOUNTis reserved (see Deferred).scopeis any of the three scope forms.implicitis true when the system manages the assignment's whole lifecycle, false when it stands on its own. Who created the row does not decide this, and the two assignments every vendor starts with teach the rule from both sides. The All Internal Users assignment isimplicit: falseeven though a trigger wrote it, because nothing revokes it when a relationship changes and a Super Admin manages it like any other explicit grant. The All Super Admins assignment isimplicit: trueeven though no resource relationship produced it, because the system owns it end to end: it cannot be edited as a role assignment, and a DELETE of it is rejected.
Only a Super Admin creates or deletes an explicit assignment. Every other user touches authorization only through an indirection: adding or removing a project member, choosing a member's access level, or changing a project owner drives the implicit assignments below.
Role assignments are validated when created or updated:
- The principal exists and belongs to the vendor.
- The role exists and is assignable in this vendor (builtin, or custom in the same vendor; restricted roles only in the OnRamp vendor).
- The scope parses, the referenced workspace/instance exists in the vendor, and the instance type is supported.
(principal, role, scope)is unique among explicit assignments. Implicit assignments are additionally keyed by their source, so an implicit and an explicit grant of the same role at the same scope coexist and are lifecycle-managed independently.implicitcannot be set by callers.
An assignment may carry permissions that cannot take effect at its scope. The rule is the scope clamp: an instance-scoped assignment contributes only INSTANCE-effective permissions, and any GLOBAL- or WORKSPACE-effective permission it carries is dormant. Such assignments are valid; the dormant permissions are simply never satisfied through them. The clamp is what makes instance-scoped assignments safe to delegate: whatever role an instance assignment names, it grants at most full control of that one instance. Workspace- and global-scoped assignments carry no clamp; they exist only as vendor defaults or Super Admin grants.
Role assignments are inputs to the effective-permission computation, never checked directly.
Implicit role assignments
The system creates implicit assignments to codify what resource relationships mean in permission terms. They accept no direct writes. The relationship-derived ones — everything in this section — appear, change, and disappear only with the relationship that produced them. The All Super Admins grant under Default access is the one implicit assignment no relationship produced, and the system converges it instead.
Assigning a user as a project member or a project owner always produces an implicit Project Editor assignment at that project's instance scope. A membership's access level (below) overrides the role that assignment names; the access level is empty by default, and an empty access level means the implicit Project Editor assignment takes effect.
Every application write path that creates, moves, or removes a project, a project membership, or a project owner converges the assignment directly. A database trigger on or_projects maintains the ownership half, because one writer sets project_owner_user outside the main web application through a direct database write. The bulk convergence routine is a repair tool that runs on demand when the assignments have drifted. It plays no part in normal operation and never runs at boot.
Project membership — removing the member removes the implicit assignment; changing the access level replaces it. A customer user's membership creates no assignment, and the API rejects an attempt to set an access level on it (see Deferred).
Project ownership — the owner's assignment follows the same lifecycle terms as the member's. It is fixed and carries no access level: an owner always holds full-edit access to their own project. An owner who is also a member holds both assignments, and the ownership one overrides: the owner's membership resolves to Project Editor regardless of any stored access level, so an access level neither lowers nor raises an owner.
Member access levels
Each project membership carries an optional access level: a reference to exactly one role. The implicit membership assignment mirrors it — the chosen role, or Project Editor when no choice is made. The access level replaces the default rather than stacking on it, so an access level below Project Editor narrows the member's access on that project. That replacement is what makes least privilege expressible at the roster.
The role_uuid field follows two rules for setting a member's access level, one per endpoint, because the two endpoints have different contracts for a request that omits it.
The access-level PUT (/projects/<id>/members/<user_id>/access-level) requires role_uuid. A request that omits the field answers badRequest. A null value clears the access level back to the default. A PUT targeting the project owner is rejected: the owner's access level is immutable, so it cannot be set or downscoped.
The add-member POST keeps the three-way rule: role_uuid is authoritative when the request carries it, a null value clears the access level back to the default, and omitting the field is meaningful there — it is what resolves a new member to the builtin Project Editor default.
Reading an access level uses GET /projects/<id>/members/<user_id>/access-level. For the project owner it answers with the builtin Project Editor role, because the owner's membership always resolves to that role regardless of any stored value. Reading the owner's access level is valid; setting it is not.
The two write rules diverge because a PUT that silently performs no write is a weaker contract than one that refuses: an update endpoint should not accept a request that changes nothing and report success as if it had. Omission becomes an error on the write path while remaining meaningful on creation, where it still resolves a new member to a real default.
Setting the access level is membership management, not authorization configuration: projects.members.manage covers adding a member, removing a member, and choosing the member's access level. Holders need no reach into the authz configuration API.
Implicit assignments are never editable as role assignments: no path edits or deletes one directly. Deleting a membership's access level is a membership operation, not an authorization one; it returns the member to the implicit Project Editor default.
A role is eligible as an access level when it is live, assignable in the vendor (builtin, or custom in the same vendor), permitted by the restricted-placement rule, and carries at least one INSTANCE-effective permission. The add-member dialog and the roster's access-level control list exactly the eligible roles; the API validates the same rule on write.
Delegating this choice to projects.members.manage holders is safe through three independent bounds:
- Role authorship stays with Super Admins — an access level only names a role that already exists in the vendor's catalog.
- The scope clamp caps every membership assignment at its project — whatever role the access level names, it grants at most full control of that one project.
- The member's permission boundary caps the result by their seat license type.
An access level can name a role that carries more instance permissions than Project Editor; the eligibility rule does not bound that choice. What bounds the member's actual reach is the permission boundary bound to the member's own seat license type, applied when permissions are evaluated rather than when the access level is set. A projects.members.manage holder can therefore never grant a member more than that member's own seat license type already allows, which is why Super-Admin-only authorization management survives this delegation intact.
Example: a Super Admin creates a custom role Project Comment Editor holding projects.project.read and projects.comments.manage. Andromeda, who holds projects.members.manage on project Oz, adds Perseus as a member with that access level. Perseus reads Oz and manages its comments; he can change nothing else in Oz and nothing outside it, whatever the role might later contain.
The add-member dialog presents the access level pre-selected to Project Editor, so the common case costs no extra interaction. The project roster shows each member's access level, editable inline by projects.members.manage holders. Customer members show no access level. The scope clamp ships in the same change as the access-level control, or before it: the control is escalation-safe only under the clamp, so no deployed state may carry the control without it.
User groups
A User Group is a set of users, addressable as a principal in a role assignment. Two builtin groups ship:
| Group | Members |
|---|---|
| All Internal Users | every internal user of the vendor |
| All Super Admins | every holder of the Super Admin seat license type |
Both groups resolve from user attributes rather than from stored membership: a resolver answers whether a user belongs, and no row records it. Neither group is created, renamed, or deleted through any interface.
Group membership is an identity concern, not an authorization one. The authorization engine treats a group principal exactly like a user principal — it aggregates the assignments applying to the user directly plus those applying to every group the user belongs to. Adding a differently-resolved group later changes no authorization rule.
Vendor-managed groups, with stored membership, are deferred.
Default access
Two role assignments ship with every vendor, both granting the builtin All Permissions role:
| Principal | Scope | Deletable |
|---|---|---|
| All Internal Users | /workspaces/<default> | yes |
| All Super Admins | * | no |
The two have different lifecycles. The All Internal Users assignment is explicit, and a trigger creates it in the same transaction as the vendor's Default Workspace. The All Super Admins assignment is implicit and system-owned. Org creation converges it for the new vendor, best-effort, and POST /api/authz/default-grants/converge repairs it on demand.
The All Internal Users assignment grants every internal user the maximum their seat license type allows inside the Default Workspace. Effective permissions there equal the permission boundary, which is what makes the migration invisible: no user gains or loses access on the day the system turns on. A Super Admin who deletes this assignment closes the Default Workspace, and access to its contents then flows only from project membership and explicit assignments.
The All Super Admins assignment is global and cannot be deleted. It keeps authorization control reachable: a Super Admin who closes the Default Workspace does not thereby remove their own ability to manage roles, assignments, and workspaces. It also reaches every Workspace, so a Super Admin always sees what they administer.
Every internal user holds an All Permissions grant, yet managing roles, assignments, and Workspaces stays a Super Admin capability: the Super Admin boundary is the only boundary allowing auth.role.write, auth.role-assignment.write, and workspaces.workspace.write. The boundary does the gating, and no enforcement site names a seat license type.
A Workspace created by a Super Admin receives no All Internal Users assignment. Contents put into a new Workspace are invisible to every non-Super-Admin until the Super Admin grants a role there explicitly. That is the whole of workspace segregation — no visibility flag, no special case in the engine.
Permission Boundaries
A Permission Boundary is an internal policy document: a list of permissions with allowed scope classes.
[
{ "permission": "projects.project.read", "scopes": ["INSTANCE"], "effect": "ALLOW" },
{ "permission": "projects.project.create", "scopes": ["GLOBAL", "WORKSPACE", "INSTANCE"], "effect": "DENY" }
]When a boundary is associated with a user, it acts as an allow-list filter on the effective-permission computation. Each contribution — a permission granted through some role assignment — is classified by the assignment's scope class and filtered:
- A
DENYentry matching the permission and scope class drops the contribution.DENYwins overALLOW. - Otherwise, an
ALLOWentry matching keeps it. - Otherwise the contribution is dropped — the boundary is an allow-list; absence means denied. A
DENYentry pins an exclusion explicitly instead of leaving it to omission.
Grants the boundary does not describe are clamped out, regardless of what role assignments say. The scope-class dimension is what makes seat-type semantics like "sees only projects they belong to" expressible:
- Today's Contributor reads and writes the projects they belong to, and reads no others. Boundary entry for the Contributor seat license type:
{ "permission": "projects.project.read", "scopes": ["INSTANCE"], "effect": "ALLOW" }, and the same shape forprojects.project.update. - Charlie holds a Contributor seat license and is a member of Project A, so the system holds an implicit assignment of Project Editor at
/workspaces/<default>/projects/<project-a>. Charlie is not a member of Project B. - Charlie also picks up the vendor's All Internal Users assignment — All Permissions at
/workspaces/<default>— and a Super Admin has granted Charlie Project Viewer at*. - Three surviving-candidate contributions carry
projects.project.read, one per scope class. The boundary keeps the INSTANCE contribution and drops the WORKSPACE and GLOBAL ones. Charlie reads Project A and nothing else. - Dana holds a Creator seat license, whose boundary allows
projects.project.readat WORKSPACE. Dana's All Internal Users contribution survives, so Dana reads every project in the Default Workspace. Same assignments as Charlie, different boundary, today's behavior for both.
Deployment model:
- One boundary per seat license type, 1:1, with a fixed set of policies.
- Boundaries ship with the application. They are not database objects and are not modifiable at runtime.
- Boundaries bind to principals through the seat license type assignment. A principal without a seat license type evaluates against a default-deny boundary.
- Boundaries are never exposed through any interface — no endpoint lists or returns them as objects, and no UI renders them. A denied check may name the boundary as its reason; the policy contents stay internal to the backend authorization computation.
Effective permissions
The effective permissions for a principal are computed by aggregating the permissions from all role assignments that apply to the principal, dropping the contributions the scope clamp forbids, then filtering through the principal's permission boundary:
contributions(P) = { (perm, a) : a ∈ assignments(P), perm ∈ permissions(role(a)),
scope_class(a) = INSTANCE ⇒ effective_scope(perm) = INSTANCE }
effective(P) = { (perm, scope(a)) : (perm, a) ∈ contributions(P),
boundary(P) allows (perm, scope_class(a)) }The condition on contributions is the scope clamp: an instance-scoped assignment contributes only INSTANCE-effective permissions.
permissions(role) expands * to every non-restricted permission in the live catalog, so All Permissions enters the formula as an ordinary permission set.
A check asks whether a principal holds a permission at a scope. The decision depends on the permission's effective_scope:
GLOBALpermissions are held-anywhere capabilities. Instances don't apply, so the granting assignment's scope is irrelevant to coverage: the check passes when any surviving contribution carries the permission. The scope clamp means such a contribution only ever arrives through a workspace- or global-scoped assignment — a vendor default, or a Super Admin grant. This is also what makes the system self-hosting out of the box — a Super Admin's global capabilities (user, workspace, role, and assignment management) arrive through the All Super Admins assignment.WORKSPACEandINSTANCEpermissions evaluate through coverage:check(P, perm, scope) = ∃ (perm, s) ∈ effective(P) : s covers scopeEnforcement sites issue the check at the scope of the resource being acted on. A check issued at a coarser scope than the permission's grain asks whether the permission is held across that entire scope — e.g. checking an INSTANCE permission at a workspace scope is true only for holders of a workspace- or global-scope grant.
For a user, assignments(P) covers the assignments applying to the user directly plus those applying to every group the user belongs to. Group resolution feeds that set and nothing else, so it changes no rule above.
Architecture
The authorization system is one subsystem, authz, with two surfaces:
- Configuration — create and manage roles and role assignments; read the permission catalog and the builtin groups. Enforces the rules around role definitions, assignment validation, and restricted visibility.
- Decision — computes effective permissions and answers whether a principal may perform an action at a scope. Enforces the rules around effective permissions, scopes, and boundaries.
Configuration API
Representative endpoints (not exhaustive):
| Endpoint | Methods | Notes |
|---|---|---|
/authz/permissions | GET | read-only catalog; * omitted everywhere, restricted rows omitted / 404 outside the OnRamp vendor |
/authz/roles, /authz/roles/<uuid> | GET/POST/PATCH/DELETE | writes on custom roles only; restricted visibility rules apply |
/authz/role-assignments, /authz/role-assignments/<uuid> | GET/POST/DELETE | implicit assignments are read-only; DELETE of the All Super Admins assignment is rejected |
/authz/user-groups | GET | read-only; the builtin groups |
Every write requires auth.role.write or auth.role-assignment.write, which in practice means a Super Admin.
Workspace CRUD is ordinary resource management (Workspaces are resources, not authorization configuration) and lives with the rest of the resource APIs.
Decision API
Exactly two endpoints. Both are also callable in-process: enforcement inside the Flask app uses the same interface as these endpoints without a network call. The HTTP surface exists for external components — lambda functions and other services making authorization checks.
POST /authz/check — takes a principal, a permission, and a scope; returns whether the principal has the permission at that scope. The endpoint adheres to the AuthZEN specification, implementing its minimal requirements:
// request
{
"subject": { "type": "USER", "id": "<principal_uuid>" },
"resource": { "type": "SCOPE", "id": "/workspaces/<workspace_uuid>" },
"action": { "name": "projects.project.read" }
}
// responses
{ "decision": true }
{ "decision": false, "context": { "reason": "Denied by permission boundary" } }POST /authz/query — takes any two of principal, permission, scope, and returns effective permissions organized by principal, permission, and scope(s):
| Given | Returns |
|---|---|
| principal + scope | the permissions the principal holds at that scope |
| principal + permission | the scopes at which the principal holds the permission |
| permission + scope | the principals holding the permission at that scope — computed in-process only; the HTTP endpoint answers 400 for this form until a concrete consumer shapes its design (result size, pagination) |
Query returns grant scopes — the (permission, scope) pairs of effective(P) — not their expansion into every covered scope; coverage questions go to /authz/check.
Authentication. Both endpoints accept a session cookie or a bearer token. A bearer token must be a valid JWT issued by the OnRamp OAuth issuer. Users can only query their own permissions by default; querying another principal — including the inherently cross-principal permission + scope form — requires a dedicated permission (auth.effective-permissions.read), and the system returns 403 Forbidden without it.
Access Control UI
Access Control is a new item on the Settings navigation, visible to Super Admins. It has three tabs:
| Tab | Purpose |
|---|---|
| Workspaces | create and update Workspaces; move Projects and Playbooks between them |
| Roles | view builtin roles and the permissions they bundle; create and manage custom roles |
| Role Assignments | grant and revoke roles, grouped by Workspace |
The Role Assignments tab is the aggregate view: every assignment in the vendor, grouped by the Workspace it applies to, with the Default Workspace and its All Internal Users assignment first. Implicit assignments appear read-only, labelled with whatever produced them, so a Super Admin sees each member's access level without being able to edit it there; access levels change on the project roster. The label comes from the assignment's source_type and source_id columns, holding PROJECT_MEMBERSHIP, PROJECT_OWNERSHIP, or SYSTEM. A SYSTEM row carries the All Super Admins grant. Org creation converges it for the new vendor, best-effort, and POST /api/authz/default-grants/converge repairs it on demand. The Default Workspace assignment for All Internal Users is not implicit, so it renders as an editable row: a Super Admin who deletes it stays deleted.
Nothing else in the product exposes authorization configuration. The roster's access-level control is membership management, not authorization configuration: it edits the membership, and the system derives the assignment.
Tenancy
The vendor is the tenant. Both subsystems are multi-tenant, data-separated by vendor_id, with three global exceptions: the permission catalog, builtin roles, and permission boundaries.
The special OnRamp vendor is the platform tenant — often vendor_id = 0, but not always; it depends on the environment. This work introduces a codified platform-tenant designation to resolve it, replacing today's role-code and email-domain heuristics; it is never a hardcoded id. Restricted permissions and restricted roles are assignable only there.
Data model
All new tables follow the standard model (integer PK, public uuid, audit columns). Public identifiers in the API are UUIDs.
| Table | Columns (beyond standard) | Notes |
|---|---|---|
or_permissions | name (unique), description, effective_scope, restricted | global; seeded from the in-code catalog at deploy; carries the reserved * row, the one name exempt from the format rule |
or_roles | vendor_id (NULL = builtin), name, description, restricted | builtin name uniqueness global; custom unique per vendor, checked against builtin names at the service layer |
or_role_permissions | role_id, permission_id | join table; All Permissions is one row pointing at * |
or_role_assignments | vendor_id, principal_type, principal_uuid, role_id, scope_workspace_uuid, scope_resource_type, scope_resource_id, implicit, source_type, source_id | scope is stored decomposed and rendered as a string, never stored as one; a builtin group principal carries that group's reserved constant UUID, defined in code beside its resolver; source_* ties an implicit assignment to the row that produced it so lifecycle is mechanical, or carries SYSTEM and a sentinel id for a system-owned grant that no relationship produced |
or_workspaces | vendor_id, name, is_default | one is_default per vendor; a trigger on or_vendors creates it with the vendor; a trigger trims name on write |
Resource containment: workspace_id FK added to or_projects and or_playbook_series (a playbook's Workspace is stable across its versions), backfilled to the Default Workspace, then made NOT NULL — standard expand-and-contract.
Member access levels: nullable role_id FK to or_roles added to or_project_user_mappings. NULL means the Project Editor default. No backfill — existing memberships stay on the default, so shipping the column changes no one's access.
A trigger on or_workspaces inserts the All Internal Users assignment as soon as a vendor's Default Workspace row appears, for the same reason the workspace itself is created by trigger: vendors are created from dozens of call sites, including raw-SQL seed scripts and tests with no factory, so an application-layer hook leaves some vendors with no workspace and no grant. The All Super Admins global assignment is system-owned and arrives instead from ensure_system_grants, an on-demand repair routine.
Three triggers carry the containment rules, so they hold for every writer, including the integration Lambdas that reach this database directly. resolve_project_workspace and resolve_playbook_series_workspace fill workspace_id with the vendor's Default Workspace when a writer leaves it NULL, and reject a workspace belonging to another vendor or already deleted. A caller that names a Workspace keeps it, which is how Phase 4 passes an explicit choice. protect_workspace_contents refuses to delete a Workspace, hard or soft, while projects or playbook series still point at it: deleting a Workspace means moving its contents to another one first, and the Default Workspace is always available as that destination because it cannot be deleted. A soft delete counts live contents, which are the rows it would strand. A hard delete counts every referencing row, deleted ones included, because that is what the foreign key counts — so the operator reads a message naming the fix rather than a foreign-key violation.
Enforcement
Permissions are the end state for every enforcement site: no site checks a role or a seat license type. Enforcement moves domain by domain, so the system spends time in a hybrid state where permissions gate the operations already onboarded and the legacy requires_access_level role list gates the rest.
- Flask — an onboarded route or controller checks the permission through the authz interface in-process (same semantics as
/authz/check, no network call). Onboarding a domain replaces itsrequires_access_levelsites with permission checks at the same sites, and inventories the inline role-code checks in its routes, controllers, services, and helpers to replace the same way. - External components — lambda functions and other services call the HTTP authz API.
The two models coexist safely because they agree by construction inside the Default Workspace: All Permissions clamped by the boundary reproduces the reach the seat license type already had. They diverge only where a Super Admin has deliberately created a Workspace or a grant that says something different.
Data scoping. Reporting and data access are scoped to the instances available to the caller through the authorization system. List endpoints, Insights, dashboards, exports, and search constrain their queries to the workspaces and instances where the caller holds the relevant read permission. A user with access to Workspaces A and B but not C sees only data from projects in A and B on the Insights page. A surface adopts this scoping when its domain is onboarded, not before.
Rollout
Phase one ships the smallest set of objects that segregates access: Workspaces containing Projects and Playbooks, and coarse permissions on both.
| Object | Phase one introduces |
|---|---|
| Permissions | the granular projects.* set (projects.project.read, projects.project.update, projects.tasks.update, projects.members.manage, and the other per-resource project verbs), library.modules.create, library.tasks.create, playbooks.playbook.read, playbooks.playbook.write, workspaces.workspace.read, workspaces.workspace.write, the auth.* permissions, and * |
| Builtin roles | Project Viewer, Project Editor, Playbook Viewer, Playbook Editor, All Permissions |
| Builtin groups | All Internal Users, All Super Admins |
| Containment | workspace_id on Projects and Playbook series, backfilled to the Default Workspace |
| UI | Access Control, under Settings |
The table lists what this phase adds, not the whole catalog as of this phase. A domain onboarded earlier keeps the permissions it already carries: data.export.read, data.export.write, data.import.read, and data.import.write are live catalog entries with ceiling rows, and they are not part of the set above. Every other permission, role, and instance type arrives with the domain that needs it. A domain's verbs stay coarse — read and write — until a permission boundary or role needs one mutation and not another; the project domain ships granular because the seat-license boundaries distinguish its mutations.
The dual-check machinery — running a permission check beside the legacy role check and recording both — is already built and stays available, but nothing new is built on it until a migration needs the comparison. Phase-one behavior preservation does not depend on it: the Default Workspace assignment reproduces existing reach structurally.
The Workspace migration is invisible to users. Existing Projects and Playbooks land in the Default Workspace, the All Internal Users assignment grants what the seat license type already allowed, and no one's access changes.
Audit & logging
Audit log. All RBAC changes are audited: role created/updated/deleted, role assignment created/removed — implicit assignments and deletion of the All Internal Users assignment included — workspace created/updated/deleted, and instance moves between workspaces, with who performed the action, when, and the outcome. The audit log is gated by auth.audit.read, so Super Admins and OnRamp admins read it. It is not available to other users, and it is not Activity History.
Decision log. Every enforcement decision is logged (stdout/err), including the dual-check comparison records during rollout.
Deferred
- Vendor-managed user groups: stored membership, group CRUD, and assignment of a role to a vendor-authored group. The two builtin groups ship and resolve from attributes; no interface manages groups.
SERVICE_ACCOUNTprincipals. The assignment model carries the type from day one. Service accounts hold no seat license type, so they evaluate against the default-deny boundary until a boundary-binding mechanism for them is specified.- A role distinct from Project Editor for project ownership. Owners hold a fixed Project Editor assignment; a dedicated owner role waits for demand.
- The access-level override on interfaces that create memberships through automation: CRM-driven project creation, workflows, and playbook or module copying. Those memberships take the Project Editor default; offering the override there is a later feature.
- An effective-access view on the project roster: the roster shows the membership's access level only, while a member's actual reach is the union of that level with workspace and explicit grants (a Super Admin can grant a role across a workspace with no membership involved, and the roster does not reflect it).
- Database-backed, runtime-editable permission boundaries.
- Grants to external users (customer users,
User.is_customer_user). Anexternal_usersattribute on permissions and roles — marking what may be granted to customer principals — and every rule that depends on it. The initial system grants only to vendor principals. - Instance types beyond
projectsandplaybooks, and Workspace containment of further object types (library modules, automations, Workflows, Views).