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 tolibs/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 Level | role | seatType | What it means |
|---|---|---|---|
| Company Admin | owner | power | All-company access plus org administration — billing, users & roles, org settings (the org.* permissions), org ownership, last-admin protection |
| Admin | admin | power | Runs every operational module the plan includes; NOT org administration unless explicitly granted |
| Manager | member | power | A 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 |
| Member | member | team | Standard 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 allorg.*administration. - Admin (
admin) — full within plan for every operational module (sales/operations/finance/hr/integrations/agent/revops), but anorg.*permission requires an explicit grant inpermissions[]. Existing admins were grandfathered (their org-admin perms unioned intopermissions[]) 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
| Field | Lives on | Answers | Source of truth |
|---|---|---|---|
| Plan | Org.license → normalizePlan() | What capabilities exist (which modules) | libs/access.ts MODULES |
| Seat | User.seatType (team | power) | Capability ceiling you paid for | libs/access.ts seatAllows() |
| Role | User.role (owner/admin/member) | Default permission bundle | libs/access.ts role logic |
| Permission | User.permissions[] + role | The 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.
| Module | Basic (lite) | Pro (pro) | Enterprise (byo) |
|---|---|---|---|
sales (CRM) | ✅ | ✅ | ✅ |
operations | — | ✅ | ✅ |
finance | — | ✅ | ✅ |
hr | — | ✅ | ✅ |
integrations | — | ✅ | ✅ |
agent (Artie) | — | ✅ | ✅ |
revops | install-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"):salesonly (core CRM). - Pro (
license: "pro"):sales, operations, finance, hr, integrations, agent(+revops/propertyManagementby 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
byoprovisionsmoduleVisibility = { mode:"restricted", modules:[] }(write-if-absent, viabyoDefaultVisibilityinlibs/access/byo.ts). A fresh Build Your Own account has no business modules until they are turned on.PATCH/POST /api/admin/customersapply this; a configured account is never reset. - The pickable set is
SELECTABLE_MODULES(sales, operations, finance, hr, integrations, agent, timesheets) withBUSINESS_MODULE_METAlabels. 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 bycanConfigureModuleVisibility). Both render the sharedcomponents/access/ModulePicker.tsx. - Convert-in-place: switching a live
lite/proaccount tobyoconfirms first, offering Start empty or Start from current access (seeded viaenabledSelectableModules). moduleEnabledForOrg(license, moduleVisibility, module)is the one helper (entitled ∩ moduleAllowed) used by the picker and the addon install gate — it composes both bounds, so apro/liteaccount missing a module is treated the same as a BYO one.- Backward compat: existing full
byoorgs 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.manageby 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.tsseatTypebase default ='team'.drizzle/0004_better_auth.sqloriginally declared the column default'power'; a reconciliation migration aligns it to'team'so a bare insert resolves to the same base value asmodels/User.ts.- Creation paths that make owner/admin principals set
seatType: "power"explicitly (e.g. the self-provisioned owner inbuildSessionOrg; invite-claim uses["owner","admin"].includes(role) ? "power" : "team"). Where a path omits the seat, callers usedefaultSeat(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
powerseat) or Member (with ateamseat) —*.view,*.edit,*.export,timesheets.submit,agent.use, bounded by seat and plan. A Manager (member + power) receives the manager dashboard (DashboardScreenkeys the manager persona viaisAtLeast(accessLevelFor(role, seat), "manager")). The power seat is a ceiling, not a granter — aPOWER_ONLYaction (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 enforcedcan()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):
- Public demo tenant (non-sandbox) + a write/send perm → deny (read-only sandbox).
- Platform super-admin → allow.
- Permission's module not in the plan → deny (bounds everyone).
- Role
owner(Company Admin) → allow (within plan). - Role
admin(Admin) → allow within plan, except an org-admin perm (isOrgAdminPerm(perm)) which is allowed only if it's inUser.permissions(explicit grant). - Permission in
User.permissions(explicit grant) → allow (overrides seat ceiling, not plan). customRoleset → the expanded template is a strict allow-list: anything not granted → deny (the member*.view/*.edit/*.exportdefaults are turned OFF).- Seat ceiling blocks it → deny.
- 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
customRolereplaces 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:
| Module | Binding |
|---|---|
| Sales | libs/sales/authz/ |
| Property management | libs/property-management/authz/ |
| Finance | libs/finance/authz/ |
| HR / performance reviews | libs/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:
| Key | Grants |
|---|---|
hr.performance.view.all | View every employee's review — the org-wide escape from manager scope |
hr.performance.remind | Send review reminders |
hr.performance.template.configure | Configure review templates and grading scales |
hr.performance.reassign | Reassign a review to another reviewer |
Where to enforce
- Server (required): every mutating/sensitive API route resolves the caller via
getRequestOrg()(which carriesrole,permissions,plan,seatType) and callscan(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), viapermFor(resource, action)+resourceModule()inlibs/access.ts. New resources inherit this automatically — just add the resource toRESOURCE_MODULEif it's notcore.
- The generic ERP routes are already enforced:
- 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
employeeIdorcreatedByagainst 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_expenseArtie tools (andlist_pending_approvals' expense bucket) now requiretimesheets.approve, matching the route — the agent path is narrowed with it (BUILDING.md §C, agent ⊆ user). Pinned bylibs/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.
| Path | Behavior |
|---|---|
PATCH/DELETE /api/erp/{timesheetEntries,timesheetPeriods}/:id | 405 — use the timesheet APIs |
POST /api/erp/timesheetPeriods | 405 — periods are engine-created only |
POST /api/erp/{timesheetEntries,timesheetPeriods,expenses}/bulk | 405 |
POST /api/erp/timesheetEntries | allowed (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}/:id | the 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/import | refuse 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 byemployees.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 byuser._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 bylibs/identity.ts(ensurePersonForUser, run inbuildSessionOrg+ invite/claim).SessionOrg.employeeIdis the request's canonical Person id. Employee.title/roleIds/positionIdare descriptive HR, never access. Access is alwayscan(user, perm). Person-scoped data (timesheets/PTO/expenses) keys onemployees.id, resolved vialibs/identity.ts— never onuser._iddirectly.
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 viaaccessLevelLabelForRole(role)→ Company Admin / Admin / Member. It manages onlyrole(no seat), so it does not surface Manager (which requires a power seat); that is expected. The old localowner → "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+seatTypetogether viaroleSeatForAccessLevel(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/chatrequiresagent.use(else falls back to the rule engine).- Each write tool declares a
permission(libs/artie/tools.ts); the agent loop runscan(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_writesfeature flag, the per-turn bulk-confirm threshold, andARTIE_ALLOW_SEND. When adding an agent write tool, set itspermission.
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).
- Module — which module is it (
sales/operations/finance/hr/integrations/agent/revops) or cross-cutting? Add toMODULES/SCREEN_MODULEinlibs/access.tsif new. - Permissions — define the
module.actionstrings it needs (add toPERMinlibs/access.tsif reused). Add assignable keys toPERMISSION_CATALOGinlibs/access/permission-catalog.ts(group,subsection,implies,powerOnly). Decide which are member-default vs power-only (approve/configure/deploy/manage). - Server enforcement — guard each route with
can(getRequestOrg(), "<perm>"). Return 403 otherwise. Never trust the client. - 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. - Client UX — use
useAccess().can()/entitled()to hide/disable controls. Match the server rules. - Seat impact — does it create a new power-only capability? Confirm Team seats are correctly blocked by default.
- Agent — if Artie can do this action, gate the tool by the same permission (intersection rule) + audit it.
- Docs — update this file if you added a module/permission; update
BUILDING.mdif 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.
| Concept | Where | Enforced? |
|---|---|---|
| Login role | User.role (owner/admin/member) | Yes — can() owner bypass; admin bypass except org.* (Company Admin vs Admin) |
| Role template | User.customRole → roleDefinitions.id | Yes — merged into SessionOrg.permissions via expandRolePermissions() in buildSessionOrg; acts as a strict allow-list in can() |
| Explicit grant | User.permissions[] | Yes |
| HR job pills | Employee.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 totrueonce 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:
| Role | Key permissions | Can |
|---|---|---|
| RevOps Analyst | revops.view, revops.edit | See the Sales Engine score/signals/funnel; edit RevOps records & notes |
| RevOps Manager | revops.view, revops.edit, revops.manage | Analyst access plus forecast config + revenue data-source connector setup |
Property Management (libs/property-management/roles.ts, slug property-management) seeds four personas:
| Role | Key permissions | Can |
|---|---|---|
| Portfolio Manager | propertyManagement.edit, finance.ap.view | Full property management access; see AP bills |
| Property Accountant | propertyManagement.view, finance.ap.submit, finance.ap.approve | View properties; submit + approve AP costs |
| Leasing Agent | propertyManagement.edit, propertyManagement.view | Manage leases and tenants; view properties |
| Property Viewer | propertyManagement.view | Read-only portfolio + property detail |
Property Management permission catalog keys:
propertyManagement.view— view properties, leases, and documentspropertyManagement.edit— create and edit (implies view)propertyManagement.delete— delete records (powerOnly, implies edit + view)propertyManagement.configure— unlock alockedbudget for correction; edit document reminder preferences (powerOnly, implies view)
To give a new addon default roles: add
libs/<addon>/roles.ts(aPersonaRoleSpec[]+ aseed<Addon>Rolesthat delegates toseedPersonaRolesFor), then register it inADDON_ROLE_SEEDERSby slug. The installer picks it up automatically. SeeBUILDING.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.
- Add one row to
libs/access/permission-catalog.ts(PERMISSION_CATALOG).key:module.actionstring used bycan().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.
- 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, useimpliesinstead of a new check. - Optional — add the key to one or more entries in
libs/roles/system-templates.ts. - Optional — add to
PERMinlibs/access.tsif reused across the codebase. - Test —
libs/access/permission-catalog.test.tsfor expand/implies behavior. - 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.
| Group | Module-level keys | Subsections (examples) |
|---|---|---|
| Sales / CRM | sales.view/edit/delete/export, sales.goals | contacts, accounts, deals, tasks, sequences, estimates, proposals (+ send), activities, territory, inbox |
| Operations | operations.*, operations.approve | work orders (+ assign), dispatch, projects, PO (+ approve), vendors, fleet, equipment, inventory, receiving, expenses, field, utilization |
| Finance | finance.*, finance.configure | AR, AP, COA, journals, reporting, expense review |
| HR / People | hr.view/edit/delete | employees, departments, positions, locations, org chart (view/edit/import/export/link), hiring, performance, roles |
hr.performance.view.all / remind / template.configure / reassign | Performance 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. | |
| Timesheets | timesheets.view/edit/submit/approve/lock | export, dashboard, expenses, PTO (request/approve/configure), pay periods, cost/job codes |
| Home / Data | dashboards.*, core.datasets.*, core.import | dashboards, datasets, bulk import (dashboards.* aliases → core.view/core.edit) |
| Artie (AI) | agent.use, agent.share, agent.deploy | write actions, knowledge view/edit |
| Revenue Ops | revops.view/edit, revops.manage | dashboards & signals, forecast + data-source connector config (revops.manage is power-only) |
| Administration | org.manage, integrations.configure | team 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)
| File | Role |
|---|---|
libs/access.ts | can(), plans, seats, permFor(), PERM, expandRolePermissions() |
libs/access/permission-catalog.ts | PERMISSION_CATALOG, implies, PERMISSION_ALIASES, buildPermissionGroups() |
libs/roles/system-templates.ts | Nine seeded role templates + ALL_PERMISSION_KEYS for CompanyAdmin |
libs/roles/seed.ts | ensureSystemRoles() on GET roleDefinitions |
components/screens/RolesScreen.tsx | Admin UI for templates (subsection picker) |
docs/BUILDING.md | How to wire new features to this model |