Skip to content

Add a shared TenantScoped trait for the module packages - #5

Merged
HafizMMoaz merged 2 commits into
mainfrom
shared-tenant-scope
Jul 14, 2026
Merged

Add a shared TenantScoped trait for the module packages#5
HafizMMoaz merged 2 commits into
mainfrom
shared-tenant-scope

Conversation

@HafizMMoaz

Copy link
Copy Markdown
Member

The cross-tenant IDOR closed in zerp/lead v1.0.3 is not a Lead-specific mistake — it is how the modules were written. A survey of the other packages:

module update/destroy actions in files that check ownership
hrm 78 5
recruitment 31 4
account 24 8
support-ticket 22 3
contract, training, product-service, budget-planner, double-entry, form-builder 37 combined 0

hrm holds employee PII and salary data.

This PR

Adds App\Models\Concerns\TenantScoped — one definition, in the app. The packages already depend on the app (App\Models\User, the creatorId() helper), so this adds no new coupling, and a security guard should not exist in twenty copies.

The trait documents the two things it cannot cover, both of which bit us in Lead:

  • exists: validation rules go through the query builder, not Eloquent, so a global scope never sees them. They must be scoped by hand.
  • Provisioning code that legitimately acts on another tenant (a new company's defaults) must opt out with withoutGlobalScope("tenant"), or signup silently re-creates its defaults on every run.

Verification

tests/Unit/TenantScopeTest.php — 4 tests over the hrm models. Removing the scope from a model fails 2 of them. Isolation was also proven against real MySQL (two tenants; each sees its own rows, blocked on the other's), inside a rolled-back transaction.

Rollout is staged, highest-risk first. hrm is zerp-pk/hrm#1; account, recruitment and support-ticket follow. Excluded by design: landing-page, aiassistant, calendar, package-template — they have no created_by, so their data is global and scoping them would blank out pages.

The cross-tenant IDOR closed in zerp/lead is not a Lead-specific mistake — it
is how the modules were written. Controllers authorise mutations with a
capability check alone, which every tenant's staff passes, so another
company's id resolves fine and is then read, edited or deleted.

The boundary belongs on the models, and the trait belongs in one place: the
packages already depend on the app (App\Models\User, the creatorId() helper),
so a single definition here beats twenty copies of a security guard.

Documents the two things it cannot cover: `exists:` rules go through the
query builder and must be scoped by hand, and provisioning code that acts on
another tenant must opt out explicitly.
@HafizMMoaz
HafizMMoaz merged commit a6760dd into main Jul 14, 2026
1 check 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