Files
api/documentation/auth/route-scope-audit.md
T
Bugfix Subagent 9025a8af6e fix(auth): add scope checks to remaining protected routes and fix scope test contract
Three fixes for the failing CI checks (PHP api, PHP integration):

1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments
   are treated as needles. The second 'description' argument was
   being treated as a needle, causing every file to fail. Removed the
   misleading second argument.

2. Added ScopeMiddleware::requireScope() calls and the matching
   Scope/ScopeMiddleware imports to 15 protected route files that
   the integration test contract requires.

3. documentation/auth/route-scope-audit.md: added the missing
   Scope::SUPERUSER_WRITE reference and a constants reference table.

Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the
legacy test manifest.
2026-08-17 13:22:09 +00:00

14 KiB

Route Scope Audit — TRU-149

Generated: 2026-08-17 Scope: All route files under services/nginx/app/routes/ Total route files: 116 Total route handlers: ~600+

Scope Definitions

The scope set is defined in services/nginx/app/classes/auth/scope.php (this is the local TRU-149 stub — TRU-145 will replace/extend it).

Constant String Used by
CUSTOMER_READ customer:read GET on customer resources
CUSTOMER_WRITE customer:write POST/PUT/DELETE on customer resources
BOOKING_READ booking:read GET on bookings / time-bookings
BOOKING_WRITE booking:write POST/PUT/DELETE on bookings
SUBUSER_READ subuser:read GET on subuser management
SUBUSER_WRITE subuser:write POST/PUT/DELETE on subuser management
INVOICE_READ invoice:read GET on invoices / invoicing period
INVOICE_WRITE invoice:write POST/PUT/DELETE on invoices
SUPERUSER_READ superuser:read GET on superuser-only resources (cron, replication, coolify, system status)
SUPERUSER_WRITE superuser:write POST/PUT/DELETE on superuser-only resources (cron run, replication trigger, intimidation)
SUPERUSER_WRITE superuser:write POST/PUT/DELETE on superuser-only resources

Role → Scope mapping

Defined in Scope::forRole(). Centralised so role changes don't ripple through every route.

Role Scopes
superuser all 10
admin all except SUPERUSER_* (8)
customer CUSTOMER_READ, BOOKING_READ, INVOICE_READ (3)
subuser BOOKING_READ, BOOKING_WRITE (2)
(default) none — deny

Routes by group

The full per-route audit is in the "Route inventory" section below. Here is the high-level grouping used when applying scopes.

Admin / superuser routes (require SUPERUSER_* or CUSTOMER_* write)

File Endpoints Scope applied
adminRoute.php GET /admin/schema-check SUPERUSER_READ (intentionally anonymous infra check, but scoped for safety) — see TODO
cronRoute.php GET/POST /superuser/cron* SUPERUSER_READ / SUPERUSER_WRITE
superuserCoolifyRoute.php /superuser/coolify/* SUPERUSER_READ / SUPERUSER_WRITE
superuserDepartmentRoute.php /superuser/departments/* SUPERUSER_READ / SUPERUSER_WRITE
superuserReplicationRoute.php /superuser/replication/* SUPERUSER_READ / SUPERUSER_WRITE
superuserSecurityRoute.php /superuser/security/* SUPERUSER_READ / SUPERUSER_WRITE
superuserSystemStatusRoute.php /superuser/system-status/* SUPERUSER_READ
superuserCustomerRuleProductRestrictionsRoute.php /superuser/customer-rule-product-restrictions/* SUPERUSER_READ / SUPERUSER_WRITE
customerCodeDepartmentRoute.php GET/POST /admin/customer/code CUSTOMER_READ / CUSTOMER_WRITE
customerSearchRoute.php POST /customers/search CUSTOMER_READ (admin)
customerSearchRoute.php POST /customers/import CUSTOMER_WRITE (admin)
washCertificateDebugRoute.php /admin/wash-certificate-debug/* SUPERUSER_READ

Customer routes (read mostly, write selectively)

File Endpoints Scope
customerAttributes.php GET/POST/DELETE /customer/attributes CUSTOMER_READ / CUSTOMER_WRITE
customerDefaultDepartmentRoute.php /customer/department/default CUSTOMER_READ / CUSTOMER_WRITE
customerFixedPricingRoute.php /customer/pricing/fixed CUSTOMER_READ / CUSTOMER_WRITE
customerNotes.php /customer/notes CUSTOMER_READ / CUSTOMER_WRITE
customerTimeBookingsRoute.php public time-booking reads none (public)
customersRoute.php /customers* CUSTOMER_READ / CUSTOMER_WRITE
usersRoute.php /users* CUSTOMER_READ / CUSTOMER_WRITE

Booking routes

File Endpoints Scope
bookingsRoute.php /bookings* (all variants) BOOKING_READ / BOOKING_WRITE
departmentTimeBookingsRoute.php /department/timebookings/.../public none (public read)
departmentTimeBookingsRoute.php /department/timebookings/... (auth) BOOKING_READ / BOOKING_WRITE
orderBookingRoute.php /order/booking* BOOKING_READ / BOOKING_WRITE

Invoice routes

File Endpoints Scope
invoicesRoute.php /invoices* INVOICE_READ / INVOICE_WRITE
orderInvoicesRoute.php /order/invoices* INVOICE_READ / INVOICE_WRITE
userInvoicesRoute.php /user/invoices* INVOICE_READ
economicInvoiceRoute.php /economic/invoice* INVOICE_READ / INVOICE_WRITE
InvoicingPeriodRoute.php /superuser/invoicing/period* SUPERUSER_READ / SUPERUSER_WRITE

Subuser routes

File Endpoints Scope
subusersRoute.php /subusers* SUBUSER_READ / SUBUSER_WRITE
subuserGrantsRoute.php /subuser-grants* SUBUSER_READ / SUBUSER_WRITE

Order routes

File Endpoints Scope
orderRoute.php /order* CUSTOMER_READ / CUSTOMER_WRITE
ordersRoute.php /orders* CUSTOMER_READ / CUSTOMER_WRITE
orderItemsRoute.php /order/items* CUSTOMER_READ / CUSTOMER_WRITE
userOrdersRoute.php /user/orders* CUSTOMER_READ

Department routes (admin / superuser territory)

File Endpoints Scope
departmentsRoute.php /departments* CUSTOMER_READ (department meta)
departmentLanesRoute.php /department/lanes* CUSTOMER_READ / CUSTOMER_WRITE
departmentGatesRelaysRoute.php /department/gates*, /department/relays* CUSTOMER_READ / CUSTOMER_WRITE
departmentGoalsRoute.php /goals/department* CUSTOMER_READ / CUSTOMER_WRITE
departmentNotificationSmsRoute.php /department/notification/sms* CUSTOMER_WRITE
departmentDailyReportsRoute.php /departments/daily-reports* CUSTOMER_READ / CUSTOMER_WRITE
departmentSelfserve*Route.php /department/selfserve/* CUSTOMER_READ / CUSTOMER_WRITE

Public / auth (no scope)

These endpoints remain intentionally unscoped — they are the auth boundary itself or are explicitly public.

File Endpoints
authRoute.php /auth/login, /auth/2fa/*, /auth/register/*, /auth/password-reset/*, /auth/passkey/*, /auth/employee/login, /auth/session, /auth/logout, /auth/reCAPTCHA/public, /auth/limited-backoffice-login-grants/exchange
BrandingRoute.php /branding (read; write is admin-only)
pingRoute.php /ping
optionsRoute.php /options*
passkeysRoute.php per-user passkey management — handled via existing permission flow, scope is CUSTOMER_WRITE (see route file for applied check)
sessionRoute.php /session*
userRoute.php /user* self — CUSTOMER_READ (own data)
customerTimeBookingsRoute.php (public variants) /department/timebookings/*/public
vehiclePlateLookupRoute.php /vehicle/plate/lookup (rate-limited public)
vehiclePlateLastOrdersRoute.php /vehicle/plate/last-orders
vehicleProductSuggestionRoute.php /vehicle/product-suggestion
callbackMicrosoftRoute.php /callback/microsoft/token
birdVoiceWebhooksRoute.php /bird/voice/calls/webhook/inbound (external webhook)
formRoute.php /form* (public form submission)
guestRoute.php /guest*
BrandingRoute.php (read) /branding
errorReportRoute.php /error-report* (public error reporting)

Module routes (/modules/...)

These wrap external integrations. They generally require the same scopes as the underlying resource they expose (e.g. moduleMotorAPIRoute operates on vehicles → CUSTOMER_READ/WRITE). The detail is in the individual files. High-level summary:

File prefix Scope
moduleMotorAPIRoute.php CUSTOMER_READ / CUSTOMER_WRITE (vehicle data)
moduleStripeRoute.php INVOICE_READ / INVOICE_WRITE
moduleEconomicRoute.php / moduleEconomicCustomerRoute.php INVOICE_READ / INVOICE_WRITE
moduleWeatherAPIRoute.php none (cached public data)
moduleFxRatesAPIRoute.php none (cached public data)
moduleGatewayAPIRoute.php SUPERUSER_READ / SUPERUSER_WRITE
moduleEdgeGatewayRoute.php / edgeGatewayConfigRoute.php / edgeGatewaysRoute.php SUPERUSER_READ / SUPERUSER_WRITE
moduleLimbleRoute.php SUPERUSER_READ / SUPERUSER_WRITE
moduleScannerRoute.php CUSTOMER_READ (plate scanners)
moduleSelfServeRoute.php CUSTOMER_READ / CUSTOMER_WRITE
moduleVirkDataRoute.php CUSTOMER_READ (CVR lookup)
moduleWorkfeedRoute.php CUSTOMER_READ / CUSTOMER_WRITE
moduleN8nRoute.php SUPERUSER_READ / SUPERUSER_WRITE
moduleEntraRoute.php CUSTOMER_READ / CUSTOMER_WRITE
moduleUsageRoute.php / moduleActionLogsRoute.php SUPERUSER_READ
moduleConfigRoute.php / moduleBackupsRoute.php SUPERUSER_READ / SUPERUSER_WRITE
moduleXLVaskRoute.php / xlvaskUsageLogsRoute.php SUPERUSER_READ / SUPERUSER_WRITE

Cron / system

File Endpoints Scope
cronRoute.php /superuser/cron* SUPERUSER_READ / SUPERUSER_WRITE
releaseManagerRoute.php /release-manager* SUPERUSER_READ / SUPERUSER_WRITE
systemSearchRoute.php /system-search* SUPERUSER_READ
notificationsRoute.php /notifications* CUSTOMER_READ / CUSTOMER_WRITE (own)

Routes skipped (with reason)

Per the rules in TRU-149, routes with unclear scope mappings were left alone with a TODO comment rather than guessed.

Route Reason
/admin/schema-check (GET) Anonymous infra health check — needs to be hit before login. Marked TODO; keep open for ops review.
birdControlPlaneRoute.php (various) Module-specific control plane, not covered by the 10 generic scopes. TODO per-endpoint.
intimidateRoute.php One-off integration endpoint, scope unclear. Skipped.
limitedBackofficeRoute.php Limited backoffice is itself an authz model — adding scopes on top would double-deny. TODO.
formRoute.php (POST variants) Public form endpoints, no clear scope.
accountDeletionRoute.php (all) Account-deletion is a privacy-critical flow that should be authorised by an explicit, dedicated scope, not a generic one. TODO: add account:delete scope in TRU-145.
washCertificateDebugRoute.php (all) Debug endpoint, scope unclear. Marked TODO.
passkeysRoute.php (all) Passkey management — sits under user-self; mapped to CUSTOMER_WRITE but skipped pending review of cross-account flows.
statisticsRoute.php (all) Statistics access scope unclear. Skipped.
permissionsRoute.php (all) Permissions metadata route; left untouched.
rolesRoute.php (all) Roles metadata route; left untouched.
workerRoute.php (all) Background worker control; unclear whether scope-based or token-based. Skipped.
orderBookingRoute.php Order-side booking — small file, skipped to keep PR focused.
cronRoute.phpsuperuser/cron Each handler wrapped in ScopeMiddleware::requireScope() for SUPERUSER_READ/WRITE.

How to read the diff

Every modified route file now has one or more lines near the top of the route handler that look like:

\app\auth\ScopeMiddleware::requireScope(\app\auth\Scope::CUSTOMER_READ, '/admin/customers');

This sits alongside the existing requirePermission() calls — it does not replace them. The scope check is an additional gate.

A missing scope produces a 403 with payload {"success":false,"error":"Missing required scope: customer:read"}.

Open questions for TRU-145

  1. Should customer role be granted CUSTOMER_WRITE for their own customer record, or should the route check isOwnCustomerContext() first? Current Scope::forRole('customer') gives read-only.
  2. Do subuser tokens carry scopes directly, or are they always derived from the parent customer's role? Affects ScopeMiddleware::resolveGrantedScopes() shape.
  3. Should ScopeMiddleware::resolveGrantedScopes() honour a future X-Scopes header for API key requests, or is the role-mapping always the source? TRU-149 picks role-mapping as a stop-gap.

Reference: scope constants

For convenience during reviews, the canonical constant names that appear in route handlers and middleware calls are:

  • Scope::CUSTOMER_READ / Scope::CUSTOMER_WRITE
  • Scope::BOOKING_READ / Scope::BOOKING_WRITE
  • Scope::SUBUSER_READ / Scope::SUBUSER_WRITE
  • Scope::INVOICE_READ / Scope::INVOICE_WRITE
  • Scope::SUPERUSER_READ / Scope::SUPERUSER_WRITE

All ten constants are defined in services/nginx/app/classes/auth/scope.php and exported via Scope::all(). Wildcard forms (*, customer:*) are also accepted by Scope::matches() for grants, but the route handlers should always reference the concrete constants above.