Skip to content

OnRamp Authorization — Current State & Deficiencies

Status: Draft — companion to docs/proposals/rbac-authorization-model.md (the RBAC / authz RFC) Scope: the current authorization model and its deficiencies. The future design lives in the RFC; this document is the grounded baseline it argues against.

How this was grounded

Grounded from the source tree via a parallel code-exploration pass on 2026-06-25. Claims carry path:line citations. Items that could not be confirmed from code are listed in the final section and marked. This document is the canonical current-state reference; the RFC carries a shorter inline summary.


1. Tenancy model

  • The vendor is the tenant. vendor_id is the tenant boundary (app/api/vendor/or_models.py:55). The vendor_id = 0 self-tenant exists in stage/prod and must be treated as a real tenant.
  • An Account is a customer within a vendor — a sub-tenant grouping. Account.vendor FK → or_vendors.id (app/api/account/models.py:44); Account.id is the customer-owner identity (app/api/account/models.py:37).
  • Tenant isolation is enforced implicitly — via ORM relationships and per-query vendor_id filters (e.g. app/api/project/helpers.py:179-194), not by a single data-layer guarantee. (The agent SQL-tool path is the exception — it injects scope at rewrite time.)

2. Identity & user classes

  • One user table for everyone (app/api/user/models.py:33), distinguished by is_customer_user: bool (app/api/user/models.py:70).
  • Vendor users → one vendor FK. Customer users → one or more accounts via CustomerUserAccountMapping (user_id, customer_account_id, vendor_id, app/api/user/models.py:323-349).
  • Identity at the model level is email + bcrypt password + UUID (app/api/user/models.py:47-61). (Session vs. JWT mechanics are a Part B / architecture concern.)

3. Role model

  • A single global role per userUser.user_role FK → user_role.id (app/api/user/models.py:68). Not per-project, not multi-role.
  • 10 role codes across 3 realms (UserRoleRealm: Platform / Vendor / Customer), seeded in devtools/db/seed_data.sql:4162-4180:
    • Platform: ONRAMP_ADMIN
    • Vendor: OWNER (Super Admin), CREATOR, CONTRIBUTOR, COLLABORATOR, INTEGRATOR, API_USER, INTEGRATION_USER
    • Customer: CUSTOMER_ADMIN, CUSTOMER_EMPLOYEE
  • Role codes are hardcoded strings duplicated in app/api/utils/constants/roles_and_realms.py and app/api/utils/decorators.py.
  • Defaults: vendor user → CREATOR; customer user → CUSTOMER_EMPLOYEE (app/api/user/services.py:270-273).
  • Realm is declared but unused in enforcement.

Two latent role systems (mostly unused)

  • VendorRole (app/api/vendor/or_models.py:200-226) — per-vendor custom roles with is_internal / is_external and is_template_role. The scaffolding for "vendor admins author custom roles" partly exists but is not wired into the active enforcement path.
  • ORProjectUserMapping.user_role (app/api/project_user/or_models.py:34, FK → vendor_role.id) — a per-project role slot, nullable, largely unenforced.

Roles are billable seat licenses (a third hat the role object wears)

The user_role is not only a permission identity — it is also the unit a customer is sold. A vendor buys a number of licenses for users at a given role, and this is enforced in code, not merely on a contract:

  • or_user_role_license_limit (app/api/admin/models/or_user_role_license_limit.py) — a (vendor_id, user_role_id, license_limit) row: a per-vendor, per-role cap on how many users may hold that role.
  • ORVendor.max_users (app/api/vendor/or_models.py:85) — an overall per-vendor seat cap, independent of the per-role caps.
  • or_user_role_license_activity (app/api/admin/models/or_user_role_license_activity.py) — an audit log of limit changes (previous_statenew_state, modified_by/modified_at).
  • Enforcement is has_exceeded_role_license_limit_or_seat_count(vendor, role_code) (app/api/admin/services/svc_user_role_license.py:30), which checks both the overall max_users cap and the per-role license_limit. It is called at user-create / role-assignment time (app/api/admin/controllers/user_controller.py:209); the rejection message — "reached their User License Limit. Please increase the number of contracted users" — is the commercial framing made explicit.
  • Seat counting excludes ONRAMP_ADMIN, API_USER, INTEGRATION_USER, and onramp.us users (svc_user_role_license.py:56-104) — only vendor human seats are licensed.
  • The caps are provisioned through the platform-admin surface (app/api/admin/...: set_max_users_for_org, the per-role limit controller), i.e. OnRamp sets them to match the sales contract — not vendor self-serve.

Shape of the enforcement: today the seat model is an assignment-time count ("no more than N users may hold role R"), not a request-time permission ceiling. Once a user holds a role, nothing re-checks the seat entitlement against what that user can actually do per request — including capability gained through the per-object sharing path (§7), which is independent of role.

Why this matters for the redesign: the user_role object is overloaded — it is simultaneously a permission-set, a role identity, and a billable seat SKU. The RFC plans to split the permission-set out of the role (granular permissions) and to let vendor admins author custom roles. Both moves touch the seat axis: seat counting is keyed on user_role_id, so any new role identity (a custom role) has no defined license bucket, and free-form roles would let a tenant mint capability they did not purchase.

4. Enforcement mechanism (the gating semantics)

  • @requires_access_level([...]) (app/api/utils/decorators.py:33-49) is an exact-match allowlist against current_user.user_role_rel.role_code.
  • No hierarchy / rankingONRAMP_ADMIN does not auto-satisfy an OWNER-gated endpoint; every endpoint must name every allowed role.
  • ~30 distinct gated operations enumerated from decorator usage, with the allowed-role set varying inconsistently endpoint-to-endpoint. There is no granular-permission axis — roles are composited directly into per-endpoint allowlists.
  • Authorization is scattered across at least three places: route decorators, inline role_code checks inside handlers/services, and the agent SQL rewriter.

5. Cross-tenant / platform-admin access

  • Every ONRAMP_ADMIN has standing access to every customer of every vendor. Cross-vendor isolation is explicitly bypassed for ONRAMP_ADMIN (e.g. app/api/vendor/routes.py: vendor_id != current_user.vendor and not current_user.is_onramp_admin()).
  • ONRAMP_ADMIN is special-cased at 13+ sites (cross-vendor reads, implicit feature access, user-management, portal config, SSO strategy, etc.).
  • The access is blanket, standing, and untracked — no justification, no time bound, no impersonation audit, no customer consent control.

6. Membership & association

  • or_project_user_mappings (app/api/project_user/or_models.py:17-60) is the core membership abstraction: (or_project_id, or_user_id, customer_account, is_customer, user_role?, deactivated, shared_by).
  • Project owner = a single column project_owner_user (app/api/project/or_models.py:154) — not a role.
  • Task assignment targets a membership row, not a user: ORTask.assigned_user FK → or_project_user_mappings.id (app/api/task/or_models.py:87-89) — so you can only assign existing project members.

The membership × role rule (verified)

  • Lower role (CONTRIBUTOR)PROJECT_MEMBER_ONLY: sees only projects it is a member of.
  • Higher roles (OWNER, CREATOR) → no filter: see all projects in the vendor regardless of membership.
  • API_USERDENIED on projects.
  • Declared on the models (app/api/project/or_models.py:100-105, app/api/task/or_models.py:39-44); enforced in the agent SQL rewriter (app/api/agent/services/sql_rewriter/scope_injector.py:282-298). The REST path re-implements an owner-or-member filter ad hoc (app/api/project/services.py:1533-1587). The same rule lives in more than one place.

7. Per-object grants — a LIVE second authorization paradigm

Grounded 2026-06-25. Verdict: LIVE (not vestigial/experimental). OnRamp already runs a working per-object Owner / Editor / Viewer sharing model for a subset of objects — in parallel with the role+membership model used for Projects/Tasks. This is a major finding: two authorization paradigms already coexist in the product.

  • Store: or_object_user_action_mapping (app/api/object_access/models/or_object_user_action_mapping.py:19-41) — rows of (user_id, object_id, object_type_id, object_action_id), scoped by vendor_id, soft-deleted via a boolean archived. Audit columns are non-standard (created_dts / updated_dts, not created_at/modified_at).
  • Owner module: app/api/object_access/ (model, service, helper, controller, routes, DTOs, mappers); blueprint registered at app/api/__init__.py:229.
  • Actions (or_object_action.action_code): VIEW, EDIT, OWNER (FAVORITE is seeded but unused). Composed in code into groups (object_access_service.py:46): Owner (view+edit+own), Editor (view+edit), Viewer (back-compat, hidden from the share UI by default).
  • Object types actually shared: RESOURCE, VIEW, LANE, RAMP (Workflows). Projects/Tasks are not in this system — they use role+ membership (§6).
  • Backed features: object sharing + ownership transfer + "claim ownership" for those four object types, plus the @check_resource_permission decorator (app/api/utils/decorators.py:221) that reads an explicit EDIT/OWNER mapping.
  • Real UI: an org-admin share dialog (pages/lanes/components/objectMemberAccessDialog/ObjectMemberAccessDialog.vue) mounted on Resource / Workflow / Lane / View detail pages; Pinia store objectAccess.store.js; API client api/objectAccess/objectAccess.js. The customer portal has no object-access UI (read-only consumer).
  • Trend — expanding, not deprecating: the object-access model is actively growing, not being phased out — it carries an OWNER action, ownership transfer/claim, and a backfill across existing views/ramps/lanes.
  • Limits: no group dimension, no inheritance (no "owner of project ⇒ all tasks"), no deny rules. Individual-user grants only.
  • Not exposed to the read-only SQL agent — no PortalAccessible, no __sql_tool_policy__, all columns Internal[T].

Prod last-used query (read-only)

Column names verified from the model (created_dts NOT NULL; updated_dts nullable; soft-delete is boolean archived; no archived_at/deleted_at). OWNER rows dated near 2025-10-14 are the migration backfill; created_dts after that date (and any RESOURCE OWNER rows, which the backfill did not seed) indicate genuine in-product sharing.

sql
-- A. Top-line liveness
SELECT
    COUNT(*)                                 AS total_rows,
    COUNT(*) FILTER (WHERE archived = false) AS active_rows,
    COUNT(*) FILTER (WHERE archived = true)  AS archived_rows,
    MAX(created_dts)                         AS last_created,
    MAX(updated_dts)                         AS last_updated,
    COUNT(DISTINCT user_id)                  AS distinct_grantee_users,
    COUNT(DISTINCT vendor_id)                AS distinct_vendors
FROM or_object_user_action_mapping;

-- B. By object type (active grants)
SELECT ot.type_code, COUNT(*) AS active_grants,
       COUNT(DISTINCT m.object_id) AS distinct_objects, MAX(m.created_dts) AS last_created
FROM or_object_user_action_mapping m
JOIN or_object_type ot ON ot.id = m.object_type_id
WHERE m.archived = false
GROUP BY ot.type_code ORDER BY active_grants DESC;

-- C. By action (active grants)
SELECT a.action_code, a.display_name, COUNT(*) AS active_grants, MAX(m.created_dts) AS last_created
FROM or_object_user_action_mapping m
JOIN or_object_action a ON a.id = m.object_action_id
WHERE m.archived = false
GROUP BY a.action_code, a.display_name ORDER BY active_grants DESC;

-- D. Monthly write recency (last 12 months)
SELECT date_trunc('month', created_dts) AS month, COUNT(*) AS rows_created
FROM or_object_user_action_mapping
WHERE created_dts >= now() - interval '12 months'
GROUP BY 1 ORDER BY 1;

Prod usage (run 2026-06-25)

The feature is heavily used and actively written, across most of the tenant base:

  • 34,700 active grants (35,270 total; only 570 archived). Last write 2026-06-25 — same-day.
  • 175 distinct vendors and 1,009 distinct grantee users — broad adoption, not a few power tenants.
  • By object type (active): RESOURCE 17,554 (5,834 objects) · VIEW 12,365 (3,490) · RAMP 2,748 (760) · LANE 2,033 (337). All four live; each written within the last ~2 weeks.
  • By action (active): View 12,607 · Edit 12,160 · Owner 9,933. The ownership model is in real use. FAVORITE returned zero rows — confirmed seeded-but-unused.
  • RESOURCE OWNER rows exist and are current (RESOURCE last-created today), even though the 2025-10 backfill seeded only VIEW/RAMP/LANE — i.e. RESOURCE ownership is being set in-product, not by migration.
  • Write trend is sharply up: ~56/mo (2025-06) → ~1–3k/mo through early 2026 → 13,667 in May 2026 (a >4× spike over April) → 2,975 month-to-date June.

Flagged anomaly (not yet explained): the May 2026 spike (13,667 writes) is far above trend. Could be a bulk-share action, a feature rollout, or a backfill. Worth identifying before drawing conclusions about organic growth — but even excluding it, adoption is broad and current.

Consequence for the RFC: this paradigm cannot be treated as legacy cruft to ignore or casually replace. Any RBAC redesign must explicitly decide how it relates to the role+membership model — migrate it, absorb it, or keep it as a deliberate overlay.

7.1 How object-access is actually enforced (and where it isn't)

Grounded 2026-06-25 by a two-agent enforcement trace. The grant table exists and the UI honors it, but the server enforces it inconsistently. The pattern: list endpoints are fail-closed; detail-GET, mutation, and sharing endpoints are largely fail-open — i.e. the backend trusts the frontend to hide what the user shouldn't touch. The UI never links to a hidden object, so the gaps are invisible until someone calls the API directly.

The decorator is narrower than its name suggests. It is require_resource_permission(permission_type=...) (app/api/utils/decorators.py:136-249), hard-wired to ORResource and keyed off a route param literally named uuid. So it protects only the Resource domain — VIEW/LANE/RAMP get zero coverage from it. Within Resource: read is an explicit no-op (:212-215, pass # Allow access); write/delete pass for the creator OR any EDIT-or-OWNER grantee (:218-233) — vendor boundary checked with == (handles vendor_id = 0). Because EDIT satisfies it, a mere Editor can re-share a resource and change others' roles (resource_controller.py:645-769).

Reads. Lists (my-…, shared-with-me, nav-list) iterate + verify_object_access + skip-on-fail → fail-closed (Views/Lanes/Ramps). But single-object detail GETs are fail-open:

  • GET /api/lanes/<uuid> (lanes_controller.py:64-73) and its children (/inbounds, activity) — vendor-only, no grant check (dangling "block access here" comments at :66, :141 mark the unfinished checks).
  • GET /ramps/<uuid>, /workflow/<wf_uuid>, /executions[/<id>], /activity (ramps_controller.py:117, ramps_workflow_controller.py:14, ramps_executions_controller.py:15,31) — vendor-only; exposes the full workflow definition and execution history (potential CRM/PII) of an unshared workflow.
  • GET /api/resources/<uuid> — the read no-op; plus the library list/search (resource_service.py:87-102, :804-807) return all vendor resources regardless of grant (Resource reads are fail-open by design — but the model's own "restricted by default" comment, or_resource.py:96-97, contradicts the query layer).
  • These detail GETs carry no requires_access_level either, so they're reachable even by roles the lists exclude (CONTRIBUTOR, API_USER).

Mutations. Only Resource enforces per-object writes (via the decorator). View update/delete (delete is a hard delete), Lane update/archive, and Ramp update/archive/pause/resume are vendor-scoped only — no owner/edit check (update_view/delete_view; update_lane/archive_lane — the lane lookup get_lane_by_uuid, lanes_helper.py:81, isn't even vendor-scoped → possible cross-tenant; RampsService.update_ramp et al., ramps_service.py:115+). A non-owner can pause or archive another user's production CRM workflow with its UUID.

The sharing API is the crown-jewel gap — privilege escalation, confirmed. The entire /api/object_access/* blueprint is decorated with login_required only — no owner check anywhere in the service. Any authenticated user can, via direct calls:

  • Grant themselves Owner on any object id (create_object_user_action_group_mapping, object_access_service.py:214).
  • Remove anyone's access (archive_object_access, :482 — also not vendor-scoped).
  • transfer_ownership (:554) — a one-request ownership takeover: it never verifies the caller owns the object; it archives the real owner's OWNER mapping, demotes them to Editor, and grants OWNER to the new_owner_user_id from the request body.

Safe by contrast: create-time ownership is written server-side from the session in all four domains — not spoofable via payload. (Views are created with an EDITOR, not OWNER, mapping — so a view often has no owner anchor at all.) And there is no higher-role read bypass: verify_object_access has no admin escape hatch, so an ONRAMP_ADMIN sees only their own grants + public objects in lists — yet can still read a private object via the unprotected detail GET. That is the inverse of the project-membership model (where high roles see everything by design) — another inconsistency.

Verdict: the object-access ACL creates the appearance of enforcement through list filtering, while detail reads, mutations, and the sharing API are guarded by little or nothing server-side. These are confirmed IDOR / broken object-level authorization gaps, exploitable within a tenant by any authenticated user (and, for lanes, potentially across tenants). They are live defects, not just model-design concerns — several likely warrant their own security tickets independent of this RFC.

8. Portal / customer visibility (NOT all-or-nothing)

Once a customer user can see a project, visibility is filtered by several controls — refuting the "all-or-nothing" assumption:

  • Per-task internal flags task_is_internal / is_internal hide vendor-only tasks (app/api/portal/portal_task_flow_service.py:108-109).
  • hide_vendor_task_details_from_customers (project flag, default true) strips vendor task detail (app/api/project/or_models.py:176, app/api/portal/services.py:107-120).
  • Per-task assignee restrictions (app/api/portal/task_assignment_policy.py:45-71).
  • project_published gate (app/api/portal/services.py:38-39).
  • Access check: the customer user needs an ORProjectUserMapping whose customer_account is in their account list, and the project must be published (app/api/portal/services.py:33-67).
  • Customer roles exist (VendorRole.is_internal split; assignment via unaffiliated_customer_user_role_assignments at project create) — but role-based visibility for customer roles is not yet enforced (schema + invite flow present; enforcement gap).

9. Resource / object hierarchy

Vendor (or_vendors)
└─ Account / Customer (account)              account.vendor → or_vendors.id
   └─ Project (or_projects)                  account FK; project_owner_user
      ├─ Task (or_tasks)                     project_id; assigned_user → mapping
      │  └─ Task Step (or_task_steps)        task_id; parent_step_id (branching)
      └─ Data Field Value (or_data_field_values)  polymorphic (object_type+object_id)
Vendor-owned design-time / config objects (not under a single project):
  Playbook → Module → Task (templates; is_library_*, is_linked snapshots)
  Ramp → Ramp Workflow → Ramp Execution (→ optional project / account)
  View (is_library / is_template), Data Field (definition), Tag (polymorphic)

Notes: no ORSubtask (steps + dependencies serve that role); data fields & tags are polymorphic over or_object_type; library reuse is within-vendor, never cross-vendor.


10. Deficiencies (consolidated)

The point of this document. Each entry: the deficiency, the evidence, and why it matters.

  1. Standing blanket cross-tenant admin access. Every ONRAMP_ADMIN can reach every customer of every vendor, untracked (app/api/vendor/routes.py + 13 bypass sites). Impact: maximal blast radius on the most powerful role; no isolation, justification, time bound, or audit where it matters most.

  2. No single source of truth for authorization. The same access rule is implemented in route decorators, inline handler checks, and the agent SQL rewriter (e.g. the membership rule in scope_injector.py vs. project/services.py). Impact: drift between paths; this is the structural cause of the "hundreds of IDOR" class.

  3. Enforcement is per-endpoint exact-match allowlisting with no hierarchy. Every endpoint re-lists every allowed role (decorators.py:33-49). Impact: inconsistency and easy-to-forget guards (→ IDOR); adding/changing a role touches every endpoint.

  4. Single global role per user. No per-project / per-resource role (user.user_role). Impact: cannot express "Editor on Project A, Viewer on Project B"; forces coarse roles plus the membership workaround.

  5. Role capabilities are undiscoverable. No permission catalog; what a role can do is implicit in scattered allowlists. Impact: the original customer/internal pain — "what can a CONTRIBUTOR do?" answerable only by trial and error.

  6. Magic strings & duplication. Role codes hardcoded in two files. Impact: drift risk; violates the repo's no-magic-values convention.

  7. Latent / dead subsystems. VendorRole, ORProjectUserMapping.user_role, and UserRoleRealm are declared but unused or only partly wired. Impact: confusion and false affordances; hard to tell intended from real behavior.

  8. Customer-role infrastructure without enforcement. Customer roles can be assigned but role-based visibility for them is not enforced. Impact: a false sense of granular customer control.

  9. Two parallel authorization paradigms coexist. Projects/Tasks/Portal use role + membership (§6); Resources/Views/Lanes/Workflows use a live per-object Owner/Editor/Viewer ACL (§7, or_object_user_action_mapping + a mounted share dialog). Impact: two different mental models, two share UIs, two enforcement paths — the same "incompatible-paradigms" tax seen in Monday.com. The ACL side also has no groups, inheritance, or deny, and is org-admin-only (not available to portal/customer users).

  10. Tenant isolation is enforced implicitly, not structurally. It relies on every query remembering to filter vendor_id. Impact: any missed filter is a cross-tenant leak — the IDOR class, by construction.

  11. ONRAMP_ADMIN removal is a cross-cutting refactor. 13+ explicit bypass sites. Impact: the most privileged path is also the least uniformly controlled; replacing it requires a per-site permission mapping.

  12. No impersonation / on-behalf-of audit framework. Staff access to customer data leaves no who/why/what trail. Impact: no accountability or customer visibility for support access.

  13. "Trust the frontend" — confirmed IDOR / broken object-level authorization (HIGH severity). The object-access ACL (§7.1) is enforced on list endpoints but largely not on detail-GET, mutation, or sharing endpoints. The require_resource_permission decorator is Resource-only and read is a no-op; View/Lane/Ramp detail reads and writes are vendor-scoped only; the /api/object_access/* sharing API requires only login_required, so transfer_ownership is a one-request ownership takeover and any user can self-grant Owner on any object. Lane lookups aren't even vendor-scoped (possible cross-tenant). Impact: any authenticated user can read/edit/seize objects the UI hid from them — these are live exploitable defects, not just design debt. Likely warrant security tickets independent of the RFC.

  14. The role object is overloaded as permission-set + identity + billable seat SKU. A single user_role simultaneously names a permission identity, gates enforcement, and is the unit customers are sold — per-role seat caps (or_user_role_license_limit) and an overall max_users cap are enforced at assignment time (§3, Roles are billable seat licenses). Impact: the redesign cannot treat "redefine roles as permission sets" or "vendor-authored custom roles" as a pure technical refactor — both move a commercial axis. Seat counting is keyed on user_role_id, so a new custom-role identity has no defined license bucket, and free-form roles/groups/shares can grant capability a tenant never purchased unless an entitlement ceiling is added.


11. What could not be determined from code

  • Full inventory of inline (non-decorator) role checks and any unguarded operations — needs a code-wide sweep.
  • Whether ORProjectUserMapping.user_role and the task roles array are enforced anywhere in the REST path.
  • Per-section / per-milestone and per-data-field customer visibility (no ORM evidence; may live at the endpoint layer).
  • Precise quantification of the "hundreds of IDOR" claim — a security-finding pull (the rampcullis scanner), not a model question.
  • The May 2026 write spike (13,667) in object-access grants is unexplained — bulk action, feature rollout, or backfill? Identify before treating it as organic growth.
  • Real-world exploitation of the §7.1 gaps (logs of direct API calls bypassing the UI) — not determinable from code; would need access-log review.
  • Whether portal / customer roles are seat-licensed — the seat-count logic excludes customer users (svc_user_role_license.py filters is_customer_user), so portal roles appear to sit outside the per-role license model, but the commercial intent for customer seats was not determinable from code.

Answered by the prod measurement (§7 Prod usage): volume/last-used (34.7k active, written same-day); RESOURCE OWNER rows exist and are set in-product; OBJ_ACTION_FAVORITE has zero rows (seeded-but-unused, confirmed). Answered by the prod role/seat measurement (§12): the per-project role slot is set on 0 of 257,071 memberships (the dead-subsystem question); customer roles are uniform (all CUSTOMER_EMPLOYEE, no CUSTOMER_ADMIN anywhere).


12. Current usage by the numbers (prod, 2026-06-30)

Measured by the read-only query set devtools/analysis/role_usage_analysis.sql, summarized by devtools/analysis/summarize_role_usage.py. All cuts resolve roles and object types by stable code, so they hold across environments. The raw extract is customer data and is not committed. These numbers are design signal: they show how the authorization features are actually used, which sharpens several deficiencies above from "true in principle" to "true at this scale." Read by segment, and on live customers (churned/disabled vendors excluded) — both matter, as the next subsection shows.

Active vs. inactive vendors — most vendors are churned

269 of 430 vendors (63%) are disabled (vendor_disabled = true) — dead / churned accounts. Only 161 are live, of which 157 have internal users. Including dead vendors badly skews the picture, because churn is concentrated in small orgs: filtering to live customers drops the 2–5-user band from 175 vendors to 49, while the 100+ band is unchanged (3 → 3) — large accounts essentially never churn. Every cut below is live customers only unless noted.

Billing plan is not a usable "paying" signal today. 428 of 430 vendors sit on a single Early ACCESS plan (1 Basic, 1 Premier), so commercial tier cannot be read from the plan — "paying / active" is operationally just vendor_disabled = false. The seat axis that does carry signal is max_users (below), not the plan. (If paid tiers are introduced later, re-segment by plan.)

Role assignment — usage scales sharply with org size (live customers)

A global average is actively misleading: tiny tenants dominate the vendor count, so an aggregate makes roles look unused when the larger accounts use them heavily. Segmented by internal-user count, live customers only:

Internal usersVendorsInternal users (Σ)% of vendors all-OWNEROWNER share of usersMedian projectsMedian portal usersRole mix OWN/CRE/CON/COL
199100%100%01100 / 0 / 0 / 0
2–54917778%91%4291 / 6 / 3 / 1
6–207079821%50%689550 / 24 / 11 / 14
21–100268530%21%33330021 / 48 / 18 / 12
100+36660%3%5013503 / 27 / 40 / 29

(For contrast, including churned vendors: 377 vendors / 3,740 internal users — same shape, just a fatter small-org tail.)

  • OWNER's share of users falls monotonically from 100% to 3% as orgs grow. Large accounts actively practice least privilege — few owners, mostly CREATOR / CONTRIBUTOR / COLLABORATOR. Exemplars (all live): Cardinal Health (396 users, 2% owners: 7 OWNER / 70 CREATOR / 253 CONTRIBUTOR / 66 COLLABORATOR), PowerSchool (148 users, 5%: 7 / 85 / 1 / 55), isolved (122 users, 2%).
  • By vendor count the small all-OWNER orgs dominate; by user count the opposite — ~93% of live internal users (2,317 of 2,503) sit in orgs of 6+ that differentiate roles. The all-OWNER pattern is a rational small-org default (nobody to differentiate from), not evidence the role system is unused. The role system is used as RBAC, and well, by the customers large enough to need it — who are also the commercially weighty ones.
  • Where roles aren't differentiated, deficiency #5 (undiscoverable role capabilities) still bites — small orgs default everyone to OWNER partly because it is unclear what the lesser roles do. A tail behavior, not the norm by volume.

Seat licensing — real for a minority, latent for most

  • 108 of 366 vendors (with users or a limit set) have max_users set (30%); the other 70% are uncapped. Of those capped, 44 are at or over their limit. Per-role caps (or_user_role_license_limit) exist for only 37 vendor-role pairs (34 more rows exist with a NULL limit, i.e. tracked but uncapped); 13 at/over. Since the billing plan is uniform (Early ACCESS for ~all), max_users is the only seat signal that actually varies — the entitlement ceiling (RFC D16) lives here, not in the plan.
  • 195 limit-change events across 29 vendors show that a subset actively manages seats. So the entitlement ceiling (RFC D16) is live revenue protection for ~30% of vendors and dormant for the rest — the new model must keep NULL = uncapped working, not assume every vendor has limits.

Latent role systems — quantified, and one is fully dead

  • The per-project role slot is dead: or_project_user_mappings.user_role is set on 0 of 257,071 memberships (0.0%). It can be redesigned with zero no-regression risk (deficiency #7).
  • vendor_role definitions are broadly present but not driving per-project access: 385 vendors hold 2,444 rows (280 external/customer-facing, 0 template) — yet the slot that would assign them per project is empty (above). The custom- role scaffolding is populated but unwired (deficiency #7, deficiency #8).

Portal dwarfs internal — and is single-role

  • 76,732 customer (portal) users across 349 vendors, versus ~3,980 internal users — the portal is ~19× the internal population. Every one is CUSTOMER_EMPLOYEE; CUSTOMER_ADMIN is used nowhere. Portal access is effectively single-role today, despite the schema supporting two — so granular portal permissions (the EA4 ask) start from a clean slate, against the largest user population in the system.

Scale — substantial, not hyperscale

  • 87,188 projects; 257,071 memberships (158,433 internal / 98,638 customer); avg 2.9 members per project. Projects-per-user is heavily skewed: median 4, p90 74, p99 457, max 20,589 — the long tail is the "higher roles / service accounts see everything" pattern showing up as enormous membership fan-out.
  • 35,018 active per-object share grants across 175 vendors (RESOURCE 17,689 · VIEW 12,523 · RAMP 2,773 · LANE 2,033; VIEW/EDIT/OWNER roughly even, 10,025 OWNER) — consistent with §7, confirming the second paradigm is heavily used.

Takeaway for the design — two populations, not one (after excluding the 63% of vendors that are churned, and noting plan tier carries no signal — "active" means vendor_disabled = false). (1) A long tail of small orgs (≤5 internal users, the majority by vendor count) default everyone to OWNER and want zero-configuration simplicity. (2) A smaller set of large, high-value accounts (Cardinal Health, PowerSchool, isolved, …) already run least privilege through roles — by 100+ users only ~3% are owners — and would regress if the role layer were weakened. A successful model must serve both: a simple, open default for the tail and a first-class, meaningful role/permission layer the large accounts depend on. Two further facts hold regardless of segment: (3) the dead per-project role slot and unwired vendor_role rows are safe to replace, but the global-role least-privilege behavior is load-bearing — not safe to drop; (4) the largest single population is the portal (76k single-role users), where granular permissions (EA4) start from a clean slate. Scale is firmly "normal database-as-a-SaaS" (RFC §4.7), so server-side cached resolution is comfortably sufficient.

Internal documentation — gated behind Cloudflare Access.