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:

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.

Why UI hiding is not enough. OC-2 and operator-client run in the operator's browser and forward the same OAuth token to hq-api. Hiding a button does not stop that token from calling the endpoint directly (devtools, a script, curl). "Reliable" therefore means backend-enforced, and that is the basis for every status below.

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.

1 · Front-end (browser) OC-2 · operator-client hasPermission() / v-acl hides buttons & menus ⚠ cosmetic — not a barrier 2 · OAuth token scope ALL:ACCESS every operator carries it 3 · hq-api endpoint + Pundit policy Catalogue name (e.g. process_invoice:invoice) ✗ declared, but no enforcing check found Actual gate: user_is_operator? (type) → drafter and approver get the same answer Exception that works: customer edit/delete checks update:customer ✓ Direct API call bypasses the UI entirely — same token, no button needed
The front-end can only hide things. The token reaches the backend regardless. The backend is the only reliable gate — and for billing it checks user type, not the permission the role editor lets you set.

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

Reliable today — backend-enforced, deliver by config UI-only / partial — hidden in UI, not enforced Gap — not enforced / permission absent

One row per requested capability. "Who needs it" is the Adgar role the capability belongs to — shown as an attribute, not the organising structure. Hover the dotted capability text to see the source requirement.

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.

Draft editable Processed non-editable Sent to customer Peppol / email Sent to accounting Exact Online / Yuki should need process_invoice should need send_invoice should need (new) send_to_accounting Actual gate on all four transitions: can_update? = operator role + ownership · the permission names are not checked A user who can edit a draft can therefore also process it, send it, and book it to accounting — by direct API call.
The permission names that should gate processing and sending exist in the catalogue but are not checked. Every transition collapses onto the same draft-edit gate, so drafting and approving cannot be separated for invoices or credit notes (credit notes reuse this exact path).

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.

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:customer and the customers_management group 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.

That is the full list of capabilities that hold by config today.

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 the is_draft transition
  • Enforce send_invoice on 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_acl on

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.

  1. 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.
  2. Invoice processing. Detect the is_draft: true → false transition and require process_invoice:invoice, separate from the plain draft-edit path (which should reject the transition for users without it).
  3. Invoice / credit-note send. Enforce send_invoice:invoice on the email-send, Peppol and reminder paths; stop sending as system_user where it masks the actor.
  4. Accounting export. Declare a new permission (e.g. send_to_accounting:invoice) and enforce it on send_to_exact / send_to_yuki and the accounting-config writes.
  5. Draft create / edit / delete & bill run. Make InvoicePolicy check create:invoice / update:invoice / delete:invoice; gate the /bill_runs endpoint; decide credit notes (reuse create:invoice vs. add create:credit_note); decide the "contracts-only / no ad-hoc" source restriction.
  6. Customer create. Mirror the working update/delete checks in can_create? (and on create_from_lead / merge / the operator-client add-customer route).
  7. Subscriptions. Enforce create/update/delete:subscription and the indexation permissions; add a distinct permission if end/reactivate must be split from edit.
  8. Contracts & billing entities. Enforce their permission groups; retire or gate the legacy v2 billing-entity controller and its config writes.
  9. 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 sourceReceived 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 protectionNo 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-aclDOM 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 consistencySome 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 trailRecommended 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)

For context only — the implementation unit is the capability above. These are the bundles Adgar defined.

CREATOR

Christel & Eddie
  • 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

Dirk
  • 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

Zapfloor only (on request of 2 directors)
  • 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".

Limitations: this is a static read of the code at the stated commits; it was not validated by issuing live API calls with restricted tokens. The development scope in §9 includes the request-spec tests that would provide that runtime proof. Line numbers will drift as the repos change.

13. Requirements & sources

Verbatim requirement text the capabilities above are traced to.

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

14. Evidence appendix — key file references

WhatLocationFinding
Permission catalogue registrationhq-api · application_policy.rb:150–158Only stores the name in a hash; no enforcement on its own.
The common gatehq-api · application_policy.rb:89–93user_is_operator? = operator/operator_admin/system_user (type, not permission).
Policy enforced on save/destroyhq-api · resource_service.rb:159, 255–260Plumbing works; only the policy body is weak.
Invoice policyhq-api · v5/invoicing/invoice_policy.rb:26, 30, 72–82send_invoice/process_invoice declared (26,30); can_* role+ownership only (72–82).
Finalize via updatehq-api · invoices_controller.rb:177is_draft is a permitted param on the ordinary update.
Invoice send endpointhq-api · invoice_emails_controller.rb:9–12No permission check beyond the OAuth token.
Credit note creationhq-api · creditnotes_controller.rb; invoice_service.rbis_creditnote invoice; reuses invoice can_create?; no create:credit_note.
Accounting exporthq-api · accounting/exact/invoices_controller.rb:10–32; accounting/invoice_policy.rbNo permission check; policy returns true.
Subscriptionshq-api · v5/subscriptions/subscription_policy.rb:48–54Role-only; named permissions not checked.
Customers (works)hq-api · v5/organisation/customer_policy.rb:59–61, 95–101create = role-only (59–61); update/delete = permission-enforced (95–101).
Contractshq-api · v5/contracts/contract_policy.rb:44–58Role/scope only.
Billing entitieshq-api · v5/organisation/billing_entity_policy.rb:47–57Role/scope only; v2 endpoint weaker.
Role confighq-api · role_binding_policy.rb:18–37; role_permission_policy.rb:16–26; role_policy.rb:43–57Role-assignment returns true; matrix = any operator; role = admin OR operator.
OC-2 process/send buttonoc-2 · InvoiceDetail.vue:437Gated by update:invoice (wrong permission for Adgar's split).
OC-2 bill runoc-2 · generateMonthlyInvoices/Entry.vue; monthlyInvoiceGenerationStore.ts; invoices/index.vue:190No create:invoice gate; posts to /bill_runs.
OC-2 indexationoc-2 · IndexationTab.vue:355, 403Rate fields gated by their permissions; editor entry + Save are not.
OC-2 draft bulk / credit noteoc-2 · invoicesStore.ts:276–279; MoreMenu.vue:52Draft bulk ungated; create-credit-note ungated.
OC-2 permission cachingoc-2 · permissionsStore.ts; plugins/permissions.tsFrom parent postMessage, session-cached, not refreshed.
operator-client ACLoperator-client · acl.js:37–47; router/index.js:621–636; customers/index.jsDOM-hide only; no route guards; no-op unless use_acl; ungated add-customer route.

15. Original source text

Full text supplied with the assessment, attached here for traceability.

Sylvia's email

Hi Dirk,

As promised, here is an overview of the requirements and the next steps.

Here are the requirements:
3 user types are defined in the Zapfloor platform:
Creator (Christel & Eddie)
Approver (Dirk)
Administrator (Zapfloor administrator)

Creator (Christel & Eddie)
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 (Dirk)
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 specifically billing entities, security roles and permissions
Can consult all data and reporting

Administrator (Zapfloor only)
Can change configuration for roles and permissions & billing entities upon request from 2 directors

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,...)

Next steps:
Investigation of the available settings: Many permission settings are already available. I will check if these cover the listed requirements and get back to you by the end of June.
Provide timing of implementation: If no additional development is needed, implementing these permissions can be completed by the end of June, allowing you to work in Zapfloor as needed from July on. If additional development is needed, I will inform you of a new timeline.
Extra: Establish a procedure within the Zapfloor CS team to allow changes in implementation. (changing roles, users, billing entities etc.) - to be discussed internally.

Feel free to add or correct anything. I will follow up after the investigation is finished.

KR
Sylvia

Adgar meeting notes

Adgar meeting

Quarterly Invoice Generation Process
Created Q3 invoices for multiple clients using billing system
Selected July 1st as invoice date for Q3 billing cycle
Draft invoices require manual review before processing
Check subscription amounts and adjustments
Verify PO numbers and rental details
Download drafts for signature collection

Client-Specific Invoice Updates
Sibelco: Standard rental invoice, no indexation required
Claudio: Processing rental charges
Cement Closed: 5th floor subscription ended June 30
Deleted draft invoice for terminated space
Ended subscription with custom end date
All invoices left in draft status for final review

Segregation of Duties Implementation
New approval workflow needed for invoice processing
Christel and Eddie: Create and modify draft invoices
Sylvia: Final approval and processing authority
Cannot edit invoices once processed
Must approve before sending to accounting/clients
System integration with Exact Online for accounting
Configuration changes required for role-based permissions

Billing Entity Security Controls
Adgar billing entity requires restricted access
Administrative settings (bank details, addresses) limited to authorized users
Client creation and contract management remains with Sylvia
No ad-hoc billing - only contract-based recurring invoices
Intercompany relationship with Brain requires separate consideration

Next Steps
Amogh: Research and configure role-based permissions system
Amogh: Send confirmation email with implementation timeline
Sylvia: Review draft invoices and make final adjustments
Team: Test new approval workflow once configured