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:
parent
c51e4310b1
commit
1c34757645
2 changed files with 61 additions and 32 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue