Skip to content

Remove the abandoned CompanyScope global scope - #260

Open
roncodes wants to merge 1 commit into
mainfrom
chore/remove-abandoned-company-scope
Open

roncodes wants to merge 1 commit into
mainfrom
chore/remove-abandoned-company-scope

Conversation

@roncodes

Copy link
Copy Markdown
Member

What

Deletes src/Scopes/CompanyScope.php and its unit test, and rewrites the comments that pointed at it.

No behaviour change — the class was registered on nothing.

Why

CompanyScope was an attempt to enforce tenant isolation with a single global Eloquent scope. It was abandoned because it destabilised the application, and it has been dead code since. Verified across core-api and every extension package:

  • the only addGlobalScope(new CompanyScope()) anywhere was inside this scope's own unit test, against a throwaway test-only model;
  • the withoutCompanyScope() macro it registered was likewise only ever called from that same test;
  • every other textual hit is a comment, or an unrelated name (ReportQueryConverter::applyCompanyScope() is its own method; samsara's SamsaraCompanyScope is a middleware).

Leaving it in place was worse than merely unused, because its docblock claimed to be "the primary defence against the cross-tenant IDOR vulnerability (GHSA-3wj9-hh56-7fw7)", and HasApiModelBehavior repeated the claim:

// The CompanyScope global scope provides the primary protection; this
// explicit clause ensures the constraint survives withoutGlobalScope().

So the explicit company_uuid clauses that are actually the protection were labelled "defence-in-depth", as though a real layer sat behind them. Reading the code as though it did is how tenant-owned lookups get written with no scoping at all — see fleetbase/fleetops#331, where the order lifecycle actions resolved their targets straight from a caller-supplied uuid with no company constraint of any kind.

Why it could never have been that primary defence

Worth recording, because the docblock was persuasive:

Property Consequence
if (app()->runningInConsole()) return; No isolation in queue workers, scheduler or artisan. The same model, reached through the same code, was protected in a web request and unprotected in a job.
if (empty($companyUuid)) return; Failed open. No session company (webhooks, public tracking, installer, token flows before session setup) meant no constraint — precisely the contexts where you cannot rely on a session.
Removed by withoutGlobalScopes() Dropped as a side effect of dropping an unrelated scope. fleetops server/src alone has 83 such call sites.
Applies to relations / eager loads / whereHas Legitimate cross-company references (system-level order configs, shared places, vendor and facilitator records, admin tooling) resolved to null instead of raising — cascading nulls surfacing far from the query that caused them. This is the destabilisation.

A control with three "apply nothing" early returns cannot be a floor. Authorization that silently returns fewer rows is indistinguishable from "no data" at the call site, which is what makes this failure mode so expensive to debug.

What replaces it

Nothing new — tenant isolation stays where it already effectively was: an explicit, visible company_uuid clause at each lookup. This PR only stops the comments from lying about it:

  • the four inline comments at those clauses now say they are the tenant constraint, not a second layer, and that removing one reopens GHSA-3wj9-hh56-7fw7;
  • getById()'s docblock no longer claims the query arrives pre-scoped;
  • findRecordOrFail()'s // create query — CompanyScope global scope is applied automatically loses the false half;
  • the HasApiModelBehavior trait docblock gains a short "Tenant isolation" section recording that there is deliberately no global scope, and why, so one does not get reintroduced.

ExpiryScope is untouched. It is a legitimate global scope: opt-in per model via Expirable, single-concern, no session dependency, no authorization meaning.

Verification

  • Tests: 1435 passed, 0 failures (vendor/bin/pest). The removed test's cases are the only ones gone.
  • Lint: php-cs-fixer --dry-run clean across all 732 files.
  • Date drift: passes.
  • PHPStan: src has 12,444 pre-existing errors at level: max both before and after; the deleted file contributed exactly 1, so the count goes down by 1 and the comment edits cannot affect analysis. reportUnmatchedIgnoredErrors is safe — there are no ignoreErrors entries and no baseline file referencing the deleted path.

Notes

  • Based on main because no release/* branch is currently open (v1.6.62 is merged). Happy to retarget if you would rather this ride a release branch, and to add a RELEASE.md entry there.
  • A follow-up PR will add the contract test that flags unscoped tenant-owned lookups at review time, so omission is caught by CI rather than by discipline. Keeping it separate as discussed.

CompanyScope was an attempt to enforce tenant isolation with a single global
Eloquent scope. It was abandoned because it destabilised the application, and it
has been dead code since: the only `addGlobalScope(new CompanyScope())` anywhere
across core-api and the extension packages was inside this scope's own unit
test, against a throwaway test-only model, and the `withoutCompanyScope()` macro
it registered was likewise only ever called from that test.

Leaving it in place was worse than unused, because its docblock claimed to be
"the primary defence against the cross-tenant IDOR vulnerability
(GHSA-3wj9-hh56-7fw7)" and `HasApiModelBehavior` repeated that claim. The
explicit `company_uuid` clauses that actually provide the protection were
therefore labelled "defence-in-depth", as though a real layer sat behind them.
It does not, and reading the code as though it did is how tenant-scoped lookups
get written with no scoping at all — see fleetbase/fleetops#331, where the order
lifecycle actions resolved their targets by caller-supplied uuid with no company
constraint of any kind.

The design could not have served as that primary defence:

- It bailed out during console execution, so isolation was absent in queue
  workers, the scheduler and artisan — the same model reached through the same
  code was protected in a web request and unprotected in a job.
- It bailed out when no session company was set, i.e. it failed open in exactly
  the contexts where a session is missing (webhooks, public tracking, installer,
  token flows before session setup).
- Any `withoutGlobalScopes()` call dropped it as a side effect of dropping an
  unrelated scope. fleetops `server/src` alone has 83 such call sites.
- Global scopes also apply to relations, eager loads and `whereHas`, so
  legitimate cross-company references resolved to null rather than raising,
  surfacing as cascading nulls far from the query that caused them.

Tenant isolation stays where it already is: an explicit, visible `company_uuid`
clause at each lookup. The comments at those clauses are rewritten to say they
are the tenant constraint rather than a second layer, and the trait docblock
records why there is no global scope so one does not get reintroduced.

No behaviour change: the class was registered nowhere. `ExpiryScope` is
untouched — it is opt-in per model via `Expirable`, single-concern, and carries
no authorization meaning.
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (a67d303) to head (5b8e48d).

Additional details and impacted files
@@             Coverage Diff             @@
##                main      #260   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
+ Complexity      6765      6757    -8     
===========================================
  Files            398       397    -1     
  Lines          22554     22534   -20     
===========================================
- Hits           22554     22534   -20     
Flag Coverage Δ
backend 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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