Skip to content

Security ​

This page is for the person running the installation. The first half is how security works. The second half is the review of the code and what it found.

How it's protected ​

Tenant isolation. Every operational table has organization_id, and every query is scoped by it. A request needs X-Organization-Id and an active membership in that organization. A record in another organization returns 404. Public links use hashed random tokens, never ids. See architecture.

Permissions. Each route names the permissions it accepts, and TenantResourcePolicy maps every shared resource to permission keys. The console hides buttons the server would refuse, but the server is the check. See users and roles.

Sign-in. Sanctum bearer tokens, issued at login to active users only. Field phones are enrolled devices that can be revoked, and a revoked phone is blocked from syncing. The field app keeps its token and PIN hash in expo-secure-store and can require biometric unlock.

Secrets. Per-business credentials (payments, email, SMS, integrations, Turnstile, plugin secrets) are encrypted with APP_KEY and never returned by the API or written to the audit log. Back APP_KEY up. Without it these can't be decrypted.

Webhooks. Every inbound webhook verifies a signature or a secret, with a replay window. Outgoing webhooks are signed. See API.

Uploads. Files go to a private bucket by signed address, are hashed and scanned, and fail closed when the scanner is down (ATTACHMENT_ANTIVIRUS_FAIL_OPEN=false).

Audit. Creates, updates, deletes and workflow actions are recorded with who and when, visible in Admin > Audit log. Deleted records are soft-deleted and can be restored.

Reports and invoices. Issued reports and sent invoices can't be edited through the generic API. Their PDFs are stored with hashes.

Plugins. Plugins are code installed on the server by whoever runs it. There's no upload path from the browser. A plugin runs only for the businesses that switched it on. See plugins.

Public pages. Pay, quote, tracking, portal and booking endpoints are throttled. The booking form has a hidden field, a per-visitor limit and optional Turnstile.

What you must do ​

  • Serve everything over HTTPS and set APP_DEBUG=false.
  • Set APP_URL correctly. Links and webhooks depend on it.
  • Rate limit at your proxy. Laravel sees the proxy's address.
  • Keep the object bucket private and versioned.
  • Close or restrict the Horizon dashboard. It's closed to everyone outside local by default.
  • Don't bake a real password into a public console build. The console currently signs in with build-time credentials; see API.
  • Revoke a lost phone straight away under Admin > Work phones.
  • Keep the system and its packages updated. See upgrading.
  • Use DEMO_READ_ONLY=true for any public demo, and only there.

Code review ​

Snapshot ​

Review snapshot: 2026-06-26, Australia/Brisbane.

This review covers the sellable-core tenant security concerns tracked in TODO.md: tenant isolation, IDOR, file access, mobile storage, report immutability, and audit completeness. It is a code-level review and regression test pass, not a third-party penetration test.

Evidence checked ​

Tenant isolation and IDOR:

  • ResolveOrganization requires X-Organization-Id on tenant API routes and verifies active membership before controller code runs.
  • TenantTableController scopes shared CRUD reads and writes by organization_id.
  • TenantResourcePolicy maps every tenant table controller to explicit permissions.
  • AuthorizationAndValidationTest verifies route permission catalog coverage, tenant resource policy coverage, tenant-scoped foreign-key validation, and a report-route permission hardening case.
  • TenantIsolationTest verifies missing organization context, organization context switching, and tenant-scoped customer indexes.
  • SecurityReviewTest now covers workflow-lock behavior without bypassing authorization ordering.

File access and upload security:

  • Attachment presign and completion requests validate that the owner record belongs to the active tenant.
  • Upload tokens are encrypted and bind attachment ID, organization ID, owner type, owner ID, object path, disk, SHA-256 hash, and expiry.
  • Object keys are tenant scoped under organizations/{organization_id}/{owner_type}/{owner_id}/....
  • Attachment processing verifies hashes, rejects EICAR/unsafe SVG content, calls the configured antivirus scanner, fails closed on scanner errors, and records ready/rejected/failed audit events.
  • AttachmentUploadTest covers tenant owner validation, duplicate handling, token/hash mismatches, antivirus rejection, and fail-closed scanning.
  • SecurityReviewTest now covers reuse of a valid upload token under another tenant context.

Report and invoice immutability:

  • Compliance report status changes go through ComplianceReportWorkflowService.
  • Report PDF generation creates immutable attachment metadata with hashes and source snapshots.
  • Generic compliance report PATCH now rejects workflow-owned fields: status, snapshot_json, pdf_attachment_id, issued_at, superseded_at, and superseded_by_report_id.
  • Generic compliance report PATCH now rejects all edits once a report is issued, submitted, or superseded.
  • Invoice send, payment, PDF generation, and accounting sync remain controlled by workflow services.
  • Generic invoice PATCH now only edits draft invoices and rejects workflow-owned fields: status, sent_at, external_accounting_id, pdf_attachment_id, and billing_batch_id.

Mobile storage:

  • Mobile session tokens are stored through expo-secure-store in src/services/session-store.ts.
  • App PIN hashes, salts, and biometric settings are stored through expo-secure-store in src/services/security-store.ts.
  • Strong biometric unlock is required before biometric app unlock is enabled.
  • Offline route, job, inspection, photo queue, and outbox data use Expo SQLite. This is operational field data, not bearer-token storage.
  • Device diagnostics report token presence and PIN/biometric configuration without exporting bearer tokens, PIN hashes, salts, or job payloads.

Audit completeness:

  • TenantRecordAuditObserver centralizes tenant CRUD audit writes for the shared table controller.
  • Role permission, service template field, and price book item replacements now emit child-record replacement audit events rather than only parent-summary events.
  • Workflow services record domain events for report generation/issue/submission, invoice send/payment/PDF generation, mobile evidence, QR binding, upload processing, monitoring notifications, integrations, and webhooks.
  • TenantCrudAuditTest, RoleManagementTest, ServiceTemplateManagementTest, PricingManagementTest, and workflow tests assert audit records on the main mutable paths.

Findings fixed in this pass ​

  1. Issued compliance reports could be mutated through the generic compliance report PATCH route if the caller had report edit permission. The controller now rejects issued/submitted/superseded report edits and prohibits workflow fields.
  2. Generic invoice PATCH exposed status, sent timestamp, and external accounting IDs. The controller now limits generic edits to draft invoices and leaves status/accounting/PDF/batch fields to workflow services.
  3. The first immutability patch checked report state before the shared tenant resource policy. That could return 409 where a caller should receive 403. TenantTableController::authorizeTenantResource is now protected so controller overrides can enforce authorization before state locks.

Residual work before production ​

  • Run a real penetration test against a deployed staging environment with signed URLs, object storage, Horizon workers, Reverb, and real provider callbacks enabled.
  • Add automated browser coverage for the main operator flows so UI regressions cannot reopen IDOR or workflow bypass paths.
  • Recheck mobile SQLite encryption options before handling tenants that require encrypted-at-rest field data on shared devices.
  • Re-run this review after queued Chromium PDFs, upload progress UI, and provider-specific integrations land.

A$125 a month, or A$9,450 once with the source code. Contact us.