Skip to content

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

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

Confine every Support Ticket 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. Depends on zerp-pk/zerp#6.

22 update/destroy actions were guarded by a capability check alone, which every tenant's staff passes. Another company's tickets, contacts and knowledge base articles resolved fine and were then read, edited or deleted.

The fix

12 models scoped via the shared App\Models\Concerns\TenantScoped. ticket_field_values has no created_by column of its own, so it declares $tenantParent = "ticket" and inherits the boundary through its parent.

The public help centre

It serves one company's FAQ, knowledge base and ticket lookup to the world, addressed by the slug in the URL — a visitor signed in to a different company must still see it. Its middleware stands the scope down for that request (zerp-pk/zerp#6).

The public ticket form is validated against the portal's company, not creatorId(). The submitter is usually a guest, for whom creatorId() means nothing — so StoreFrontendTicketRequest resolves the company from the slug and validates the category against that. Left as it was, a submitter could file a ticket under another company's category; scoped to creatorId() instead, no guest could ever file one at all.

Provisioning opt-outs

SupporUtility (default ticket categories), TicketField (default fields) and SupportTicketSetting::getAllByCompany() all read a company other than the caller's — the first two when provisioning a new company, the third from the public portal. All three opt out with withoutGlobalScope("tenant"). Without that, signup would silently re-seed defaults on every run and the portal would render with no settings.

Verification

Against real MySQL: in admin context each tenant is blocked from the other's ticket categories; in portal context an acme-authenticated visitor still reads beta's help centre. Covered by tests/Unit/TenantScopeTest.php.

Same cross-tenant IDOR as the other modules: 22 update/destroy actions
guarded by a capability check alone, which every tenant's staff passes, so
another company's tickets, contacts and knowledge base resolved fine and were
then read, edited or deleted.

12 models are scoped via App\Models\Concerns\TenantScoped. ticket_field_values
has no created_by column and inherits the boundary through its parent ticket.

The public help centre is addressed by a slug in the URL and must remain
readable by a visitor signed in to another company, so its middleware stands
the scope down for the request (see zerp-pk/zerp#6). The public ticket form is
validated against the PORTAL's company, resolved from the slug, not against
creatorId() — the submitter is usually a guest, for whom creatorId() means
nothing.

SupporUtility, TicketField and SupportTicketSetting::getAllByCompany() all
read a company other than the caller's, so they opt out explicitly.
@HafizMMoaz
HafizMMoaz requested a review from a team as a code owner July 14, 2026 01:24
@HafizMMoaz
HafizMMoaz merged commit 1b3f416 into master Jul 16, 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