diff --git a/Concerns/BelongsToWorkspace.php b/Concerns/BelongsToWorkspace.php index 43035c8..5361db1 100644 --- a/Concerns/BelongsToWorkspace.php +++ b/Concerns/BelongsToWorkspace.php @@ -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(); } /** diff --git a/Scopes/WorkspaceScope.php b/Scopes/WorkspaceScope.php index cf0810b..f6780ba 100644 --- a/Scopes/WorkspaceScope.php +++ b/Scopes/WorkspaceScope.php @@ -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. */