php-tenant/Concerns/BelongsToWorkspace.php
Claude 1c34757645
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>
2026-03-24 13:21:43 +00:00

369 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Concerns;
use Core\Tenant\Exceptions\MissingWorkspaceContextException;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Scopes\WorkspaceScope;
use Core\Tenant\Services\WorkspaceCacheManager;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Trait for models that belong to a workspace.
*
* SECURITY: This trait enforces workspace isolation by:
* 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;
* }
*
* // 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();
*
* // Explicit local scope (kept for backward compatibility):
* $accounts = Account::ownedByCurrentWorkspace()->where('status', 'active')->get();
*
* To opt out of strict mode (not recommended):
* class LegacyModel extends Model {
* use BelongsToWorkspace;
* protected bool $workspaceContextRequired = false;
* }
*
* For custom caching beyond the default ownedByCurrentWorkspace, also use HasWorkspaceCache:
* class Account extends Model {
* use BelongsToWorkspace, HasWorkspaceCache;
*
* public static function getActiveAccounts(): Collection
* {
* return static::rememberForWorkspace(
* 'active_accounts',
* 300,
* fn() => static::ownedByCurrentWorkspace()->where('status', 'active')->get()
* );
* }
* }
*/
trait BelongsToWorkspace
{
/**
* Boot the trait - registers WorkspaceScope as a global scope,
* sets up auto-assignment of workspace_id, and cache invalidation.
*
* 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)) {
$workspace = static::getCurrentWorkspace();
if ($workspace) {
$model->workspace_id = $workspace->id;
return;
}
// No workspace context - check if we should enforce
if ($model->requiresWorkspaceContext()) {
throw MissingWorkspaceContextException::forCreate(
class_basename($model)
);
}
}
});
// Clear cache on saved event (create/update)
static::saved(function ($model) {
if ($model->workspace_id) {
static::clearWorkspaceCache($model->workspace_id);
}
});
// Clear cache on deleted event
static::deleted(function ($model) {
if ($model->workspace_id) {
static::clearWorkspaceCache($model->workspace_id);
}
});
}
/**
* Determine if this model requires workspace context.
*
* Models can opt out by setting $workspaceContextRequired = false,
* but this is not recommended for security reasons.
*/
public function requiresWorkspaceContext(): bool
{
// Check model-level setting
if (property_exists($this, 'workspaceContextRequired')) {
return $this->workspaceContextRequired;
}
// Check if global strict mode is disabled
if (! WorkspaceScope::isStrictModeEnabled()) {
return false;
}
// Check if running from console (CLI commands may need to work without context)
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
// Default: require workspace context for security
return true;
}
/**
* Get the workspace this model belongs to.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Scope query to the current workspace (explicit local scope).
*
* 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 = Workspace::current();
if ($workspace) {
return $query->where('workspace_id', $workspace->id);
}
// No workspace context - check if we should enforce strict mode
if ($this->requiresWorkspaceContext()) {
throw MissingWorkspaceContextException::forScope(
class_basename($this)
);
}
// Non-strict mode: return empty result set (fail safe)
return $query->whereRaw('1 = 0');
}
/**
* Scope query to a specific workspace.
*/
public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
return $query->where('workspace_id', $workspaceId);
}
/**
* Get all models owned by the current workspace, cached.
*
* Uses the WorkspaceCacheManager for caching, which supports both
* tagged cache stores (Redis, Memcached) and non-tagged stores.
*
* SECURITY: Throws MissingWorkspaceContextException when no workspace context
* is available and strict mode is enabled.
*
* @param int|null $ttl Cache TTL in seconds (null = use config default)
*
* @throws MissingWorkspaceContextException When workspace context is missing in strict mode
*/
public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection
{
$workspace = Workspace::current();
if ($workspace) {
return static::getWorkspaceCacheManager()->rememberModel(
$workspace,
static::class,
static::getDefaultCacheKey(),
$ttl,
fn () => static::ownedByCurrentWorkspace()->get()
);
}
// No workspace context - check if we should enforce strict mode
$instance = new static;
if ($instance->requiresWorkspaceContext()) {
throw MissingWorkspaceContextException::forScope(
class_basename(static::class)
);
}
// Non-strict mode: return empty collection (fail safe)
return collect();
}
/**
* Get all models for a specific workspace, cached.
*
* @param int|null $ttl Cache TTL in seconds (null = use config default)
*/
public static function forWorkspaceCached(Workspace|int $workspace, ?int $ttl = null): Collection
{
return static::getWorkspaceCacheManager()->rememberModel(
$workspace,
static::class,
static::getDefaultCacheKey(),
$ttl,
fn () => static::forWorkspace($workspace)->get()
);
}
/**
* Get the cache key for a workspace's model collection.
*
* This generates the full cache key including the workspace-scoped prefix.
*/
public static function workspaceCacheKey(int $workspaceId): string
{
return static::getWorkspaceCacheManager()->key(
$workspaceId,
static::getDefaultCacheKey()
);
}
/**
* Get the default cache key suffix for this model.
*
* Override this in your model to customise the cache key.
*/
protected static function getDefaultCacheKey(): string
{
return class_basename(static::class).'.all';
}
/**
* Clear the cache for a workspace's model collection.
*
* This clears the default cached collection. If using HasWorkspaceCache
* for custom cached queries, you may need to clear those separately.
*/
public static function clearWorkspaceCache(int $workspaceId): void
{
static::getWorkspaceCacheManager()->forget(
$workspaceId,
static::getDefaultCacheKey()
);
}
/**
* Clear cache for all workspaces this model exists in.
*
* For tagged cache stores (Redis), this flushes all cache for this model.
* For non-tagged stores, this clears cache for workspaces the current user has access to.
*/
public static function clearAllWorkspaceCaches(): void
{
$manager = static::getWorkspaceCacheManager();
// If tags are supported, we can flush all cache for this model efficiently
if ($manager->supportsTags()) {
$manager->flushModel(static::class);
return;
}
// For non-tagged stores, clear for all workspaces the current user has access to
$user = auth()->user();
if ($user && method_exists($user, 'hostWorkspaces')) {
foreach ($user->hostWorkspaces as $workspace) {
static::clearWorkspaceCache($workspace->id);
}
}
}
/**
* Get the current workspace context.
*
* 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
{
return Workspace::current();
}
/**
* Check if this model belongs to the given workspace.
*/
public function belongsToWorkspace(Workspace|int $workspace): bool
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
return $this->workspace_id === $workspaceId;
}
/**
* Check if this model belongs to the current user's workspace.
*/
public function belongsToCurrentWorkspace(): bool
{
$workspace = static::getCurrentWorkspace();
if (! $workspace) {
return false;
}
return $this->belongsToWorkspace($workspace);
}
/**
* Get the workspace cache manager instance.
*/
protected static function getWorkspaceCacheManager(): WorkspaceCacheManager
{
return app(WorkspaceCacheManager::class);
}
}