refactor: wire WorkspaceScope into BelongsToWorkspace as global scope

WorkspaceScope existed as a standalone Scope class but was never
registered as a global scope via BelongsToWorkspace. This meant
queries like Account::query()->get() would not be automatically
filtered by workspace, and the forWorkspace()/acrossWorkspaces()
macros (which call withoutGlobalScope) had no effect.

Changes:
- Add static::addGlobalScope(new WorkspaceScope) in bootBelongsToWorkspace()
- Delegate getCurrentWorkspace() to Workspace::current() (DRY)
- Update scopeOwnedByCurrentWorkspace() to use Workspace::current()
- Update ownedByCurrentWorkspaceCached() to use Workspace::current()
- Document architecture relationship between the two classes

Fixes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 13:21:24 +00:00
parent c51e4310b1
commit 1c34757645
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 61 additions and 32 deletions

View file

@ -16,19 +16,39 @@ use Illuminate\Support\Collection;
* Trait for models that belong to a workspace.
*
* SECURITY: This trait enforces workspace isolation by:
* 1. Auto-assigning workspace_id on create (throws if no context)
* 2. Scoping queries to current workspace
* 1. Registering WorkspaceScope as a global scope (auto-filters ALL queries)
* 2. Auto-assigning workspace_id on create (throws if no context)
* 3. Providing workspace-scoped caching with auto-invalidation
*
* Architecture:
* WorkspaceScope (global scope) -- automatically applied to every query via this trait.
* Filters queries to the current workspace context (Workspace::current()).
* Provides query macros: ->forWorkspace($ws), ->acrossWorkspaces().
* Strict mode throws MissingWorkspaceContextException when context is missing.
*
* BelongsToWorkspace (this trait) -- the integration point for models.
* Registers WorkspaceScope as a global scope on boot.
* Auto-assigns workspace_id on model creation.
* Provides caching helpers and workspace relationship methods.
*
* Usage:
* class Account extends Model {
* use BelongsToWorkspace;
* }
*
* // Get cached collection for current workspace
* // All queries are automatically scoped to current workspace:
* $accounts = Account::query()->get(); // filtered by workspace
*
* // Query a specific workspace (bypasses global scope):
* $accounts = Account::query()->forWorkspace($workspace)->get();
*
* // Query across all workspaces (bypasses global scope):
* $accounts = Account::query()->acrossWorkspaces()->get();
*
* // Get cached collection for current workspace:
* $accounts = Account::ownedByCurrentWorkspaceCached();
*
* // Get query scoped to current workspace
* // Explicit local scope (kept for backward compatibility):
* $accounts = Account::ownedByCurrentWorkspace()->where('status', 'active')->get();
*
* To opt out of strict mode (not recommended):
@ -54,13 +74,24 @@ use Illuminate\Support\Collection;
trait BelongsToWorkspace
{
/**
* Boot the trait - sets up auto-assignment of workspace_id and cache invalidation.
* Boot the trait - registers WorkspaceScope as a global scope,
* sets up auto-assignment of workspace_id, and cache invalidation.
*
* SECURITY: Throws MissingWorkspaceContextException when creating without workspace context,
* SECURITY: The global scope ensures ALL queries on models using this
* trait are filtered to the current workspace. This prevents accidental
* cross-tenant data access even when developers forget to add explicit
* where clauses.
*
* Throws MissingWorkspaceContextException when creating without workspace context,
* unless the model has opted out with $workspaceContextRequired = false.
*/
protected static function bootBelongsToWorkspace(): void
{
// Register WorkspaceScope as a global scope so all queries are
// automatically filtered to the current workspace context.
// This also registers the forWorkspace() and acrossWorkspaces() macros.
static::addGlobalScope(new WorkspaceScope);
// Auto-assign workspace_id when creating a model without one
static::creating(function ($model) {
if (empty($model->workspace_id)) {
@ -132,16 +163,21 @@ trait BelongsToWorkspace
}
/**
* Scope query to the current user's default workspace.
* Scope query to the current workspace (explicit local scope).
*
* SECURITY: Throws MissingWorkspaceContextException when no workspace context
* is available and strict mode is enabled.
* NOTE: Since WorkspaceScope is registered as a global scope, all queries
* are already filtered to the current workspace automatically. This local
* scope is kept for backward compatibility and for use in cached queries
* where the intent should be explicit.
*
* For new code, prefer using the global scope directly:
* Account::query()->get() // already scoped
*
* @throws MissingWorkspaceContextException When workspace context is missing in strict mode
*/
public function scopeOwnedByCurrentWorkspace(Builder $query): Builder
{
$workspace = static::getCurrentWorkspace();
$workspace = Workspace::current();
if ($workspace) {
return $query->where('workspace_id', $workspace->id);
@ -183,7 +219,7 @@ trait BelongsToWorkspace
*/
public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection
{
$workspace = static::getCurrentWorkspace();
$workspace = Workspace::current();
if ($workspace) {
return static::getWorkspaceCacheManager()->rememberModel(
@ -288,31 +324,15 @@ trait BelongsToWorkspace
}
/**
* Get the current user's default workspace.
* Get the current workspace context.
*
* First checks request attributes (set by middleware), then falls back
* to the authenticated user's default workspace.
* Delegates to Workspace::current() which checks request attributes
* (set by middleware), then authenticated user's default workspace,
* then subdomain resolution.
*/
protected static function getCurrentWorkspace(): ?Workspace
{
// First try to get from request attributes (set by middleware)
if (request()->attributes->has('workspace_model')) {
return request()->attributes->get('workspace_model');
}
// Then try to get from authenticated user
$user = auth()->user();
if (! $user) {
return null;
}
// Use the Host UK method if available
if (method_exists($user, 'defaultHostWorkspace')) {
return $user->defaultHostWorkspace();
}
return null;
return Workspace::current();
}
/**

View file

@ -17,9 +17,18 @@ use Illuminate\Database\Eloquent\Scope;
* 1. Automatically filtering queries to the current workspace context
* 2. Throwing an exception when workspace context is missing (prevents silent data leaks)
*
* Registration: This scope is registered as a global scope by the BelongsToWorkspace
* trait in its boot method. Any model using BelongsToWorkspace will have this scope
* applied automatically to all queries.
*
* Can be disabled per-query using withoutGlobalScope() when intentionally
* querying across workspaces (e.g., admin operations, CLI commands).
*
* Query macros provided by this scope (via extend()):
* ->forWorkspace($workspace) -- query a specific workspace (bypasses global scope)
* ->acrossWorkspaces() -- query all workspaces (bypasses global scope)
* ->currentWorkspaceId() -- get the current workspace ID
*
* To opt-out a model from strict enforcement, set $workspaceScopeStrict = false
* on the model class.
*/