ACCESS

docs/ACCESS.md · Permissions & roles

← Onboarding

ELAS Access Model — Plan, Access Level, Permissions

Read this before adding any feature. Every screen, API route, and Artie tool must declare how it's gated. The checklist at the bottom is mandatory.

Implementation checklist: BUILDING.md §A–E wires new resources, screens, agent tools, and integrations to this model — including when to add rows to libs/access/permission-catalog.ts.

Access Level — the unified org axis

Access is one ordered Access Level per principal, highest → lowest: Company Admin → Admin → Manager → Member. Access Level is a lens: it is derived from the two stored fields (User.role + User.seatType) by accessLevelFor(role, seat) in libs/access/labels.ts (the single Label_Source). Storage is unchanged — no accessLevel column exists; role (owner/admin/member) and seatType (team/power) stay exactly as they were, and can() keeps reading them directly.

Access LevelroleseatTypeWhat it means
Company AdminownerpowerAll-company access plus org administration — billing, users & roles, org settings (the org.* permissions), org ownership, last-admin protection
AdminadminpowerRuns every operational module the plan includes; NOT org administration unless explicitly granted
ManagermemberpowerA member with the manager dashboard. Same enforced can() outcome as Member — the power seat is a ceiling that lifts the cap so a granted POWER_ONLY action can take effect; none are granted by default
MembermemberteamStandard access: *.view/*.edit/*.export within plan, capped below POWER_ONLY

"Super Admin" is NOT an Access Level. It refers only to the platform operator (libs/admin.ts isPlatformAdmin, determined by email) — never to any org tier. The org's highest tier is Company Admin.

Company Admin vs Admin

The one intentional behavioral split in this model: org administration belongs to Company Admin only. Org-admin permissions are namespaced under the org module — org.manage, org.team.invite, org.team.assign_role, org.billing, org.data.export, org.data.import — and identified by isOrgAdminPerm(perm) (true when the perm's module, its first path segment, is org).

  • Company Admin (owner) — full within plan, including all org.* administration.
  • Admin (admin) — full within plan for every operational module (sales/operations/finance/hr/integrations/agent/revops), but an org.* permission requires an explicit grant in permissions[]. Existing admins were grandfathered (their org-admin perms unioned into permissions[]) so no one lost access; newly assigned Admins get the narrower default.

Permissions are what's enforced. Access Level, plan, and custom roles just compose into permissions.

The stored fields behind the lens

FieldLives onAnswersSource of truth
PlanOrg.license → normalizePlan()What capabilities exist (which modules)libs/access.ts MODULES
SeatUser.seatType (team | power)Capability ceiling you paid forlibs/access.ts seatAllows()
RoleUser.role (owner/admin/member)Default permission bundlelibs/access.ts role logic
PermissionUser.permissions[] + roleThe exact action allowed (module.action)enforced everywhere

libs/access/labels.ts owns the Access Level ↔ (role, seat) mapping (accessLevelFor, roleSeatForAccessLevel, accessLevelLabelForRole) and re-exports the canonical ROLE_LABEL, SEAT_LABEL, PLAN_LABEL maps so it is the single import site for every screen's labels.

Plans (MODULES) — the licensing matrix

Plan gates which modules exist; Access Level + permissions gate what you can do within a module. The two axes are orthogonal: a Company Admin on Basic still cannot touch Finance (not in the plan), and a Member on Enterprise still cannot finance.approve without a grant.

ModuleBasic (lite)Pro (pro)Enterprise (byo)
sales (CRM)✅✅✅
operations—✅✅
finance—✅✅
hr—✅✅
integrations—✅✅
agent (Artie)—✅✅
revopsinstall-gated¹install-gated¹install-gated¹
Cross-cutting (dashboard, timesheets, org, platform, core, marketplace)✅✅✅ (always)

¹ Marketplace addons (revops, propertyManagement) are not plan-gated — entitled() returns true for them, and the real gate is an approved install (addonInstalls row → AppSidebar ADDON_SCREENS). Enterprise (byo) additionally supports custom per-account module selection.

  • Basic (license: "lite"): sales only (core CRM).
  • Pro (license: "pro"): sales, operations, finance, hr, integrations, agent (+ revops/propertyManagement by install).
  • Enterprise / Build Your Own (license: "byo"): permissive ceiling (see below) — the real license is the module selection.
  • Cross-cutting modules (dashboard, timesheets, org, platform) are always available.

Build Your Own (à la carte) — byo

normalizePlan("byo") === "enterprise", so entitled() is a permissive ceiling (every business module may exist). The account's real license is its module selection, stored on Org.moduleVisibility and enforced by the existing second bound moduleAllowed() — so can() is unchanged (effective = plan ∩ moduleVisibility ∩ seat).

  • Empty by default. Setting a customer's license to byo provisions moduleVisibility = { mode:"restricted", modules:[] } (write-if-absent, via byoDefaultVisibility in libs/access/byo.ts). A fresh Build Your Own account has no business modules until they are turned on. PATCH/POST /api/admin/customers apply this; a configured account is never reset.
  • The pickable set is SELECTABLE_MODULES (sales, operations, finance, hr, integrations, agent, timesheets) with BUSINESS_MODULE_META labels. The always-on shell (core/org/dashboard/ marketplace) and the admin-essential screens (orgchart/employees/roles/settings) are never toggles — user accounts, roles, and settings stay available on an empty account, and the marketplace stays reachable so addons can be installed.
  • Where you pick: the platform-admin Customer detail screen (dashboard/customers/[id] → PATCH /api/admin/customers/[id] { moduleVisibility }) and the org's own Settings → Billing (PATCH /api/org { moduleVisibility }, gated by canConfigureModuleVisibility). Both render the shared components/access/ModulePicker.tsx.
  • Convert-in-place: switching a live lite/pro account to byo confirms first, offering Start empty or Start from current access (seeded via enabledSelectableModules).
  • moduleEnabledForOrg(license, moduleVisibility, module) is the one helper (entitled ∩ moduleAllowed) used by the picker and the addon install gate — it composes both bounds, so a pro/lite account missing a module is treated the same as a BYO one.
  • Backward compat: existing full byo orgs are snapshotted to an explicit selection equal to what they can access today (scripts/migrations/byo-explicit-module-selection.ts, idempotent), so no live customer loses access.

Marketplace addons that require a module

An addon's required business modules are the union of the modules derived from the ERP resources it declares in dataAccess (via resourceModule) and any explicit requiresModules on the listing, intersected with SELECTABLE_MODULES (requiredModulesForAddon, libs/marketplace/dependencies.ts). POST /api/marketplace/install (and admin approval) blocks with a 409 naming the missing module(s) when a required module isn't moduleEnabledForOrg. An addon's own module (revops) is not selectable, so internal QC installs are never gated; RevOps/Sales-Playbook derive sales and are blocked on a BYO account until Sales is enabled.

Seats (User.seatType)

  • Power User (power) — runs/configures/approves/deploys agents. No ceiling. Backs the Manager level (for members) and above.
  • User (team) — executes work. Cannot hold *.approve, *.configure, *.deploy, org.manage by default.
  • The founding user (org creator) is a Power seat. Owners/admins are Power (Company Admin / Admin).

Canonical default seat (reconciled)

There is one canonical default seat, defined by defaultSeat(role) in libs/access.ts: power for owner/admin, team for member (and anything else). The base column default is team; role-based elevation to power for owner/admin happens in code on every creation path.

  • models/User.ts seatType base default = 'team'.
  • drizzle/0004_better_auth.sql originally declared the column default 'power'; a reconciliation migration aligns it to 'team' so a bare insert resolves to the same base value as models/User.ts.
  • Creation paths that make owner/admin principals set seatType: "power" explicitly (e.g. the self-provisioned owner in buildSessionOrg; invite-claim uses ["owner","admin"].includes(role) ? "power" : "team"). Where a path omits the seat, callers use defaultSeat(role), so the resolved seat is role-consistent regardless of which code path created the record.

Roles (stored) → Access Level (lens)

  • owner = Company Admin — all permissions within plan, including org administration (org.*).
  • admin = Admin — all permissions within plan except org administration (org.*), unless explicitly granted. See Company Admin vs Admin.
  • member = Manager (with a power seat) or Member (with a team seat) — *.view, *.edit, *.export, timesheets.submit, agent.use, bounded by seat and plan. A Manager (member + power) receives the manager dashboard (DashboardScreen keys the manager persona via isAtLeast(accessLevelFor(role, seat), "manager")). The power seat is a ceiling, not a granter — a POWER_ONLY action (approve/configure/deploy/manage) still requires an explicit grant; the seat only removes the cap so the grant can take effect. So a Manager's enforced can() outcome equals a Member's until a grant or custom role is added; the difference is the dashboard persona.

Permissions

Format module.action, e.g. finance.approve, sales.edit, timesheets.approve, org.manage, agent.deploy. Named ones live in PERM (libs/access.ts). Actions: view, edit, delete, approve, configure, export (+ submit, use, deploy, manage). sales.goals (PERM.SALES_GOALS_MANAGE) gates setting company/per-rep sales goals (Goal Settings + set_sales_goals); it's a power permission. The Sales/BD resources proposals/salesActivities/activityTypes/salesGoals are all in the sales module, so they use sales.view/sales.edit.

finance.configure (PERM.FINANCE_CONFIGURE, a power permission) gates finance configuration (GL/tax) and, as of Phase D, the timesheet settings — pay-period on/off + type, overtime policy (federal/california/none/custom), rounding, break rule, max-daily, week-start, and the three settings added since: approvalScope (org|team, specs/timesheet-dept-approvals), requireSubmitAcknowledgement (default ON, specs/global-timesheets R3.2 + specs/timesheet-workflow-locks R3), and autoLockDays (null|0..60, the deadline auto-lock, specs/timesheet-workflow-locks R1) — plus the Phase F notifications block. Enforced via canConfigureTimesheets(ctx) (owner/admin ∨ finance.configure ∨ org.manage) on the timesheets branch of PATCH /api/org and the set_overtime_policy tool. The cost/job/phase catalogs (costCodes/jobCodes/phases) are in the timesheets module → gated timesheets.view/edit/delete via the generic ERP route. No new permission row was added.

revops (Revenue Ops module — Pro/Enterprise) gates the RevOps dashboards, signals, revenue, and funnel screens plus the data-source connectors. Three keys: revops.view (see RevOps screens, signals, snapshots), revops.edit (edit RevOps records/notes), and revops.manage (a power permission — forecast configuration + Stripe/Aircall/Calendly/DataFast connector setup on /api/revops/connectors; see docs/INTEGRATIONS.md). The RevOps resources (revopsSnapshots/revopsSignals/revopsFunnelSnapshots/revopsMrrSnapshots) are in the revops module via RESOURCE_MODULE, so the generic ERP route gates them revops.view/edit/delete.

agent.share (Phase K1, docs/KNOWLEDGE_MODEL.md) gates sharing knowledge or a dataset to the WHOLE org — i.e. promoting an item's visibility to org on the user → department → org ladder. It's required by PATCH /api/artie/knowledge/:id/visibility, the visibility extension of PATCH /api/datasets/:id, and the Artie tools set_knowledge_visibility / set_dataset_visibility (whose permission is agent.share only when the target is org; lower tiers use the owner check / datasets.edit). It is held by org owner/admin by default and can be granted to a non-admin from Settings → Team (a power permission, not auto-granted to members). It is not a path to the global tier: global writes are rejected for everyone except isPlatformAdmin() (libs/admin.ts).

The one check

The server is the single source of truth and can(ctx, perm) in libs/access.ts is the one enforcement primitive. Hiding UI is never security.

import { can, PERM } from "@/libs/access";
can(ctx, "finance.approve"); // ctx = { role, permissions, plan, seatType, customRole?, isPlatformAdmin?, demo? }

Evaluation order (libs/access.ts → can):

  1. Public demo tenant (non-sandbox) + a write/send perm → deny (read-only sandbox).
  2. Platform super-admin → allow.
  3. Permission's module not in the plan → deny (bounds everyone).
  4. Role owner (Company Admin) → allow (within plan).
  5. Role admin (Admin) → allow within plan, except an org-admin perm (isOrgAdminPerm(perm)) which is allowed only if it's in User.permissions (explicit grant).
  6. Permission in User.permissions (explicit grant) → allow (overrides seat ceiling, not plan).
  7. customRole set → the expanded template is a strict allow-list: anything not granted → deny (the member *.view/*.edit/*.export defaults are turned OFF).
  8. Seat ceiling blocks it → deny.
  9. Otherwise the member role default decides.

Explicit grants beat the seat ceiling. An admin can grant a Team-seat member timesheets.approve; the seat ceiling only caps role-derived defaults.

Custom role = allow-list. Assigning a customRole replaces the member view/edit/export baseline with a strict allow-list — the principal gets only what the template grants plus any explicit grants. Settings → Team warns before you save this.

can() answers "may you do this at all" — Cedar answers "to whose rows"

can(ctx, perm) is still the only gate on whether an action is permitted, and everything above holds. What it does not decide is which rows you may act on. Four modules now bind a Cedar policy engine (libs/authz/cedar/engine.ts) for that second question:

ModuleBinding
Saleslibs/sales/authz/
Property managementlibs/property-management/authz/
Financelibs/finance/authz/
HR / performance reviewslibs/hr/authz/

Why this matters when you add a feature to one of them: can(ctx, "hr.performance.edit") is true for every HR admin, and on its own it would let that holder file a review about anyone. The row scope is a separate decision, and in these modules the Cedar policies are the decision — not a can() call, and not a hand-rolled if. Policies are .cedar text compiled into policy-text.generated.ts, so they are reviewable as policy and are covered by their own tests.

For performance reviews specifically, the scope is resolved once into a ReviewCtx (libs/perf-reviews/db/ctx.ts) that every repository read and write takes as its first argument, so no query can be written without one. It fails closed: if scope resolution throws, the context is role: "none", not "unfiltered".

Four permission keys were added for reviews (libs/access/permission-catalog.ts:164-167) alongside the existing hr.performance.view / .edit / .delete, which keep their current meaning:

KeyGrants
hr.performance.view.allView every employee's review — the org-wide escape from manager scope
hr.performance.remindSend review reminders
hr.performance.template.configureConfigure review templates and grading scales
hr.performance.reassignReassign a review to another reviewer

Where to enforce

  • Server (required): every mutating/sensitive API route resolves the caller via getRequestOrg() (which carries role, permissions, plan, seatType) and calls can(org, "<perm>"). Hiding UI is not security.
    • The generic ERP routes are already enforced: /api/erp/:resource (GET→.view, POST→.edit), /api/erp/:resource/:id (PATCH→.edit, DELETE→.delete), and /bulk (→.edit), via permFor(resource, action) + resourceModule() in libs/access.ts. New resources inherit this automatically — just add the resource to RESOURCE_MODULE if it's not core.
  • Client (UX): useAccess() (hooks/use-access.ts) → can(perm) / entitled(module) to hide buttons and disable inputs.
  • Sidebar/screens: plan entitlements gate modules (entitled(plan, screenModule(id))), on top of the platform feature flags (global kill-switch) and platform-admin gating.

Expense review — timesheets.approve only (was SEC-2's combined gate)

Expense approve/reject/flag (POST /api/timesheets/expenses/decision) is gated by timesheets.approve alone — the same gate as a timesheet decision.

finance.edit was REMOVED from this gate (specs/timesheet-workflow-locks R7.5). The original SEC-2 design accepted finance.edit ∨ timesheets.approve for ergonomics, but finance.edit is a *.edit suffix, and memberRoleHas() grants every *.edit to a plain member — so the combined gate left the expense-decision route effectively open to every employee in the org. timesheets.approve is a power permission that must be granted explicitly, which is what the control needs.

Two further guards live in the shared decideExpenses engine, so the route and the Artie tools inherit both:

  • No self-review (403). A reviewer can never decide their own expenses (matched on employeeId or createdBy against the caller's dual-read identity). Own rows are skipped in a mixed batch; an own-only batch is refused.
  • Locked week (409). A payroll-locked (employee, week) refuses the whole batch — unlock first. The deadline auto-lock deliberately does not block reviewers; it freezes employee self-service only.

Resolved (artie-timesheet-parity AT-1): the approve_expense / reject_expense Artie tools (and list_pending_approvals' expense bucket) now require timesheets.approve, matching the route — the agent path is narrowed with it (BUILDING.md §C, agent ⊆ user). Pinned by libs/artie/tools.test.ts.

Receipt ZIP — the same finance.edit trap, closed the same way

The receipt-zip download (/api/timesheets/receipts/zip) also required finance.edit alone, and this section explained above exactly why that is not a gate: memberRoleHas() grants every *.edit to a plain member. So every employee with a Team seat could download receipts — and because the range form (from/to) takes no employee scope, that was a bulk export of the whole org's expense documents. It was listed here as "finance-only" for some time, which read as a restriction rather than the absence of one.

It is now canDownloadReceipts() (libs/timesheets/global-access.ts), shared by the route and by every button that calls it (Global list header + selected bar, Global detail "Download all", Team detail, Team approvals). Membership is power permissions only — timesheets.approve, timesheets.payroll, timesheets.lock, finance.approve, org.manage — with a test asserting none of them carries a view/edit/export suffix, so the hole cannot reopen as the list grows. Note the Global detail's "Download all" previously had no client gate at all, so it 403'd from a button that looked live.

Neighbouring gates, unchanged: the manager dashboard (/api/timesheets/dashboard) is timesheets.approve; the expenseCategories catalog inherits the generic ERP gate via RESOURCE_MODULE.expenseCategories = "finance". Note RESOURCE_MODULE.expenses = "operations", so generic ERP access to expense rows keys on operations.* — which is why the timesheet expense grid's delete path had to stop requiring operations.delete (see below). No new permission was introduced.

Timesheet workflow-managed resources

timesheetEntries, timesheetPeriods, expenses, and mileageEntries are workflow-managed (specs/timesheet-workflow-locks R8): their lifecycle fields are owned by the timesheet engine, so the generic ERP route and the generic Artie record tools refuse to write them even when the caller holds the module .edit/.delete permission.

PathBehavior
PATCH/DELETE /api/erp/{timesheetEntries,timesheetPeriods}/:id405 — use the timesheet APIs
POST /api/erp/timesheetPeriods405 — periods are engine-created only
POST /api/erp/{timesheetEntries,timesheetPeriods,expenses}/bulk405
POST /api/erp/timesheetEntriesallowed (field-worker quick log) but normalized: lifecycle fields stripped, status: "draft", self-owned unless timesheets.approve, weekOf defaulted
POST/PATCH /api/erp/{expenses,mileageEntries}status whitelisted to draft|submitted (403 otherwise); non-approvers limited to their own rows and to draft/rejected (403/409); frozen weeks 409
DELETE /api/erp/{expenses,mileageEntries}/:idthe owner of a draft/rejected row may delete it without operations.delete (members never hold it, which made the grid's trash button a silent 403); a non-owner needs timesheets.approve; a payroll-locked week refuses for everyone
Artie create_record/update_record/delete_record/importrefuse all four resources; reads (list_records) still work

Deadline auto-lock is not a permission

Org.timesheets.autoLockDays freezes a week for employee self-service N days after it ends (libs/timesheets/auto-lock.ts). It is a computed freeze, not a permission and not a stored flag: an approver acting on-behalf is exempt, and every reviewer action (approve/reject/flag/lock/ unlock) is unaffected. The manual payroll lock remains the control that stops everyone.

Timesheet detail read is approval-scoped for plain approvers

GET /api/timesheets/global/:id (one employee's week — serves BOTH the Global detail screen and the Team detail page /dashboard/timesheet/:id) is view-gated by canViewGlobalTimesheets, whose perm list includes plain timesheets.approve. Since that would let a team-scoped approver read any employee's week, the route additionally runs loadApproverScope for callers whose only qualifying grant is timesheets.approve: out-of-scope employees → 403 (own week always allowed). Org-wide viewers (owner/admin, org.manage, timesheets.payroll, timesheets.lock) are exempt — they keep the full roster view and the scope loader is never invoked for them. hr.view was removed from the view-perm list: memberRoleHas() blanket-grants every *.view to plain members, so it made EVERY employee an org-wide roster viewer (same suffix pitfall as the expense-review finance.edit gate above); HR staff who need the roster get timesheets.payroll. The approver payroll-comment thread (POST /api/timesheets/notes { payrollComment }) carries the same approve + scope gate. The global LIST (GET /api/timesheets/global) and its sidebar item go further (V2 parity): they require an org-wide grant via isOrgWideTimesheetViewer — plain timesheets.approve never sees the roster at all (approvers work Team Timesheet; the detail route alone accepts them, scope-guarded, because the Team detail page reuses it).

People vs logins vs roles (identity model)

Three distinct things (specs/identity-model/):

  • Person — the canonical roster record (employees, keyed by employees.id). Everyone in the org is a Person, with or without a login (subcontractors / not-yet-invited / hired exist without one).
  • Login — the auth account (users, keyed by user._id). Carries all access control (role/seatType/permissions/customRole). No login ⇒ no access — RBAC lives on the login.
  • Link — Employee.userId ↔ User.employeeId, formed/self-healed by libs/identity.ts (ensurePersonForUser, run in buildSessionOrg + invite/claim). SessionOrg.employeeId is the request's canonical Person id.
  • Employee.title/roleIds/positionId are descriptive HR, never access. Access is always can(user, perm). Person-scoped data (timesheets/PTO/expenses) keys on employees.id, resolved via libs/identity.ts — never on user._id directly.

Screen labels — one source, canonical wording

Every screen renders access labels from the Label_Source (libs/access/labels.ts); no screen defines its own label map.

  • Customers screen (CustomerDetailScreen, the platform-admin area) labels org roles with the canonical Access Level labels via accessLevelLabelForRole(role) → Company Admin / Admin / Member. It manages only role (no seat), so it does not surface Manager (which requires a power seat); that is expected. The old local owner → "Super Admin" map has been removed — "Super Admin" refers only to the Platform_Super_Admin (libs/admin.ts), never an org tier.
  • Settings → Team uses the full four-level Access Level control (one select writing role + seatType together via roleSeatForAccessLevel(level)), plus the custom-role and extra-permission controls, with a composition explainer and the custom-role allow-list warning.
  • EmployeeDrawer / org chart shows the linked login's Access Level (Label_Source) separately from descriptive HR job pills, which are labeled not enforced; a person with no linked login is shown as having no enforced access.

Agents (Artie) — enforced

Artie's effective permissions = intersection(agent policy, the invoking user's permissions) — an agent can never exceed the user who triggered it. Now enforced at runtime:

  • /api/artie/chat requires agent.use (else falls back to the rule engine).
  • Each write tool declares a permission (libs/artie/tools.ts); the agent loop runs can(invokingUser, perm) before executing (move_deal→sales.edit, create_record/update_record→permFor(resource,"edit"), send_sms→sales.edit).
  • Still also gated by the artie_writes feature flag, the per-turn bulk-confirm threshold, and ARTIE_ALLOW_SEND. When adding an agent write tool, set its permission.

Billing model (positioning)

Price by depth of usage, not access: you pay full price for Power seats (people running the business + automation); Team seats are priced to participate. Plan sets the module entitlements; meter AI/agent usage (the ArtieAction audit log) for Enterprise usage-based pricing.


✅ Checklist for EVERY new feature

Step-by-step build patterns: BUILDING.md §A (resource), §B (screen), §C (Artie tool), §D (integration), §E (definition of done).

  1. Module — which module is it (sales/operations/finance/hr/integrations/agent/revops) or cross-cutting? Add to MODULES/SCREEN_MODULE in libs/access.ts if new.
  2. Permissions — define the module.action strings it needs (add to PERM in libs/access.ts if reused). Add assignable keys to PERMISSION_CATALOG in libs/access/permission-catalog.ts (group, subsection, implies, powerOnly). Decide which are member-default vs power-only (approve/configure/deploy/manage).
  3. Server enforcement — guard each route with can(getRequestOrg(), "<perm>"). Return 403 otherwise. Never trust the client.
  4. Plan entitlement — if it's a new screen, make sure screenModule() maps it so plans gate it; confirm it's hidden for plans that shouldn't have it.
  5. Client UX — use useAccess().can()/entitled() to hide/disable controls. Match the server rules.
  6. Seat impact — does it create a new power-only capability? Confirm Team seats are correctly blocked by default.
  7. Agent — if Artie can do this action, gate the tool by the same permission (intersection rule) + audit it.
  8. Docs — update this file if you added a module/permission; update BUILDING.md if you introduced a new build pattern.

Role templates (roleDefinitions) — not a parallel auth system

System and custom role templates live in ERP roleDefinitions. They are not a fifth auth layer.

ConceptWhereEnforced?
Login roleUser.role (owner/admin/member)Yes — can() owner bypass; admin bypass except org.* (Company Admin vs Admin)
Role templateUser.customRole → roleDefinitions.idYes — merged into SessionOrg.permissions via expandRolePermissions() in buildSessionOrg; acts as a strict allow-list in can()
Explicit grantUser.permissions[]Yes
HR job pillsEmployee.roleIds[]No — descriptive unless synced to customRole

System templates (FOSA-aligned)

Seeded per org from libs/roles/system-templates.ts on first GET /api/erp/roleDefinitions (ensureSystemRoles). Nine defaults: CompanyAdmin, Sales Manager, Finance Manager, Payroll Manager, HR Manager, Department Manager, Office Location Manager, Team Lead, Employee.

  • isSystem: true — cannot rename/delete; permissions editable.
  • Assign in Settings → Team (User.customRole) or org-chart drawer.

Marketplace addon roles (seeded on install)

A marketplace addon can ship its own default role templates that are seeded into the org's roleDefinitions when the addon is installed — so an admin has ready-made personas to assign without hand-building them. The contract:

  • Seed-if-absent + idempotent — installing (or re-installing) never overwrites a role an admin has edited, and never duplicates. Uninstalling leaves the roles (and any user assignments) in place.
  • isSystem: true, unassigned — seeded roles are marked system (can't be renamed/deleted; permissions still editable) and are never auto-assigned to any user. An admin assigns them from Settings → Team or the org-chart drawer.
  • Addon-scoped permissions — the role's permission bundle is expressed in the addon's own permission keys (e.g. revops.*), which only resolve to true once the addon is installed (the module is install-gated), so the roles are inert until the addon is active.

Addons are wired via a slug-keyed registry — ADDON_ROLE_SEEDERS in libs/marketplace/addon-roles.ts maps an addon slug → its seeder; seedAddonRoles(slug, orgId) is called from every install path (the internal-app installer, the generic storefront install, and the pending-request approval). Adding default roles for a new addon is one registry line + a persona module — no per-addon branch in the route. The generic seeder lives in libs/marketplace/persona-roles.ts (seedPersonaRolesFor).

RevOps (libs/revops/roles.ts, slug revops) seeds two personas:

RoleKey permissionsCan
RevOps Analystrevops.view, revops.editSee the Sales Engine score/signals/funnel; edit RevOps records & notes
RevOps Managerrevops.view, revops.edit, revops.manageAnalyst access plus forecast config + revenue data-source connector setup

Property Management (libs/property-management/roles.ts, slug property-management) seeds four personas:

RoleKey permissionsCan
Portfolio ManagerpropertyManagement.edit, finance.ap.viewFull property management access; see AP bills
Property AccountantpropertyManagement.view, finance.ap.submit, finance.ap.approveView properties; submit + approve AP costs
Leasing AgentpropertyManagement.edit, propertyManagement.viewManage leases and tenants; view properties
Property ViewerpropertyManagement.viewRead-only portfolio + property detail

Property Management permission catalog keys:

  • propertyManagement.view — view properties, leases, and documents
  • propertyManagement.edit — create and edit (implies view)
  • propertyManagement.delete — delete records (powerOnly, implies edit + view)
  • propertyManagement.configure — unlock a locked budget for correction; edit document reminder preferences (powerOnly, implies view)

To give a new addon default roles: add libs/<addon>/roles.ts (a PersonaRoleSpec[] + a seed<Addon>Roles that delegates to seedPersonaRolesFor), then register it in ADDON_ROLE_SEEDERS by slug. The installer picks it up automatically. See BUILDING.md §D3.

How to expand the permission catalog later

When shipping a feature, do this as part of the same PR — see BUILDING.md §A–C.

  1. Add one row to libs/access/permission-catalog.ts (PERMISSION_CATALOG).
    • key: module.action string used by can().
    • group / subsection: Roles screen picker layout.
    • implies: optional — grant other keys when this one is on (e.g. hr.orgchart.edit → hr.edit).
    • powerOnly: mark approve/configure/deploy/manage-style caps.
  2. Gate the feature — can(org, "your.new.perm") on the API route, screen, or Artie tool. If the action is still covered by an existing perm, use implies instead of a new check.
  3. Optional — add the key to one or more entries in libs/roles/system-templates.ts.
  4. Optional — add to PERM in libs/access.ts if reused across the codebase.
  5. Test — libs/access/permission-catalog.test.ts for expand/implies behavior.
  6. Docs — one line in this file describing what the new perm gates.

No migration required for new catalog keys — roleDefinitions.permissions is a JSON map of key → boolean.

Legacy coarse keys (sales, operations, admin, …) on old roles still expand via expandCatalogPermissions().

Catalog structure (expanded)

The canonical list is PERMISSION_CATALOG in libs/access/permission-catalog.ts (172 keys). Groups mirror FOSA-style module trees; subsection keys are optional finer grants that implies the coarse module.view / module.edit keys routes already check.

GroupModule-level keysSubsections (examples)
Sales / CRMsales.view/edit/delete/export, sales.goalscontacts, accounts, deals, tasks, sequences, estimates, proposals (+ send), activities, territory, inbox
Operationsoperations.*, operations.approvework orders (+ assign), dispatch, projects, PO (+ approve), vendors, fleet, equipment, inventory, receiving, expenses, field, utilization
Financefinance.*, finance.configureAR, AP, COA, journals, reporting, expense review
HR / Peoplehr.view/edit/deleteemployees, departments, positions, locations, org chart (view/edit/import/export/link), hiring, performance, roles
hr.performance.view.all / remind / template.configure / reassignPerformance reviews v2 - org-wide read, reminders, template config, and reviewer reassignment (view.all/template.configure/reassign are powerOnly). HR Manager template gets all except reassign. See .kiro/specs/perf-reviews-v2.
Timesheetstimesheets.view/edit/submit/approve/lockexport, dashboard, expenses, PTO (request/approve/configure), pay periods, cost/job codes
Home / Datadashboards.*, core.datasets.*, core.importdashboards, datasets, bulk import (dashboards.* aliases → core.view/core.edit)
Artie (AI)agent.use, agent.share, agent.deploywrite actions, knowledge view/edit
Revenue Opsrevops.view/edit, revops.managedashboards & signals, forecast + data-source connector config (revops.manage is power-only)
Administrationorg.manage, integrations.configureteam invite/assign role, billing, data import/export, POS

Enforcement honesty: subsection keys are safe to assign today because implies maps them to existing route gates. To split enforcement (e.g. only sales.proposals.send may send proposals), add can(org, "sales.proposals.send") on that route and remove the broad implies if needed.

CompanyAdmin receives every catalog key automatically (ALL_PERMISSION_KEYS in system-templates.ts). Other system templates use coarse module keys; granular keys are for custom roles and future tightening.

Files (quick map)

FileRole
libs/access.tscan(), plans, seats, permFor(), PERM, expandRolePermissions()
libs/access/permission-catalog.tsPERMISSION_CATALOG, implies, PERMISSION_ALIASES, buildPermissionGroups()
libs/roles/system-templates.tsNine seeded role templates + ALL_PERMISSION_KEYS for CompanyAdmin
libs/roles/seed.tsensureSystemRoles() on GET roleDefinitions
components/screens/RolesScreen.tsxAdmin UI for templates (subsection picker)
docs/BUILDING.mdHow to wire new features to this model