Adgar Permissions — Current-State Reliability Assessment
Adgar asked us to make sure the requested billing, subscription, customer and configuration permissions are reliable. This is a code-level review of what is actually enforced today, organised around the requested permission capabilities. It spans hq-api (backend), OC-2 (billing & subscriptions UI) and operator-client (legacy customers UI).
Reviewed 2026-06-23 against these checkouts — hq-api
5ee87472d · OC-2 c8fb59d0c · operator-client
d72f25618. Line numbers below refer to those commits and may
shift as the repos change. Each capability links to its source
requirement (hover the dotted text; see the
Requirements & sources
appendix).
Contents
1. Executive summary
Adgar wants a clean split between who can create/draft billing work, who can approve, process and send it, and who can change configuration — with configuration reserved to Zapfloor. The unit of implementation is the capability (e.g. "process an invoice", "send to accounting", "create a customer"); the three roles are just bundles of those capabilities.
On the backend, the capabilities are not separated. Almost every billing
endpoint authorizes on the user's account type
(operator), not on the permissions assigned to their role.
Because every billing user is an operator, the backend cannot
currently tell a drafter apart from an approver. Concretely:
- Editing a draft, processing it (locking it), sending it to the customer, and pushing it to Exact Online all pass through the same gate — so a user allowed to edit drafts can also do all three by a direct API call. They are only hidden in the UI.
-
"Send" cannot be granted without also granting draft editing, because
both ride the single
update:invoicepath. - The role / permission / role-assignment endpoints do not restrict the change to a Zapfloor administrator. The most serious finding is that the role-assignment policy does not block a user from changing role assignments — so the "configuration is Zapfloor-only" boundary is not enforced, and a restricted user is not prevented from lifting their own restrictions if they reach the endpoint.
-
The one control that holds today: withholding
update:customer/delete:customerfrom a role really does block customer edits and deletes at the API. Customer creation, however, is not enforced.
For the Adgar conversation: the timeline in Sylvia's email assumed "if no additional development is needed, this can be completed by end of June." Additional development is needed. The permission names are mostly present and the UI is mostly wired, but making them reliable means adding real permission checks to the hq-api billing, subscription, accounting and role-management policies — a backend program of work, not a settings change.
2. The concept that decides everything
A permission being declared is not the same as being enforced
hq-api keeps a catalogue of permission names, registered with
permission "name", group:, description:. That registration
only stores the name in a list. The list powers two things:
- the role editor (which names you can tick on a role), and
- the UI (front-ends hide buttons based on the names a user holds).
It does not, on its own, make any endpoint check the name. A permission only restricts a real API call if a policy method reads the user's assigned permissions for it. application_policy.rb:150–158
Most policies check user type, not assigned permissions
The common gate is user_is_operator?, which is just
operator? || operator_admin? || system_user? — a property
of the account type, not of the role's permissions.
application_policy.rb:89–93
Of the 275 Pundit policy files in hq-api, only
7 read a user's assigned permissions at all; the rest
authorize on user type or record scope. Every billing user is an
operator, so they pass those type-based gates identically.
For the billing surfaces Adgar cares about, the named-permission
catalogue is effectively decorative.
3. How permissions actually work here
A request travels through three layers. Today, the meaningful decision happens in the policy method at the bottom — and for billing that method ignores the permission catalogue.
The enforcement plumbing itself is sound: when a record is saved or
destroyed, the service layer runs check_policy!, which calls
can_create? / can_update? / can_destroy?
and raises if they return false.
resource_service.rb:159, 255–260 The issue is what
those methods look at. The working pattern already exists elsewhere (e.g.
customers, visitors:
user.role_permissions.pluck(:name).include?("update:customer"))
— it simply has not been applied to the billing surfaces in scope.
4. Requested capabilities — current status
| Capability | Who needs it | Permission name | Backend | Reliable? | What we found (evidence) |
|---|---|---|---|---|---|
| Read / list everything (consult data & reporting) | All | list:invoice, list:subscription … |
Role + scope | OK for Adgar | Reads are gated by role + location/customer scope, not by the list:* names (declared but not used). All roles may consult data, so this is acceptable as-is. role/scope based |
| Create / edit draft invoice (+ lines) | Creator | create:invoice, update:invoice, update:invoiceline |
Role-type only | No | can_create?/can_update? = operator + ownership; the named permissions are not checked. Any operator can edit drafts via API. update:invoiceline is orphaned — no line endpoint/policy; lines ride the invoice update. invoice_policy.rb:72–82 |
| Delete draft invoice | Creator | delete:invoice |
Role-type only | No | can_destroy? role + ownership only. In OC-2 the detail menu checks delete:invoice but the draft bulk delete is ungated — inconsistent, and UI-only regardless. invoice_policy.rb:80–82 · invoicesStore.ts:276–279 |
| ⬛ Process invoice (draft → non-editable) | Approver | process_invoice:invoice |
None (open) | No — core defect | The permission is declared but we found no enforcing reference (grep of app/ config/ lib/ returned only the declaration). "Processing" is just PATCH /invoices/:id with is_draft:false (a permitted param), gated by the same can_update? as editing a draft. OC-2's "Process & send" button is gated by update:invoice. Edit and process cannot be separated. invoice_policy.rb:30 · invoices_controller.rb:177 · InvoiceDetail.vue:437 |
| ⬛ Send invoice to customer (email / Peppol) | Approver | send_invoice:invoice |
None (open) | No — core defect | Declared; no enforcing reference found. Email send = POST /invoices/:id/invoice_emails with no authorize beyond the OAuth token; the send job runs partly as the account system_user. invoice_policy.rb:26 · invoice_emails_controller.rb:9–12 |
| ⬛ Send to accounting (Exact Online / Yuki) | Approver | none exists | None (open) | No — no permission at all | No send_to_accounting permission exists. send_to_exact / send_to_yuki have no permission check beyond the OAuth token; the accounting invoice policy returns true for everything. exact/invoices_controller.rb:10–32 · accounting/invoice_policy.rb |
| Create / edit credit note | Creator | create:invoice (no create:credit_note) |
Role-type only | No | A credit note is an invoice variant (is_creditnote flag). create:credit_note does not exist. Creation reuses the unenforced invoice path; in OC-2 "Create credit note" has no permission check. Process/send share update:invoice. creditnotes_controller.rb · MoreMenu.vue:52 |
| Generate monthly/recurring invoices (bill run) | Creator | none (uses list:invoice only) | Role-type only | No | The generator (generateMonthlyInvoices) is embedded on the invoice list pages and gated only by reaching them (list:invoice); the entry component itself has no create:invoice check. It posts to /bill_runs, whose backend permission we did not find enforced. See §6. generateMonthlyInvoices/Entry.vue · /bill_runs |
| Create subscription | Creator | create:subscription |
Role-type only | No | can_create? = user_is_admin? || user_is_operator?; permission not checked. OC-2 has no create flow — creation lives in operator-client, where the Create button has no ACL. subscription_policy.rb:48–50 |
| Edit / end / reactivate subscription | Creator | update:subscription |
Role-type only | No | Role-only; permission not checked. End & reactivate are field updates sharing the same update:subscription as plain edit — not separable even after wiring. (The "Cement Closed, ended June 30" scenario.) subscription_policy.rb:52–54 |
| Subscription indexation config | Creator | custom_indexation_rate_setup:…, indexation_minimum_maximum_rate_setup:… |
None enforced | Partial (UI) | In OC-2 the custom-rate and min/max-rate fields are gated by these permissions (IndexationTab.vue:355, 403), but the editor entry point and Save action are not — and the backend update has no permission check. So the UI restricts the sensitive fields, but enforcement does not. subscription_policy.rb:26–32 · IndexationTab.vue:355,403 |
| ✓ Edit customer (+ archive) | Approver | update:customer / customers_management |
Enforced | Yes — by config | can_update? → update_organisation_permission? checks the user's assigned permissions. Withhold these from a role and edits are blocked at the API. The one requirement deliverable now. customer_policy.rb:95–97 |
| ✓ Delete customer | Approver | delete:customer / customers_management |
Enforced | Yes — by config | Genuinely enforced via delete_organisation_permission?. customer_policy.rb:99–101 |
| Create customer | Approver | create:customer, create:existing_customer |
Role-type only | No | Asymmetry: can_create? is operator + ownership only — the create permission is not checked. So "customers read-only" is half-broken: a restricted user can still create customers (incl. via lead-conversion and the operator-client add-customer route). customer_policy.rb:59–61 |
| Manage contracts (contract-based recurring billing) | Approver | create/update/delete:contract, contracts_management |
Role-type only | No | All declared, none checked; can_* = operator + account match. Standard-contract templates have no permissions at all. Adgar's "contract management = Sylvia" cannot be enforced. contract_policy.rb:44–58 |
| Change billing entity (bank, address, Exact) | Admin | update:billing_entity, billing_entity_management |
Role-type only | No | Declared, not checked; any operator can mutate bank details/Exact config via the v5 endpoint (the legacy v2 endpoint is weaker still). "Admin-only" not enforced. billing_entity_policy.rb:47–57 |
| Manage roles (create/edit/delete role) | Admin | create/update/delete:role, role_management |
Role-type only | No | can_* = user_is_admin? || user_is_operator?. Because it is admin or operator, billing operators pass. Should be admin-only. role_policy.rb:43–57 |
| ⬛ Assign roles to users / edit the permission matrix | Admin | none declared | Open | No — P0 | The role-assignment policy returns literal true for create/update/destroy, and the permission-matrix policy allows any same-account operator. The backend therefore does not prevent an operator from changing role assignments or a role's permission set if they reach the endpoint — defeating the Admin-only boundary. role_binding_policy.rb:18–37 · role_permission_policy.rb:16–26 |
5. The invoice lifecycle gap (the core defect)
Adgar's central ask is "drafters draft; approvers process & send."
That maps to four transitions in an invoice's life. The platform intended a
permission for each (the names exist), but only the first is even partly
wired, and we found none of the others enforced. The whole chain currently
rides one gate — update:invoice / can_update? —
so it cannot be split.
6. Monthly invoice generation (the bill run)
The meeting describes creating Q3 invoices for several clients with a July 1st invoice date — that is OC-2's monthly invoice generator. It is relevant to Adgar because generating drafts is a Creator action and "no ad-hoc billing, contracts only" is a Creator/Approver constraint.
-
The generator entry (
generateMonthlyInvoices/Entry.vue) is embedded directly on the invoices, draft and overdue list pages (invoices/index.vue:190,draft/index.vue:51,overdue/index.vue:87) and is reachable by anyone who can open those lists — i.e. it is gated only bylist:invoice, with nocreate:invoicecheck on the entry itself. -
It posts to
/bill_runs(and/bill_runs/preview) inmonthlyInvoiceGenerationStore.ts; we did not find a named permission enforced on that backend endpoint, so — like the rest of billing — generation is gated by user type, not by an assignable capability. - Implication. "Creators generate drafts, Approvers do not generate" is not separable today, and the "contracts-only / no ad-hoc" rule is a stricter source restriction on creation that no single permission expresses. Both need a backend decision (see §9).
7. Top reliability risks (most critical first)
P0Role / permission config is not Administrator-only
The role-assignment policy returns literal true for all
mutations, and the permission-matrix policy allows any same-account
operator. The backend does not prevent an operator who reaches these
endpoints from assigning roles (including to themselves) or changing a
role's permission set. This both breaks "configuration is Zapfloor-only"
and means a restricted user is not stopped from lifting their own
restrictions — so it should be fixed before any Adgar-specific config.
role_binding_policy.rb:18–37 · role_permission_policy.rb:16–26 · role_policy.rb:43–57
P0Process & send are not separable from draft editing
process_invoice:invoice and send_invoice:invoice
are declared but we found no enforcing check. Finalizing is
is_draft:false on the ordinary update endpoint; sending has no
permission check beyond the OAuth token. The core drafter-vs-approver
split cannot be expressed today for invoices or credit notes.
P0Accounting export has no permission boundary
send_to_exact / send_to_yuki enforce no
permission beyond the token, and there is no permission to assign. The
accounting-config writes (Exact credentials) are likewise gated only at
read level.
P1Customer create is open while edit/delete are enforced
The "customers read-only" rule half-works: edits and deletes are blocked by permission, but creation is not — including via lead-conversion and the legacy add-customer route.
P1Subscriptions, contracts & billing entities are role-only
None check their named permissions. Subscription create has no OC-2 flow (it lives in ungated operator-client). End/reactivate share the edit permission, so they cannot be split without a new permission. Billing-entity bank/Exact changes are open to any operator.
P1UI gating creates false confidence; permissions are cached
All OC-2 / operator-client gating is DOM hiding, bypassable with a valid
token. operator-client's v-acl is additionally a no-op unless
the account has use_acl === true. OC-2 receives permissions
once via parent postMessage and caches them for the session —
a role change does not take effect until reload/re-login, which rollout
and testing must account for. acl.js:37–47 · permissionsStore.ts
8. What works today vs what needs development
Deliverable by configuration now
- Restrict who can edit / delete customers. Withhold
update:customer,delete:customerand thecustomers_managementgroup from the drafting role — enforced server-side. ✓ - UI hiding for everything else can be configured, but it is cosmetic — useful for usability, not for the segregation guarantee.
Needs backend development
- Make role-assignment / role-permission / role policies Administrator-only do first
- Enforce
create:invoice/update:invoice; add a real process gate on theis_drafttransition - Enforce
send_invoiceon email + Peppol; add & enforce a send-to-accounting permission on Exact/Yuki - Enforce
create:customer(+ the legacy create routes & lead-conversion) - Enforce
create/update/delete:subscription& the indexation permissions; add a distinct end/reactivate permission if needed - Enforce contract & billing-entity permissions; gate the bill-run endpoint; retire / gate the legacy v2 endpoints
- Re-wire OC-2 process/send buttons to the new permissions; confirm Adgar's account has
use_aclon
9. Backend development scope (implementation handoff)
Ordered by risk. Each item is "make the policy method check the named
permission" using the pattern already proven on customers
(user.role_permissions.pluck(:name).include?(…)), plus, where
noted, a new permission or a transition-aware guard. Every item needs an
hq-api request-spec that calls the endpoint directly with a
restricted token — that is the only proof of reliability.
- Config lockdown (security-critical, first). Replace the open role-assignment / role-permission / role policies with Administrator-only checks so restricted users cannot lift their own restrictions.
- Invoice processing. Detect the
is_draft: true → falsetransition and requireprocess_invoice:invoice, separate from the plain draft-edit path (which should reject the transition for users without it). - Invoice / credit-note send. Enforce
send_invoice:invoiceon the email-send, Peppol and reminder paths; stop sending assystem_userwhere it masks the actor. - Accounting export. Declare a new permission (e.g.
send_to_accounting:invoice) and enforce it onsend_to_exact/send_to_yukiand the accounting-config writes. - Draft create / edit / delete & bill run. Make
InvoicePolicycheckcreate:invoice/update:invoice/delete:invoice; gate the/bill_runsendpoint; decide credit notes (reusecreate:invoicevs. addcreate:credit_note); decide the "contracts-only / no ad-hoc" source restriction. - Customer create. Mirror the working update/delete checks in
can_create?(and oncreate_from_lead/ merge / the operator-client add-customer route). - Subscriptions. Enforce
create/update/delete:subscriptionand the indexation permissions; add a distinct permission if end/reactivate must be split from edit. - Contracts & billing entities. Enforce their permission groups; retire or gate the legacy v2 billing-entity controller and its config writes.
- Front-end. Re-point OC-2's process/send buttons at the new permissions; add OC-2 unit tests for visible/hidden controls once the backend is in place.
10. Frontend & rollout notes
| OC-2 permission source | Received once from the parent shell via postMessage and cached in permissionsStore for the session — not refetched. A role change won't apply until the parent reloads / the user re-logs in. permissionsStore.ts · plugins/permissions.ts |
| OC-2 route protection | No Nuxt route middleware on settings/billing pages — direct navigation reaches the page; only buttons are guarded. Backend enforcement is mandatory. useSettingsPermissions.ts |
operator-client v-acl | DOM hide/show only; no route guards. Inert unless account.use_acl === true and the user is an operator — otherwise everything is shown. Confirm Adgar's account has use_acl enabled. acl.js:37–47 · router/index.js:621–636 |
| operator-client consistency | Some customer-area components key off raw user.role strings rather than the permission list, so gating is uneven. customers_v2/files/Overview.vue:188 |
| Audit trail | Recommended for Adgar's segregation: log who created/edited/processed/sent each invoice, changed customers/contracts, ended subscriptions, and changed roles/billing entities. Not assessed here — confirm what exists. |
11. The three roles (reference)
CREATOR
- Create / edit draft invoices & credit notes
- Create / edit subscriptions
- Consult all data & reporting
- Customers: read-only
- Cannot process or send invoices
- Cannot change configuration
APPROVER
- Create / edit customers
- Process & send invoices / credit notes
- (process = draft → final, then send to customer + accounting)
- Consult all data & reporting
- Cannot create/edit draft invoices
- Cannot create/edit subscriptions
- Cannot change configuration
ADMINISTRATOR
- Change roles & permissions config
- Change billing entities (bank details, addresses, Exact settings)
- (Out of scope: data-subset restrictions)
12. How this was assessed
Every claim was traced to source in the checkouts listed in the header. The method was deliberately conservative: a capability was only marked "reliable" when the enforcing line could be cited; the default assumption was "not enforced until shown otherwise".
- Backend: read each relevant Pundit policy and the controller/service that calls it; for every permission name, grepped
app/,config/andlib/to check whether it is ever read, not merely declared. - Front-ends: located each gated control and the permission string it uses, and whether routes (not just buttons) are guarded.
- Cross-checked the crux questions with a second independent investigation pass over the same code.
13. Requirements & sources
From Sylvia's email (the requirements)
Creator"Can create and edit draft invoices and/or credit notes. Can create and edit subscriptions. Cannot create or edit customers (read-only). Cannot process or send invoices (read-only). Cannot change configuration specifically billing entities, security roles and permissions. Can consult all data and reporting."
Approver"Can create and edit customers. Can process and send invoices and/or credit notes. Processing an invoice means converting it from a draft to a non editable invoice, then sending it to accounting and to the customer (via Peppol and/or e-mail). Cannot create or edit draft invoices. Cannot create or edit subscriptions. Cannot change configuration… Can consult all data and reporting."
Administrator"(Zapfloor only) Can change configuration for roles and permissions & billing entities upon request from 2 directors."
Scope"Out of scope — Data security: no additional restrictions based on data types will be available (e.g., a user only has access to a subset of customers)."
From the Adgar meeting notes
- "Created Q3 invoices for multiple clients using billing system." / "Selected July 1st as invoice date for Q3 billing cycle." → bill run (§6).
- "Draft invoices require manual review before processing." / "Cannot edit invoices once processed." → process must be a separate, enforced capability.
- "Cement Closed: 5th floor subscription ended June 30." / "Deleted draft invoice for terminated space." / "Ended subscription with custom end date." → subscription end + draft delete.
- "No ad-hoc billing — only contract-based recurring invoices." → source restriction on invoice creation.
- "System integration with Exact Online for accounting." → send-to-accounting capability.
- "Adgar billing entity requires restricted access." / "Administrative settings (bank details, addresses) limited to authorized users." → billing-entity config = Admin.
- "Configuration changes required for role-based permissions." → role/permission config = Admin.
14. Evidence appendix — key file references
| What | Location | Finding |
|---|---|---|
| Permission catalogue registration | hq-api · application_policy.rb:150–158 | Only stores the name in a hash; no enforcement on its own. |
| The common gate | hq-api · application_policy.rb:89–93 | user_is_operator? = operator/operator_admin/system_user (type, not permission). |
| Policy enforced on save/destroy | hq-api · resource_service.rb:159, 255–260 | Plumbing works; only the policy body is weak. |
| Invoice policy | hq-api · v5/invoicing/invoice_policy.rb:26, 30, 72–82 | send_invoice/process_invoice declared (26,30); can_* role+ownership only (72–82). |
| Finalize via update | hq-api · invoices_controller.rb:177 | is_draft is a permitted param on the ordinary update. |
| Invoice send endpoint | hq-api · invoice_emails_controller.rb:9–12 | No permission check beyond the OAuth token. |
| Credit note creation | hq-api · creditnotes_controller.rb; invoice_service.rb | is_creditnote invoice; reuses invoice can_create?; no create:credit_note. |
| Accounting export | hq-api · accounting/exact/invoices_controller.rb:10–32; accounting/invoice_policy.rb | No permission check; policy returns true. |
| Subscriptions | hq-api · v5/subscriptions/subscription_policy.rb:48–54 | Role-only; named permissions not checked. |
| Customers (works) | hq-api · v5/organisation/customer_policy.rb:59–61, 95–101 | create = role-only (59–61); update/delete = permission-enforced (95–101). |
| Contracts | hq-api · v5/contracts/contract_policy.rb:44–58 | Role/scope only. |
| Billing entities | hq-api · v5/organisation/billing_entity_policy.rb:47–57 | Role/scope only; v2 endpoint weaker. |
| Role config | hq-api · role_binding_policy.rb:18–37; role_permission_policy.rb:16–26; role_policy.rb:43–57 | Role-assignment returns true; matrix = any operator; role = admin OR operator. |
| OC-2 process/send button | oc-2 · InvoiceDetail.vue:437 | Gated by update:invoice (wrong permission for Adgar's split). |
| OC-2 bill run | oc-2 · generateMonthlyInvoices/Entry.vue; monthlyInvoiceGenerationStore.ts; invoices/index.vue:190 | No create:invoice gate; posts to /bill_runs. |
| OC-2 indexation | oc-2 · IndexationTab.vue:355, 403 | Rate fields gated by their permissions; editor entry + Save are not. |
| OC-2 draft bulk / credit note | oc-2 · invoicesStore.ts:276–279; MoreMenu.vue:52 | Draft bulk ungated; create-credit-note ungated. |
| OC-2 permission caching | oc-2 · permissionsStore.ts; plugins/permissions.ts | From parent postMessage, session-cached, not refreshed. |
| operator-client ACL | operator-client · acl.js:37–47; router/index.js:621–636; customers/index.js | DOM-hide only; no route guards; no-op unless use_acl; ungated add-customer route. |