Skip to content

Confine every Account query to the authenticated user's tenant - #1

Merged
HafizMMoaz merged 1 commit into
masterfrom
tenant-scope
Jul 14, 2026
Merged

Confine every Account query to the authenticated user's tenant#1
HafizMMoaz merged 1 commit into
masterfrom
tenant-scope

Conversation

@HafizMMoaz

Copy link
Copy Markdown
Member

Closes the cross-tenant IDOR in this module — the financial one: chart of accounts, invoices, payments, expenses, journal entries.

The fix

Same shape as zerp-pk/lead#1 and zerp-pk/hrm#1. The boundary goes on the models via the shared App\Models\Concerns\TenantScoped (zerp-pk/zerp#5), so a foreign id resolves to null and route-model binding 404s before a controller runs.

23 tables carry created_by. The four that do not — credit/debit note items, their taxes, and vendor payment allocations — declare a $tenantParent and inherit the boundary through it.

The parts a model scope cannot cover

33 exists: rules scoped by hand — they go through the query builder and never see an Eloquent scope. Two of them are worth calling out: allocations.*.invoice_id pointed at sales_invoices / purchase_invoices, which are owned by another module. Unscoped, a payment could still be allocated against another company's invoice.

AccountUtility provisions a new company's chart of accounts, often from a superadmin's session, so its five lookups opt out with withoutGlobalScope("tenant"). Without that, signup would silently re-seed the chart of accounts on every run.

Two find() sites that dereference immediately became findOrFail(), so a foreign id is a 404 rather than a 500.

Verification

Proven against real MySQL in a rolled-back transaction: two tenants, each sees its own chart-of-accounts row and category, blocked on the other's, and the list shows 1 of 2 — it isolates without over-filtering. Child rows (credit_note_items, no created_by of their own) verified to scope through credit_notes.created_by. Covered by tests/Unit/TenantScopeTest.php.

Same cross-tenant IDOR as the other modules: mutations were authorised with a
capability check alone, which every tenant's staff passes, so another
company's id resolved fine and was then read, edited or deleted — here across
the chart of accounts, invoices, payments, expenses and journal entries.

The boundary now lives on the models via App\Models\Concerns\TenantScoped. 23
tables carry created_by; the four that do not (credit/debit note items, their
taxes, vendor payment allocations) inherit it through their parent.

33 exists: rules are scoped by hand, since they go through the query builder
and never see an Eloquent scope. Two of them point at sales_invoices and
purchase_invoices, owned by another module — without scoping those, a payment
could still be allocated against another company's invoice.

AccountUtility provisions a NEW company's chart of accounts from someone
else's session, so its five lookups opt out of the scope explicitly.
@HafizMMoaz
HafizMMoaz requested a review from a team as a code owner July 14, 2026 00:44
@HafizMMoaz
HafizMMoaz merged commit cdab87d into master Jul 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant