Compare commits

..

No commits in common. "feat/clarify-workspace-scope-architecture" and "dev" have entirely different histories.

2 changed files with 32 additions and 61 deletions

View file

@ -16,39 +16,19 @@ use Illuminate\Support\Collection;
* Trait for models that belong to a workspace. * Trait for models that belong to a workspace.
* *
* SECURITY: This trait enforces workspace isolation by: * SECURITY: This trait enforces workspace isolation by:
* 1. Registering WorkspaceScope as a global scope (auto-filters ALL queries) * 1. Auto-assigning workspace_id on create (throws if no context)
* 2. Auto-assigning workspace_id on create (throws if no context) * 2. Scoping queries to current workspace
* 3. Providing workspace-scoped caching with auto-invalidation * 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: * Usage:
* class Account extends Model { * class Account extends Model {
* use BelongsToWorkspace; * use BelongsToWorkspace;
* } * }
* *
* // All queries are automatically scoped to current workspace: * // Get cached collection for 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(); * $accounts = Account::ownedByCurrentWorkspaceCached();
* *
* // Explicit local scope (kept for backward compatibility): * // Get query scoped to current workspace
* $accounts = Account::ownedByCurrentWorkspace()->where('status', 'active')->get(); * $accounts = Account::ownedByCurrentWorkspace()->where('status', 'active')->get();
* *
* To opt out of strict mode (not recommended): * To opt out of strict mode (not recommended):
@ -74,24 +54,13 @@ use Illuminate\Support\Collection;
trait BelongsToWorkspace trait BelongsToWorkspace
{ {
/** /**
* Boot the trait - registers WorkspaceScope as a global scope, * Boot the trait - sets up auto-assignment of workspace_id and cache invalidation.
* sets up auto-assignment of workspace_id, and cache invalidation.
* *
* SECURITY: The global scope ensures ALL queries on models using this * SECURITY: Throws MissingWorkspaceContextException when creating without workspace context,
* 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. * unless the model has opted out with $workspaceContextRequired = false.
*/ */
protected static function bootBelongsToWorkspace(): void 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 // Auto-assign workspace_id when creating a model without one
static::creating(function ($model) { static::creating(function ($model) {
if (empty($model->workspace_id)) { if (empty($model->workspace_id)) {
@ -163,21 +132,16 @@ trait BelongsToWorkspace
} }
/** /**
* Scope query to the current workspace (explicit local scope). * Scope query to the current user's default workspace.
* *
* NOTE: Since WorkspaceScope is registered as a global scope, all queries * SECURITY: Throws MissingWorkspaceContextException when no workspace context
* are already filtered to the current workspace automatically. This local * is available and strict mode is enabled.
* 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 * @throws MissingWorkspaceContextException When workspace context is missing in strict mode
*/ */
public function scopeOwnedByCurrentWorkspace(Builder $query): Builder public function scopeOwnedByCurrentWorkspace(Builder $query): Builder
{ {
$workspace = Workspace::current(); $workspace = static::getCurrentWorkspace();
if ($workspace) { if ($workspace) {
return $query->where('workspace_id', $workspace->id); return $query->where('workspace_id', $workspace->id);
@ -219,7 +183,7 @@ trait BelongsToWorkspace
*/ */
public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection
{ {
$workspace = Workspace::current(); $workspace = static::getCurrentWorkspace();
if ($workspace) { if ($workspace) {
return static::getWorkspaceCacheManager()->rememberModel( return static::getWorkspaceCacheManager()->rememberModel(
@ -324,15 +288,31 @@ trait BelongsToWorkspace
} }
/** /**
* Get the current workspace context. * Get the current user's default workspace.
* *
* Delegates to Workspace::current() which checks request attributes * First checks request attributes (set by middleware), then falls back
* (set by middleware), then authenticated user's default workspace, * to the authenticated user's default workspace.
* then subdomain resolution.
*/ */
protected static function getCurrentWorkspace(): ?Workspace protected static function getCurrentWorkspace(): ?Workspace
{ {
return Workspace::current(); // 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;
} }
/** /**

View file

@ -17,18 +17,9 @@ use Illuminate\Database\Eloquent\Scope;
* 1. Automatically filtering queries to the current workspace context * 1. Automatically filtering queries to the current workspace context
* 2. Throwing an exception when workspace context is missing (prevents silent data leaks) * 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 * Can be disabled per-query using withoutGlobalScope() when intentionally
* querying across workspaces (e.g., admin operations, CLI commands). * 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 * To opt-out a model from strict enforcement, set $workspaceScopeStrict = false
* on the model class. * on the model class.
*/ */