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_idis the tenant boundary (app/api/vendor/or_models.py:55). Thevendor_id = 0self-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.vendorFK →or_vendors.id(app/api/account/models.py:44);Account.idis the customer-owner identity (app/api/account/models.py:37). - Tenant isolation is enforced implicitly — via ORM relationships and per-query
vendor_idfilters (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
usertable for everyone (app/api/user/models.py:33), distinguished byis_customer_user: bool(app/api/user/models.py:70). - Vendor users → one
vendorFK. Customer users → one or more accounts viaCustomerUserAccountMapping(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 user —
User.user_roleFK →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 indevtools/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
- Platform:
- Role codes are hardcoded strings duplicated in
app/api/utils/constants/roles_and_realms.pyandapp/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 withis_internal/is_externalandis_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_state→new_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 overallmax_userscap and the per-rolelicense_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, andonramp.ususers (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 againstcurrent_user.user_role_rel.role_code.- No hierarchy / ranking —
ONRAMP_ADMINdoes not auto-satisfy anOWNER-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_codechecks inside handlers/services, and the agent SQL rewriter.
5. Cross-tenant / platform-admin access
- Every
ONRAMP_ADMINhas standing access to every customer of every vendor. Cross-vendor isolation is explicitly bypassed forONRAMP_ADMIN(e.g.app/api/vendor/routes.py:vendor_id != current_user.vendor and not current_user.is_onramp_admin()). ONRAMP_ADMINis 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_userFK →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_USER→DENIEDon 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 byvendor_id, soft-deleted via a booleanarchived. Audit columns are non-standard (created_dts/updated_dts, notcreated_at/modified_at). - Owner module:
app/api/object_access/(model, service, helper, controller, routes, DTOs, mappers); blueprint registered atapp/api/__init__.py:229. - Actions (
or_object_action.action_code):VIEW,EDIT,OWNER(FAVORITEis 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_permissiondecorator (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 storeobjectAccess.store.js; API clientapi/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
OWNERaction, 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 columnsInternal[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.
-- 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.
FAVORITEreturned 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,:141mark 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>— thereadno-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_leveleither, 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 thenew_owner_user_idfrom 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_internalhide 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_publishedgate (app/api/portal/services.py:38-39).- Access check: the customer user needs an
ORProjectUserMappingwhosecustomer_accountis in their account list, and the project must be published (app/api/portal/services.py:33-67). - Customer roles exist (
VendorRole.is_internalsplit; assignment viaunaffiliated_customer_user_role_assignmentsat 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.
Standing blanket cross-tenant admin access. Every
ONRAMP_ADMINcan 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.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.pyvs.project/services.py). Impact: drift between paths; this is the structural cause of the "hundreds of IDOR" class.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.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.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.
Magic strings & duplication. Role codes hardcoded in two files. Impact: drift risk; violates the repo's no-magic-values convention.
Latent / dead subsystems.
VendorRole,ORProjectUserMapping.user_role, andUserRoleRealmare declared but unused or only partly wired. Impact: confusion and false affordances; hard to tell intended from real behavior.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.
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).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.ONRAMP_ADMINremoval 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.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.
"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_permissiondecorator is Resource-only andreadis a no-op; View/Lane/Ramp detail reads and writes are vendor-scoped only; the/api/object_access/*sharing API requires onlylogin_required, sotransfer_ownershipis 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.The role object is overloaded as permission-set + identity + billable seat SKU. A single
user_rolesimultaneously names a permission identity, gates enforcement, and is the unit customers are sold — per-role seat caps (or_user_role_license_limit) and an overallmax_userscap 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 onuser_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_roleand the taskrolesarray 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
rampcullisscanner), 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.pyfiltersis_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 users | Vendors | Internal users (Σ) | % of vendors all-OWNER | OWNER share of users | Median projects | Median portal users | Role mix OWN/CRE/CON/COL |
|---|---|---|---|---|---|---|---|
| 1 | 9 | 9 | 100% | 100% | 0 | 1 | 100 / 0 / 0 / 0 |
| 2–5 | 49 | 177 | 78% | 91% | 4 | 2 | 91 / 6 / 3 / 1 |
| 6–20 | 70 | 798 | 21% | 50% | 68 | 95 | 50 / 24 / 11 / 14 |
| 21–100 | 26 | 853 | 0% | 21% | 333 | 300 | 21 / 48 / 18 / 12 |
| 100+ | 3 | 666 | 0% | 3% | 501 | 350 | 3 / 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_usersset (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 aNULLlimit, i.e. tracked but uncapped); 13 at/over. Since the billing plan is uniform (Early ACCESSfor ~all),max_usersis 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 = uncappedworking, 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_roleis set on 0 of 257,071 memberships (0.0%). It can be redesigned with zero no-regression risk (deficiency #7). vendor_roledefinitions 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_ADMINis 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.