Skip to content

System Blueprint → Access engine (permissions)

Related documentation: Commercial Model · Permissions Catalog · Backend Core Packages · Backend Core Modules · Backend Core Addons

Permissions and Delegation Design Specification

Section titled “Permissions and Delegation Design Specification”

This page defines the permission and delegation model only.

Organization access administration boundary

Section titled “Organization access administration boundary”

Tenant organization administration is split by responsibility:

  • Promoters, Artists, and Venues provide Users & Access for invitations, Auth catalog-backed module/general permissions, and shared Core business-unit structure plus Auth assignments.
  • Giving a user Finance access starts them as a Submitter in each selected business unit.
  • Finance owns only Finance workflow elevation: Submitter/Approver, approval limits, primary approver, and Primary Finance Admin.
  • Finance cannot create, rename, archive, delete, or assign business units.
  • Approver status is DERIVED from the organization level, never chosen (2026-07-21). The persona app sets who someone is; Finance sets how much they may approve.
Organization levelSet inApproves
Tenant super adminPersona app (company role)Anywhere in the company
Organization adminPersona app (company role)Anywhere in the company
Business unit adminPersona app (business-unit role)That business unit
MemberNothing; submits only

A company-level admin (Organization admin or Tenant super admin) reaches every business unit by role, so the persona app does not offer them a business-unit picker, and their approval limit is company-level rather than per unit. Their existing business-unit rows are preserved rather than cleared, so a demotion restores them.

  • Primary Finance Admin and primary approver are org-level decisions. Setting who is the company’s Primary Finance Admin, or which approver is primary in a business unit that has more than one, is done only by an organization admin or tenant super admin — a business-unit admin runs their own unit but does not appoint company-wide roles. Primary Finance Admin can only be held by an org-level role. A business unit with a single approver has that person as primary by derivation, not by a checkbox. Finance enforces this server-side (403), not only by hiding the controls.

    Granting Finance access starts a user as Submitter; they leave that state by being given a level, not by a Finance dropdown. Finance’s update payload carries no role field at all — the route echoes the stored role back, and the validator rejects a role outright, which is what makes it impossible for a Finance edit to change the organization level. The company FINANCE role does not grant approval: it outranks ADMIN in Auth’s rank ladder but is a domain role, not a level. Legacy hand-set APPROVER business-unit rows still approve; nothing writes new ones.

  • Business-unit administrators (2026-07-21). The Auth business-unit role ADMIN grants management of the people in that unit: list them, assign and unassign them within the unit, set their business-unit role, and invite into it. It does not grant company role changes, delegation, Primary Finance Admin, module or permission grants, business-unit create/rename/archive, team management, or any action on Owners, Admins, Tenant Superadmins, or members outside the unit.

  • The authority tiers are full (owner / admin / tenant superadmin) → delegated (company-wide, clamped to grantable modules and permissions) → scoped (business-unit administrator). GET /api/access/authority reports the caller’s tier so the UI hides what the API refuses.

  • The business-unit role column is shared, and ADMIN is persona-owned. Finance writes the same business_unit_memberships.role for its invoice workflow (SUBMITTER / APPROVER). Auth and Finance therefore accept ADMIN and treat it as approving — Finance shows it read-only and echoes it back unchanged. Rejecting it made Finance write APPROVER instead, silently stripping organization authority on any approval-limit edit. The persona app is where the business-unit role is granted.

  • The business-unit administrator IS the department head — one manager per unit. isDepartmentHead and the business-unit ADMIN role record the same fact and are written together; the persona app offers a single control. /auth/me/access exposes isDepartmentHead on each business unit and callers accept either signal, so memberships flagged before the role existed keep their authority with no migration. Auth’s partial unique index (business_unit_department_head_idx) is what enforces “one manager”: a second administrator in the same unit is refused.

  • ADMIN was chosen deliberately over the alternatives. Finance’s APPROVER / isPrimaryApprover is invoice-approval routing — coupling it would mean changing who signs invoices silently changes who can add users, and Finance roles must never grant persona-app powers. isDepartmentHead was org-chart data only; it has since been merged with this role, because administration duties are not in fact shared — a unit has one manager.

  • A deactivated membership is not a membership. Auth keeps the company_memberships row (returned on /auth/me with isActive: false) and refuses it on /auth/me/access. Every consumer of /auth/me memberships MUST exclude isActive === false — the admin module does, and a persona app that does not will offer the user a company they cannot open (the Promoters company switcher did exactly this, labelling a removed company “Active membership”). Exclude on isActive === false, not on falsiness, so a row from an older Auth that omits the flag is still treated as active. A client that caches /auth/me must also revalidate it (stale-while-revalidate): trusting a cached profile forever grants access that Auth has since revoked — the Finance app kept offering a deactivated business unit in its create-bill picker for exactly this reason.

  • Frontend navigation constants are not permission catalogs. Grant UIs must read the Auth permission catalog.

  • Concretely: NAVIGATION_PERMISSIONS (Frontend-Kisum-Promoters/src/common/contants/menu.ts) holds unprefixed legacy keys used only to decide which sidebar items render — 'event.view', 'avail.view'. The Auth catalog is namespacedpromoter.event.view. The two are not interchangeable, and a grant UI built on the nav constants would write keys no backend check ever matches. Grant UIs read GET /api/access/catalog; navigation constants stay for menu visibility only.

  • it is not an implementation page

  • it is not a route/API page

  • it is not a current-state audit

  • it explains how permissions should be designed regardless of current service status

It describes the intended logic for:

  • company commercial eligibility
  • user permissions
  • scope restrictions
  • delegation

Resolving access is expensive: one GET /auth/me/access costs Auth ~6 queries plus a live Core entitlements call, and Core’s subscription-summary recomputes entitlements a second time. The shared DigitalOcean Postgres cluster is sized at max_connections = 100 (3 reserved for superuser, so 97 usable) and is shared by local, staging and prod, with each service pool deliberately capped at 4. Connections are not the binding constraint today — query volume and connection hold time are — so the lever is fewer, shorter queries.

Every layer caches with a short TTL plus in-flight coalescing (so the ~10 parallel requests of one page load make one upstream call, not ten):

LayerWhat is cachedDefault TTLEnv override
Auth MeAccessResolved AccessOut, keyed user|company|tokenVersion30sAUTH_ACCESS_CACHE_TTL_MS
Auth → Core clientGET /internal/companies/{id}/entitlements30sCORE_ENTITLEMENTS_CACHE_TTL_MS
Promoters → AuthRaw /auth/me/access payload, keyed user|company|tokenVersion60sAUTH_ACCESS_CACHE_TTL_MS
Promoters → CoreCompany snapshot: company + profile + subscription-summary60sCOMPANY_SNAPSHOT_CACHE_TTL_MS

Rules every implementation must follow:

  • Never cache a failure. 401/403/503 and empty snapshots stay live, so a revoked membership or a recovering upstream is seen on the very next request — and a transient outage is not pinned for the whole TTL.
  • Cache the ungated payload, gate per caller. Promoters caches the raw Auth response and applies the module gate afterwards, so a caller that needs the persona of a company it may not open (the wrong-app redirect) still sees package / packageAppUrl from the same cache entry.
  • tokenVersion is part of the key, so logout and password change invalidate for free.
  • Writes invalidate explicitly. Auth drops its MeAccess cache for a company inside bumpAccessPolicyVersion (every access mutation funnels through it) and on membership upsert/remove.
  • x-redis: bypass is the platform-wide “read live” header. It propagates from the browser through Promoters into Auth and drops every cache in the chain for that company. Use it only after a write that changes entitlements (billing) — never on a normal load.

The platform should resolve access in layers:

Question:

Did the company buy the relevant package, module, or add-on?

Question:

Is the user allowed to access that module?

Question:

What actions inside that module are allowed?

Permissions derived from the organization level

Section titled “Permissions derived from the organization level”

A permission may be granted explicitly (team rule or user rule) or derived from the role the person already holds. Both are ordinary grants: they are filtered by the company’s entitled modules and overridden by an explicit user deny.

Auth computes the derivation in effectiveAccessV2:

Role held in the companyReceives
Company TENANT_SUPERADMIN or ADMINEvery permission of every entitled module
Business-unit member (SUBMITTER)finance.invoice.view/edit, finance.bill.view/edit, finance.vendor.view
Business-unit approver (APPROVER)The above plus finance.bill.approve, finance.invoice.approve
Business-unit administrator (ADMIN / department head)The above plus finance.settlement.view, finance.report.view

Rationale: the business-unit assignment is itself the grant. A person made a Submitter is, by that act, the person who submits — requiring a separate finance.bill.edit tick that no screen offered left every Finance user with the module enabled and no permission key, so the app opened empty and every route returned 403.

Record-level scope is unchanged and separate: the derived key opens the screen, Layer 4 decides which rows are returned. A Submitter sees their own records.

Company entitlement still wins. A derived finance.* key is dropped when the company has no Finance package, for every role including tenant super admin.

A subsection grant carries the area that opens it

Section titled “A subsection grant carries the area that opens it”

Granting promoter.event.<subsection>.<action> without promoter.event.view produces a permission its holder can never use: the Event area refuses them at the door, so the tab they were given is unreachable. Auth therefore emits an implied promoter.event.view rule whenever a membership holds an Event subsection grant and no Event-area allow of its own.

  • The implied rule copies the implying rule’s scope and business units, so the area becomes visible exactly as widely as the subsection already was.
  • Only the area key is implied. Holding one Event subsection still says nothing about any other — sales, estimate, commercial, and tax stay independently assignable.
  • It is emitted as a rule, not only as a flat permission. Consumers gate with permissions and scope with accessV2.rules; a key present in only one of them fails in a way that looks like an unrelated bug.
  • sourceType is DERIVED, so an access audit can tell computed rules from TEAM / USER rows. An explicit deny still wins.

Note the direction: a child implies its parent. The parent never implies its children — promoter.event.view opens the area and exposes no subsection.

Question:

Is the user limited to a company, business unit, workflow slice, or data subset?

Question:

What can this user grant or manage for someone else?

This layered model must always be preserved. One layer must not silently replace another.


Permissions should always be namespaced by module.

Examples:

  • basic.artists.read
  • basic.events.create
  • basic.tasks.manage
  • finance.expenses.read
  • finance.income.read
  • finance.bills.read
  • market.contracts.read
  • ai.predictions.read
  • venue.availability.read

Each permission should be structured like:

<module>.<resource>.<action>

Where:

  • module = product boundary
  • resource = business object or feature area
  • action = read, create, update, approve, manage, export, etc.

Permission names must:

  • remain stable
  • be meaningful to backend and frontend teams
  • reflect business capability, not UI component names
  • never include package names
  • never include temporary pricing or campaign language

Good examples:

  • finance.reports.read
  • basic.contacts.read
  • market.contracts.write

Bad examples:

  • basic-package-user
  • promo-finance-editor
  • screen-4-edit

Examples:

  • basic.artists.read
  • basic.artists.analyze
  • basic.contacts.read
  • basic.events.read
  • basic.events.create
  • basic.events.update
  • basic.market-study.read
  • basic.pnl.read
  • basic.tasks.manage
  • basic.news.read

Examples:

  • finance.expenses.read
  • finance.expenses.write
  • finance.income.read
  • finance.income.write
  • finance.bills.read
  • finance.bills.write
  • finance.ticketing.read
  • finance.reports.read
  • finance.vendors.read

Examples:

  • ai.predictions.read
  • ai.research.read
  • ai.valuations.read
  • ai.sales-reports.read

Examples:

  • touring.routes.read
  • touring.routes.write
  • touring.logistics.manage
  • touring.coordination.manage

Examples:

  • market.contracts.read
  • market.contracts.write
  • market.escrow.read
  • market.investments.read

Examples:

  • venue.availability.read
  • venue.details.read
  • venue.details.write
  • venue.calendar.read
  • venue.calendar.write
  • venue.bookings.read
  • venue.bookings.write
  • venue.customers.read
  • venue.customers.write
  • venue.contracts.read
  • venue.contracts.write
  • venue.deposits.read
  • venue.deposits.write
  • venue.approvals.read
  • venue.approvals.approve
  • venue.reports.read
  • venue.operations.manage

The action segment should be consistent across the platform.

Common actions:

  • read
  • create
  • update
  • delete
  • approve
  • manage
  • export
  • assign

Use actions consistently:

  • read = can view
  • create = can create new records
  • update = can modify existing records
  • delete = can remove or archive records
  • approve = can perform controlled approval workflows
  • manage = broad administrative control over a resource family
  • export = can export/report/download structured output
  • assign = can link or grant access to something

Permissions alone are not always enough.

Some access must also be scoped.

Examples:

  • a user can read bills but only for one business unit
  • a user can read finance reports but not income details
  • a user can update events only inside one operational scope

Possible scope models:

  • company scope
  • business-unit scope
  • workflow scope
  • ownership scope

Example:

User has:
- finance.bills.read
Scope:
- business unit = BU-X only
Result:
- user can read bills
- but only for BU-X

Scope should narrow a permission.
Scope should not create a permission that does not otherwise exist.

Bad design:

No finance permission exists
but BU scope alone gives finance access

Good design:

finance.bills.read exists
scope narrows it to BU-X

Delegation must be documented as a separate concept from permission.

Permission means:

What can this user do personally?

Delegation means:

What can this user grant or manage for someone else?

A manager may have:

  • finance.bills.read
  • finance.bills.write

But delegation may allow them to grant only:

  • finance.bills.read

and not:

  • finance.bills.write
  • finance.income.read

So a user may personally have more access than they are allowed to delegate.


Delegation should conceptually control:

  • grantable modules
  • grantable permissions
  • whether the user can manage users
  • whether the user can buy or assign add-ons
  • whether the user can assign access across all business units or only some scopes

Examples:

ADMIN
- can grant: basic, finance
- can grant permissions: finance.bills.read, finance.expenses.read
- canManageUsers: true
- canBuyAddons: false
MANAGER
- can grant: basic
- can grant permissions: basic.events.read, basic.tasks.manage
- canManageUsers: false
- canBuyAddons: false

7.1 Delegation must never exceed personal access

Section titled “7.1 Delegation must never exceed personal access”

A user must never be able to grant:

  • a module they do not personally control in an allowed way
  • a permission they are not allowed to delegate
  • a scope broader than their own governance authority

8. Relationship to packages, modules, and add-ons

Section titled “8. Relationship to packages, modules, and add-ons”

Permissions are not designed directly from package names.

Use this order:

Package / standalone module / add-on
-> commercial eligibility
-> module eligibility
-> permission-family eligibility
-> user permission grants
-> scope restrictions
-> delegation limits

Package determines what is commercially available.

Module determines the permission namespace.

Add-on may unlock additional capability eligibility.

Permission determines what the user can do.

Delegation determines what the user may grant or manage for someone else.


Company owns finance module
User gets:
- finance.bills.read
User does not get:
- finance.income.read

Result:

  • user can access bills
  • user cannot access income
Company owns finance module
User gets:
- finance.bills.read
Scope:
- BU-X only

Result:

  • user can access bills
  • only inside BU-X
Manager has:
- finance.bills.read
- finance.bills.write
Delegation:
- may grant finance.bills.read only

Result:

  • manager can use write access personally
  • manager cannot grant write access to other users

The access model must stay clean:

  • packages, modules, and add-ons decide commercial eligibility
  • permissions decide user actions
  • scope decides where those actions apply
  • delegation decides what can be granted to others

These concerns must stay separate or the access model will become inconsistent very quickly.

Promoters Permissions V2 rollout (2026-07-14)

Section titled “Promoters Permissions V2 rollout (2026-07-14)”

Promoter companies now have an authorization-v2 policy in Auth with LEGACY, SHADOW, and ENFORCED modes. The evaluation order is: Core entitlement, module grant, action rule, record scope, workflow rule, then individual deny. Individual denies win over team grants.

Core provisions six renameable and archivable promoter business-unit templates. Auth owns shared teams, multi-BU/team memberships, scoped allow/deny rules, Finance roles and limits, primary approvers, invitation policy snapshots, audit history, and effective-access calculation. The flat modules and permissions response remains available while accessV2 carries the structured policy.

Events and tours support company, BU, and direct-user assignment scopes. New records require an active BU when the company is ENFORCED. Existing records are migrated to Operations before activation. Activation is company-by-company and rollback changes the policy to LEGACY without deleting V2 configuration.

Event creation embedded in another workflow is not an authorization shortcut. For example, a marketplace offer may create a promoter event only when each new show supplies Core BU UUIDs and the caller has promoter.event.create in those BU scopes. The separate offer permission is still required for the offer itself.

The company rollout command accepts only the canonical Core company UUID. It provisions Core defaults and Auth team presets, empties normal-user V2 access, preserves Owner/Admin recovery access, assigns that company’s existing Promoters Events/Tours to Operations, and enters SHADOW. Promoters resolves any domain-storage compatibility internally; operators never provide a legacy company id. Activation checks both Auth preflight and unassigned domain records before switching to ENFORCED.

Default team provisioning sends an explicit map of the six Core business-unit UUIDs to Auth. Policy transitions are persisted as the PostgreSQL access_policy_mode enum. On 2026-07-14, Primuse Entertainment and Manik 360 Limited entered ENFORCED after clean checks. All current companies now use enforced access. On 2026-07-20, browser rollout switching and preflight were retired; the Promoters BFF returns 410 Gone for those routes while internal Auth policy support and runtime enforcement remain active.

Finance uses one primary APPROVER per BU and one primary Finance Admin per company. Within the BU limit the ladder is Approver plus Admin; above the BU limit but within the Admin limit it is Admin plus Tenant Superadmin; above the Admin limit it is Tenant Superadmin only. A null limit remains unlimited.

Promoters resolves the event scope before forwarding. Artists booking requests and Venue marketplace bookings persist the promoter event UUID, BU UUIDs, acting Auth user UUID, and scope type. These services return metadata only; Promoters applies company, BU, assigned-event, own-record, and individual-deny rules before returning records.

Finance browser routes in ENFORCED mode are centrally mapped to exact finance.* permission families. Bills, income/settlement, payments, reports, accounting, vendors, files, Xero, notifications, tax/currency, and settings fail closed when access is missing or a business route is unmapped. Workflow actions additionally require the Auth Finance role.