diff --git a/app/Console/Commands/TenantpilotBackfillWorkspaceIds.php b/app/Console/Commands/TenantpilotBackfillWorkspaceIds.php new file mode 100644 index 0000000..b7ae116 --- /dev/null +++ b/app/Console/Commands/TenantpilotBackfillWorkspaceIds.php @@ -0,0 +1,343 @@ +resolveTables(); + + if ($tables === []) { + return self::FAILURE; + } + + $batchSize = max(1, (int) $this->option('batch-size')); + $resumeFrom = max(0, (int) $this->option('resume-from')); + $maxRows = $this->normalizeMaxRows(); + $dryRun = (bool) $this->option('dry-run'); + + $lock = Cache::lock('tenantpilot:backfill-workspace-ids', 900); + + if (! $lock->get()) { + $this->error('Another workspace backfill is already running.'); + + return self::FAILURE; + } + + try { + $tableStats = $this->collectTableStats($tables); + + $this->table( + ['Table', 'Missing workspace_id', 'Unresolvable tenant mapping', 'Sample row ids'], + array_map(static function (array $stats): array { + return [ + $stats['table'], + $stats['missing'], + $stats['unresolvable'], + $stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']), + ]; + }, $tableStats), + ); + + $unresolvable = array_values(array_filter($tableStats, static fn (array $stats): bool => $stats['unresolvable'] > 0)); + + if ($unresolvable !== []) { + foreach ($unresolvable as $stats) { + $this->error(sprintf( + 'Unresolvable tenant->workspace mapping in %s (%d rows). Sample ids: %s', + $stats['table'], + $stats['unresolvable'], + $stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']), + )); + } + + return self::FAILURE; + } + + if ($dryRun) { + $this->info('Dry-run complete. No changes written.'); + + return self::SUCCESS; + } + + $workspaceWorkloads = $this->collectWorkspaceWorkloads($tables, $maxRows); + + if ($workspaceWorkloads === []) { + $this->info('No rows require workspace_id backfill.'); + + return self::SUCCESS; + } + + $dispatchedJobs = 0; + + foreach ($workspaceWorkloads as $workspaceId => $workload) { + $workspace = Workspace::query()->find($workspaceId); + + if (! $workspace instanceof Workspace) { + continue; + } + + $run = $operationRunService->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: 'workspace_isolation_backfill_workspace_ids', + identityInputs: [ + 'tables' => array_keys($workload['tables']), + ], + context: [ + 'source' => 'tenantpilot:backfill-workspace-ids', + 'workspace_id' => (int) $workspace->getKey(), + 'batch_size' => $batchSize, + 'max_rows' => $maxRows, + 'resume_from' => $resumeFrom, + 'tables' => array_keys($workload['tables']), + ], + ); + + if (! $run->wasRecentlyCreated) { + $this->line(sprintf( + 'Workspace %d already has an active backfill run (#%d).', + (int) $workspace->getKey(), + (int) $run->getKey(), + )); + + continue; + } + + $tableProgress = []; + foreach ($workload['tables'] as $table => $count) { + $tableProgress[$table] = [ + 'target_rows' => (int) $count, + 'processed' => 0, + 'last_processed_id' => $resumeFrom, + ]; + } + + $context = is_array($run->context) ? $run->context : []; + $context['table_progress'] = $tableProgress; + + $run->update([ + 'context' => $context, + 'summary_counts' => [ + 'total' => (int) $workload['total'], + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + ], + ]); + + $operationRunService->updateRun($run, status: 'running'); + + $workspaceAuditLogger->log( + workspace: $workspace, + action: 'workspace_isolation.backfill_workspace_ids.started', + context: [ + 'operation_run_id' => (int) $run->getKey(), + 'tables' => array_keys($workload['tables']), + 'planned_rows' => (int) $workload['total'], + 'batch_size' => $batchSize, + ], + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $run->getKey(), + ); + + $workspaceJobs = 0; + + foreach ($workload['tables'] as $table => $tableRows) { + if ($tableRows <= 0) { + continue; + } + + BackfillWorkspaceIdsJob::dispatch( + operationRunId: (int) $run->getKey(), + workspaceId: (int) $workspace->getKey(), + table: $table, + batchSize: $batchSize, + maxRows: $maxRows, + resumeFrom: $resumeFrom, + ); + + $workspaceJobs++; + $dispatchedJobs++; + } + + $workspaceAuditLogger->log( + workspace: $workspace, + action: 'workspace_isolation.backfill_workspace_ids.dispatched', + context: [ + 'operation_run_id' => (int) $run->getKey(), + 'jobs_dispatched' => $workspaceJobs, + 'tables' => array_keys($workload['tables']), + ], + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $run->getKey(), + ); + + $this->line(sprintf( + 'Workspace %d run #%d queued (%d job(s)).', + (int) $workspace->getKey(), + (int) $run->getKey(), + $workspaceJobs, + )); + } + + $this->info(sprintf('Backfill jobs dispatched: %d', $dispatchedJobs)); + + return self::SUCCESS; + } finally { + $lock->release(); + } + } + + /** + * @return array + */ + private function resolveTables(): array + { + $selectedTable = $this->option('table'); + + if (! is_string($selectedTable) || trim($selectedTable) === '') { + return TenantOwnedTables::all(); + } + + $selectedTable = trim($selectedTable); + + if (! TenantOwnedTables::contains($selectedTable)) { + $this->error(sprintf('Unknown tenant-owned table: %s', $selectedTable)); + + return []; + } + + return [$selectedTable]; + } + + private function normalizeMaxRows(): ?int + { + $maxRows = $this->option('max-rows'); + + if (! is_numeric($maxRows)) { + return null; + } + + $maxRows = (int) $maxRows; + + return $maxRows > 0 ? $maxRows : null; + } + + /** + * @param array $tables + * @return array}> + */ + private function collectTableStats(array $tables): array + { + $stats = []; + + foreach ($tables as $table) { + $missing = (int) DB::table($table)->whereNull('workspace_id')->count(); + + $unresolvableQuery = DB::table($table) + ->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table)) + ->whereNull(sprintf('%s.workspace_id', $table)) + ->where(function ($query): void { + $query->whereNull('tenants.id') + ->orWhereNull('tenants.workspace_id'); + }); + + $unresolvable = (int) $unresolvableQuery->count(); + + $sampleIds = DB::table($table) + ->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table)) + ->whereNull(sprintf('%s.workspace_id', $table)) + ->where(function ($query): void { + $query->whereNull('tenants.id') + ->orWhereNull('tenants.workspace_id'); + }) + ->orderBy(sprintf('%s.id', $table)) + ->limit(5) + ->pluck(sprintf('%s.id', $table)) + ->map(static fn (mixed $id): int => (int) $id) + ->values() + ->all(); + + $stats[] = [ + 'table' => $table, + 'missing' => $missing, + 'unresolvable' => $unresolvable, + 'sample_ids' => $sampleIds, + ]; + } + + return $stats; + } + + /** + * @param array $tables + * @return array}> + */ + private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array + { + $workloads = []; + + foreach ($tables as $table) { + $rows = DB::table($table) + ->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table)) + ->whereNull(sprintf('%s.workspace_id', $table)) + ->whereNotNull('tenants.workspace_id') + ->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count') + ->groupBy('tenants.workspace_id') + ->get(); + + foreach ($rows as $row) { + $workspaceId = (int) $row->workspace_id; + + if ($workspaceId <= 0) { + continue; + } + + $rowCount = (int) $row->row_count; + + if ($maxRows !== null) { + $rowCount = min($rowCount, $maxRows); + } + + if ($rowCount <= 0) { + continue; + } + + if (! isset($workloads[$workspaceId])) { + $workloads[$workspaceId] = [ + 'total' => 0, + 'tables' => [], + ]; + } + + $workloads[$workspaceId]['tables'][$table] = $rowCount; + $workloads[$workspaceId]['total'] += $rowCount; + } + } + + return $workloads; + } +} diff --git a/app/Jobs/BackfillWorkspaceIdsJob.php b/app/Jobs/BackfillWorkspaceIdsJob.php new file mode 100644 index 0000000..44b5bb4 --- /dev/null +++ b/app/Jobs/BackfillWorkspaceIdsJob.php @@ -0,0 +1,207 @@ +table)) { + return; + } + + $run = OperationRun::query()->find($this->operationRunId); + + if (! $run instanceof OperationRun) { + return; + } + + if ((int) $run->workspace_id !== $this->workspaceId) { + return; + } + + try { + if ($run->status === 'queued') { + $operationRunService->updateRun($run, status: 'running'); + } + + $processed = 0; + $cursor = max(0, $this->resumeFrom); + $batchSize = max(1, $this->batchSize); + + while (true) { + $ids = DB::table($this->table) + ->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $this->table)) + ->whereNull(sprintf('%s.workspace_id', $this->table)) + ->where(sprintf('%s.id', $this->table), '>', $cursor) + ->where('tenants.workspace_id', $this->workspaceId) + ->orderBy(sprintf('%s.id', $this->table)) + ->limit($batchSize) + ->pluck(sprintf('%s.id', $this->table)) + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + if ($ids === []) { + break; + } + + if ($this->maxRows !== null) { + $remaining = $this->maxRows - $processed; + + if ($remaining <= 0) { + break; + } + + if (count($ids) > $remaining) { + $ids = array_slice($ids, 0, $remaining); + } + } + + $updated = DB::table($this->table) + ->whereIn('id', $ids) + ->whereNull('workspace_id') + ->update(['workspace_id' => $this->workspaceId]); + + $processed += $updated; + $cursor = max($ids); + + if ($updated > 0) { + $operationRunService->incrementSummaryCounts($run, [ + 'processed' => $updated, + 'succeeded' => $updated, + ]); + } + + $this->persistTableProgress($cursor, $updated); + + if ($this->maxRows !== null && $processed >= $this->maxRows) { + break; + } + } + + if ($this->remainingRows() === 0) { + $this->reconcileTableProgressWithPlannedTotal($operationRunService, $run, $cursor); + } + + $run->refresh(); + $statusBeforeCompletion = $run->status; + $operationRunService->maybeCompleteBulkRun($run); + $run->refresh(); + + if ($statusBeforeCompletion !== 'completed' && $run->status === 'completed') { + $workspace = Workspace::query()->find($this->workspaceId); + + if ($workspace instanceof Workspace) { + $workspaceAuditLogger->log( + workspace: $workspace, + action: 'workspace_isolation.backfill_workspace_ids.completed', + context: [ + 'operation_run_id' => (int) $run->getKey(), + 'table' => $this->table, + 'outcome' => (string) $run->outcome, + ], + status: (string) $run->outcome, + resourceType: 'operation_run', + resourceId: (string) $run->getKey(), + ); + } + } + } catch (QueryException $e) { + $operationRunService->appendFailures($run, [ + [ + 'code' => 'workspace_backfill.query_failed', + 'message' => $e->getMessage(), + ], + ]); + + $operationRunService->incrementSummaryCounts($run, ['failed' => 1]); + $operationRunService->maybeCompleteBulkRun($run); + + throw $e; + } + } + + private function remainingRows(): int + { + return (int) DB::table($this->table) + ->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $this->table)) + ->whereNull(sprintf('%s.workspace_id', $this->table)) + ->where('tenants.workspace_id', $this->workspaceId) + ->count(); + } + + private function reconcileTableProgressWithPlannedTotal( + OperationRunService $operationRunService, + OperationRun $run, + int $lastProcessedId, + ): void { + $run->refresh(); + + $targetRows = (int) data_get($run->context, sprintf('table_progress.%s.target_rows', $this->table), 0); + $processedRows = (int) data_get($run->context, sprintf('table_progress.%s.processed', $this->table), 0); + $remaining = max(0, $targetRows - $processedRows); + + if ($remaining <= 0) { + return; + } + + $operationRunService->incrementSummaryCounts($run, [ + 'processed' => $remaining, + 'succeeded' => $remaining, + ]); + + $this->persistTableProgress($lastProcessedId, $remaining); + } + + private function persistTableProgress(int $lastProcessedId, int $processedDelta): void + { + DB::transaction(function () use ($lastProcessedId, $processedDelta): void { + $run = OperationRun::query() + ->whereKey($this->operationRunId) + ->lockForUpdate() + ->first(); + + if (! $run instanceof OperationRun) { + return; + } + + $context = is_array($run->context) ? $run->context : []; + $tableProgress = is_array(data_get($context, sprintf('table_progress.%s', $this->table))) + ? data_get($context, sprintf('table_progress.%s', $this->table)) + : []; + + $tableProgress['last_processed_id'] = $lastProcessedId; + $tableProgress['processed'] = (int) ($tableProgress['processed'] ?? 0) + $processedDelta; + + $context['table_progress'][$this->table] = $tableProgress; + + $run->update(['context' => $context]); + }); + } +} diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php index e1696a9..3422e05 100644 --- a/app/Models/BackupItem.php +++ b/app/Models/BackupItem.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\InteractsWithODataTypes; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,6 +11,7 @@ class BackupItem extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use InteractsWithODataTypes; use SoftDeletes; diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php index de28158..10cdd47 100644 --- a/app/Models/BackupSchedule.php +++ b/app/Models/BackupSchedule.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,6 +11,7 @@ class BackupSchedule extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use SoftDeletes; diff --git a/app/Models/BackupSet.php b/app/Models/BackupSet.php index c6757b8..ae6d569 100644 --- a/app/Models/BackupSet.php +++ b/app/Models/BackupSet.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,6 +11,7 @@ class BackupSet extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use SoftDeletes; diff --git a/app/Models/EntraGroup.php b/app/Models/EntraGroup.php index e8f4b27..963449a 100644 --- a/app/Models/EntraGroup.php +++ b/app/Models/EntraGroup.php @@ -2,12 +2,14 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class EntraGroup extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; protected $guarded = []; diff --git a/app/Models/EntraRoleDefinition.php b/app/Models/EntraRoleDefinition.php index 85e52dc..956c4ab 100644 --- a/app/Models/EntraRoleDefinition.php +++ b/app/Models/EntraRoleDefinition.php @@ -2,12 +2,14 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class EntraRoleDefinition extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; protected $guarded = []; diff --git a/app/Models/Finding.php b/app/Models/Finding.php index e236895..7ad310d 100644 --- a/app/Models/Finding.php +++ b/app/Models/Finding.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,6 +10,8 @@ class Finding extends Model { /** @use HasFactory<\Database\Factories\FindingFactory> */ + use DerivesWorkspaceIdFromTenant; + use HasFactory; public const string FINDING_TYPE_DRIFT = 'drift'; diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php index 096b53a..310359a 100644 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,6 +10,8 @@ class InventoryItem extends Model { /** @use HasFactory<\Database\Factories\InventoryItemFactory> */ + use DerivesWorkspaceIdFromTenant; + use HasFactory; protected $guarded = []; diff --git a/app/Models/InventoryLink.php b/app/Models/InventoryLink.php index ee061d4..e3a7157 100644 --- a/app/Models/InventoryLink.php +++ b/app/Models/InventoryLink.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class InventoryLink extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; protected $table = 'inventory_links'; @@ -19,4 +22,9 @@ protected function casts(): array 'metadata' => 'array', ]; } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } } diff --git a/app/Models/Policy.php b/app/Models/Policy.php index da28fd0..ee1fd93 100644 --- a/app/Models/Policy.php +++ b/app/Models/Policy.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\InteractsWithODataTypes; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,6 +11,7 @@ class Policy extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use InteractsWithODataTypes; diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index 0994797..a3d19bc 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,6 +10,7 @@ class PolicyVersion extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use SoftDeletes; diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index a340de5..a9de10f 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\RestoreRunStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,6 +12,7 @@ class RestoreRun extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; use SoftDeletes; diff --git a/app/Models/TenantPermission.php b/app/Models/TenantPermission.php index da85161..1c95fd0 100644 --- a/app/Models/TenantPermission.php +++ b/app/Models/TenantPermission.php @@ -2,12 +2,14 @@ namespace App\Models; +use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class TenantPermission extends Model { + use DerivesWorkspaceIdFromTenant; use HasFactory; protected $guarded = []; diff --git a/app/Services/Intune/AuditLogger.php b/app/Services/Intune/AuditLogger.php index a5a14ae..7c87de1 100644 --- a/app/Services/Intune/AuditLogger.php +++ b/app/Services/Intune/AuditLogger.php @@ -6,6 +6,7 @@ use App\Models\Tenant; use App\Support\Audit\AuditContextSanitizer; use Carbon\CarbonImmutable; +use InvalidArgumentException; class AuditLogger { @@ -26,9 +27,15 @@ public function log( $metadata = is_array($metadata) ? $metadata : []; $sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context); + $workspaceId = is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null; + + if ($workspaceId === null) { + throw new InvalidArgumentException('Tenant-scoped audit events require tenant workspace_id.'); + } return AuditLog::create([ 'tenant_id' => $tenant->id, + 'workspace_id' => $workspaceId, 'actor_id' => $actorId, 'actor_email' => $actorEmail, 'actor_name' => $actorName, diff --git a/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php b/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php new file mode 100644 index 0000000..9600102 --- /dev/null +++ b/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php @@ -0,0 +1,115 @@ +getAttribute('workspace_id'); + + if ($workspaceId === null || $workspaceId === '') { + $model->setAttribute('workspace_id', $tenantWorkspaceId); + + return; + } + + if (! is_numeric($workspaceId)) { + throw WorkspaceIsolationViolation::workspaceMismatch( + class_basename($model), + $tenantId, + $tenantWorkspaceId, + 0, + ); + } + + $workspaceId = (int) $workspaceId; + + if ($workspaceId !== $tenantWorkspaceId) { + throw WorkspaceIsolationViolation::workspaceMismatch( + class_basename($model), + $tenantId, + $tenantWorkspaceId, + $workspaceId, + ); + } + } + + private static function resolveTenantId(Model $model): int + { + $tenantId = $model->getAttribute('tenant_id'); + + if (! is_numeric($tenantId)) { + throw WorkspaceIsolationViolation::missingTenantId(class_basename($model)); + } + + return (int) $tenantId; + } + + private static function ensureTenantIdIsImmutable(Model $model, int $tenantId): void + { + if (! $model->exists || ! $model->isDirty('tenant_id')) { + return; + } + + $originalTenantId = $model->getOriginal('tenant_id'); + + if (! is_numeric($originalTenantId)) { + return; + } + + $originalTenantId = (int) $originalTenantId; + + if ($originalTenantId === $tenantId) { + return; + } + + throw WorkspaceIsolationViolation::tenantImmutable( + class_basename($model), + $originalTenantId, + $tenantId, + ); + } + + private static function resolveTenantWorkspaceId(Model $model, int $tenantId): int + { + $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; + + if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { + $tenant = Tenant::query()->find($tenantId); + } + + if (! $tenant instanceof Tenant) { + throw WorkspaceIsolationViolation::tenantNotFound(class_basename($model), $tenantId); + } + + if (! is_numeric($tenant->workspace_id)) { + throw WorkspaceIsolationViolation::tenantWorkspaceMissing(class_basename($model), $tenantId); + } + + return (int) $tenant->workspace_id; + } +} diff --git a/app/Support/WorkspaceIsolation/TenantOwnedTables.php b/app/Support/WorkspaceIsolation/TenantOwnedTables.php new file mode 100644 index 0000000..3a5ea5d --- /dev/null +++ b/app/Support/WorkspaceIsolation/TenantOwnedTables.php @@ -0,0 +1,34 @@ + + */ + public static function all(): array + { + return [ + 'policies', + 'policy_versions', + 'backup_sets', + 'backup_items', + 'restore_runs', + 'backup_schedules', + 'inventory_items', + 'inventory_links', + 'entra_groups', + 'findings', + 'entra_role_definitions', + 'tenant_permissions', + ]; + } + + public static function contains(string $table): bool + { + return in_array($table, self::all(), true); + } +} diff --git a/app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php b/app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php new file mode 100644 index 0000000..9f3507d --- /dev/null +++ b/app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php @@ -0,0 +1,46 @@ + %d).', + $modelClass, + $originalTenantId, + $updatedTenantId, + )); + } +} diff --git a/database/migrations/2026_02_14_220101_add_workspace_id_to_policies_table.php b/database/migrations/2026_02_14_220101_add_workspace_id_to_policies_table.php new file mode 100644 index 0000000..c11cc3d --- /dev/null +++ b/database/migrations/2026_02_14_220101_add_workspace_id_to_policies_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('policies') || ! Schema::hasColumn('policies', 'workspace_id')) { + return; + } + + Schema::table('policies', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220102_add_workspace_id_to_policy_versions_table.php b/database/migrations/2026_02_14_220102_add_workspace_id_to_policy_versions_table.php new file mode 100644 index 0000000..805b325 --- /dev/null +++ b/database/migrations/2026_02_14_220102_add_workspace_id_to_policy_versions_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('policy_versions') || ! Schema::hasColumn('policy_versions', 'workspace_id')) { + return; + } + + Schema::table('policy_versions', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220103_add_workspace_id_to_backup_sets_table.php b/database/migrations/2026_02_14_220103_add_workspace_id_to_backup_sets_table.php new file mode 100644 index 0000000..3b75110 --- /dev/null +++ b/database/migrations/2026_02_14_220103_add_workspace_id_to_backup_sets_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('backup_sets') || ! Schema::hasColumn('backup_sets', 'workspace_id')) { + return; + } + + Schema::table('backup_sets', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220104_add_workspace_id_to_backup_items_table.php b/database/migrations/2026_02_14_220104_add_workspace_id_to_backup_items_table.php new file mode 100644 index 0000000..d9b496f --- /dev/null +++ b/database/migrations/2026_02_14_220104_add_workspace_id_to_backup_items_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('backup_items') || ! Schema::hasColumn('backup_items', 'workspace_id')) { + return; + } + + Schema::table('backup_items', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220105_add_workspace_id_to_restore_runs_table.php b/database/migrations/2026_02_14_220105_add_workspace_id_to_restore_runs_table.php new file mode 100644 index 0000000..611e9d5 --- /dev/null +++ b/database/migrations/2026_02_14_220105_add_workspace_id_to_restore_runs_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('restore_runs') || ! Schema::hasColumn('restore_runs', 'workspace_id')) { + return; + } + + Schema::table('restore_runs', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220106_add_workspace_id_to_backup_schedules_table.php b/database/migrations/2026_02_14_220106_add_workspace_id_to_backup_schedules_table.php new file mode 100644 index 0000000..d264f5f --- /dev/null +++ b/database/migrations/2026_02_14_220106_add_workspace_id_to_backup_schedules_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('backup_schedules') || ! Schema::hasColumn('backup_schedules', 'workspace_id')) { + return; + } + + Schema::table('backup_schedules', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220107_add_workspace_id_to_inventory_items_table.php b/database/migrations/2026_02_14_220107_add_workspace_id_to_inventory_items_table.php new file mode 100644 index 0000000..549652c --- /dev/null +++ b/database/migrations/2026_02_14_220107_add_workspace_id_to_inventory_items_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('inventory_items') || ! Schema::hasColumn('inventory_items', 'workspace_id')) { + return; + } + + Schema::table('inventory_items', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220108_add_workspace_id_to_inventory_links_table.php b/database/migrations/2026_02_14_220108_add_workspace_id_to_inventory_links_table.php new file mode 100644 index 0000000..c7ad9d3 --- /dev/null +++ b/database/migrations/2026_02_14_220108_add_workspace_id_to_inventory_links_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('inventory_links') || ! Schema::hasColumn('inventory_links', 'workspace_id')) { + return; + } + + Schema::table('inventory_links', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220109_add_workspace_id_to_entra_groups_table.php b/database/migrations/2026_02_14_220109_add_workspace_id_to_entra_groups_table.php new file mode 100644 index 0000000..715b9b3 --- /dev/null +++ b/database/migrations/2026_02_14_220109_add_workspace_id_to_entra_groups_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('entra_groups') || ! Schema::hasColumn('entra_groups', 'workspace_id')) { + return; + } + + Schema::table('entra_groups', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220110_add_workspace_id_to_findings_table.php b/database/migrations/2026_02_14_220110_add_workspace_id_to_findings_table.php new file mode 100644 index 0000000..d0ae026 --- /dev/null +++ b/database/migrations/2026_02_14_220110_add_workspace_id_to_findings_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('findings') || ! Schema::hasColumn('findings', 'workspace_id')) { + return; + } + + Schema::table('findings', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220111_add_workspace_id_to_entra_role_definitions_table.php b/database/migrations/2026_02_14_220111_add_workspace_id_to_entra_role_definitions_table.php new file mode 100644 index 0000000..91c5754 --- /dev/null +++ b/database/migrations/2026_02_14_220111_add_workspace_id_to_entra_role_definitions_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('entra_role_definitions') || ! Schema::hasColumn('entra_role_definitions', 'workspace_id')) { + return; + } + + Schema::table('entra_role_definitions', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220112_add_workspace_id_to_tenant_permissions_table.php b/database/migrations/2026_02_14_220112_add_workspace_id_to_tenant_permissions_table.php new file mode 100644 index 0000000..6a57732 --- /dev/null +++ b/database/migrations/2026_02_14_220112_add_workspace_id_to_tenant_permissions_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('workspace_id')->nullable(); + $table->index('workspace_id'); + $table->index(['workspace_id', 'tenant_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('tenant_permissions') || ! Schema::hasColumn('tenant_permissions', 'workspace_id')) { + return; + } + + Schema::table('tenant_permissions', function (Blueprint $table): void { + $table->dropIndex(['workspace_id', 'tenant_id']); + $table->dropIndex(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220113_add_tenants_id_workspace_id_unique.php b/database/migrations/2026_02_14_220113_add_tenants_id_workspace_id_unique.php new file mode 100644 index 0000000..ceb49a3 --- /dev/null +++ b/database/migrations/2026_02_14_220113_add_tenants_id_workspace_id_unique.php @@ -0,0 +1,30 @@ +unique(['id', 'workspace_id'], 'tenants_id_workspace_id_unique'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('tenants')) { + return; + } + + Schema::table('tenants', function (Blueprint $table): void { + $table->dropUnique('tenants_id_workspace_id_unique'); + }); + } +}; diff --git a/database/migrations/2026_02_14_220114_enforce_workspace_id_not_null_on_tenant_owned_tables.php b/database/migrations/2026_02_14_220114_enforce_workspace_id_not_null_on_tenant_owned_tables.php new file mode 100644 index 0000000..ca3d35f --- /dev/null +++ b/database/migrations/2026_02_14_220114_enforce_workspace_id_not_null_on_tenant_owned_tables.php @@ -0,0 +1,87 @@ + + */ + private function tenantOwnedTables(): array + { + return [ + 'policies', + 'policy_versions', + 'backup_sets', + 'backup_items', + 'restore_runs', + 'backup_schedules', + 'inventory_items', + 'inventory_links', + 'entra_groups', + 'findings', + 'entra_role_definitions', + 'tenant_permissions', + ]; + } + + public function up(): void + { + $driver = DB::getDriverName(); + + if (! in_array($driver, ['pgsql', 'mysql'], true)) { + return; + } + + foreach ($this->tenantOwnedTables() as $table) { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'workspace_id')) { + continue; + } + + $missingCount = (int) DB::table($table) + ->whereNull('workspace_id') + ->count(); + + if ($missingCount > 0) { + throw new \RuntimeException(sprintf( + 'Cannot enforce NOT NULL on %s.workspace_id while %d rows are missing a workspace binding.', + $table, + $missingCount, + )); + } + + if ($driver === 'pgsql') { + DB::statement(sprintf('ALTER TABLE %s ALTER COLUMN workspace_id SET NOT NULL', $table)); + } + + if ($driver === 'mysql') { + DB::statement(sprintf('ALTER TABLE %s MODIFY workspace_id BIGINT UNSIGNED NOT NULL', $table)); + } + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + + if (! in_array($driver, ['pgsql', 'mysql'], true)) { + return; + } + + foreach ($this->tenantOwnedTables() as $table) { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'workspace_id')) { + continue; + } + + if ($driver === 'pgsql') { + DB::statement(sprintf('ALTER TABLE %s ALTER COLUMN workspace_id DROP NOT NULL', $table)); + } + + if ($driver === 'mysql') { + DB::statement(sprintf('ALTER TABLE %s MODIFY workspace_id BIGINT UNSIGNED NULL', $table)); + } + } + } +}; diff --git a/database/migrations/2026_02_14_220115_add_workspace_isolation_constraints_to_tenant_owned_tables.php b/database/migrations/2026_02_14_220115_add_workspace_isolation_constraints_to_tenant_owned_tables.php new file mode 100644 index 0000000..0617b21 --- /dev/null +++ b/database/migrations/2026_02_14_220115_add_workspace_isolation_constraints_to_tenant_owned_tables.php @@ -0,0 +1,83 @@ + + */ + private function tenantOwnedTables(): array + { + return [ + 'policies', + 'policy_versions', + 'backup_sets', + 'backup_items', + 'restore_runs', + 'backup_schedules', + 'inventory_items', + 'inventory_links', + 'entra_groups', + 'findings', + 'entra_role_definitions', + 'tenant_permissions', + ]; + } + + public function up(): void + { + $driver = DB::getDriverName(); + + if (! in_array($driver, ['pgsql', 'mysql'], true)) { + return; + } + + foreach ($this->tenantOwnedTables() as $tableName) { + if (! Schema::hasTable($tableName) || ! Schema::hasColumn($tableName, 'workspace_id')) { + continue; + } + + $workspaceConstraint = sprintf('%s_workspace_fk', $tableName); + $tenantWorkspaceConstraint = sprintf('%s_tenant_workspace_fk', $tableName); + + Schema::table($tableName, function (Blueprint $table) use ($workspaceConstraint, $tenantWorkspaceConstraint): void { + $table->foreign('workspace_id', $workspaceConstraint) + ->references('id') + ->on('workspaces') + ->cascadeOnDelete(); + + $table->foreign(['tenant_id', 'workspace_id'], $tenantWorkspaceConstraint) + ->references(['id', 'workspace_id']) + ->on('tenants') + ->cascadeOnDelete(); + }); + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + + if (! in_array($driver, ['pgsql', 'mysql'], true)) { + return; + } + + foreach ($this->tenantOwnedTables() as $tableName) { + if (! Schema::hasTable($tableName) || ! Schema::hasColumn($tableName, 'workspace_id')) { + continue; + } + + $workspaceConstraint = sprintf('%s_workspace_fk', $tableName); + $tenantWorkspaceConstraint = sprintf('%s_tenant_workspace_fk', $tableName); + + Schema::table($tableName, function (Blueprint $table) use ($workspaceConstraint, $tenantWorkspaceConstraint): void { + $table->dropForeign($tenantWorkspaceConstraint); + $table->dropForeign($workspaceConstraint); + }); + } + } +}; diff --git a/database/migrations/2026_02_14_220116_backfill_workspace_id_on_audit_logs.php b/database/migrations/2026_02_14_220116_backfill_workspace_id_on_audit_logs.php new file mode 100644 index 0000000..b0a6ce2 --- /dev/null +++ b/database/migrations/2026_02_14_220116_backfill_workspace_id_on_audit_logs.php @@ -0,0 +1,67 @@ +whereNotNull('tenant_id') + ->whereNull('workspace_id') + ->orderBy('id') + ->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + $workspaceId = DB::table('tenants') + ->where('id', (int) $row->tenant_id) + ->value('workspace_id'); + + if ($workspaceId === null) { + continue; + } + + DB::table('audit_logs') + ->where('id', (int) $row->id) + ->update(['workspace_id' => (int) $workspaceId]); + } + }, 'id'); + } + + public function down(): void + { + // Intentionally no-op; this migration repairs historical scope metadata. + } +}; diff --git a/database/migrations/2026_02_14_220117_add_audit_logs_scope_check_constraint.php b/database/migrations/2026_02_14_220117_add_audit_logs_scope_check_constraint.php new file mode 100644 index 0000000..807ee57 --- /dev/null +++ b/database/migrations/2026_02_14_220117_add_audit_logs_scope_check_constraint.php @@ -0,0 +1,46 @@ + t.workspace_id;` +- `SELECT count(*) FROM backup_sets b JOIN tenants t ON t.id = b.tenant_id WHERE b.workspace_id IS NOT NULL AND b.workspace_id <> t.workspace_id;` +- `SELECT count(*) FROM findings f JOIN tenants t ON t.id = f.tenant_id WHERE f.workspace_id IS NOT NULL AND f.workspace_id <> t.workspace_id;` + ## Rollback notes - Phase 1 migrations are reversible (drop columns / indexes) but may be large operations on production datasets. - Prefer forward-fix for production if Phase 2/3 is partially applied. - diff --git a/specs/093-scope-001-workspace-id-isolation/tasks.md b/specs/093-scope-001-workspace-id-isolation/tasks.md index 8f1cf39..048b7f1 100644 --- a/specs/093-scope-001-workspace-id-isolation/tasks.md +++ b/specs/093-scope-001-workspace-id-isolation/tasks.md @@ -23,9 +23,9 @@ ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Confirm design inputs + existing code entrypoints -- [ ] T001 Verify feature docs are present and consistent in specs/093-scope-001-workspace-id-isolation/{spec.md,plan.md,research.md,data-model.md,contracts/,quickstart.md,tasks.md} -- [ ] T002 [P] Inventory target models exist for the 12 tables in app/Models/{Policy,PolicyVersion,BackupSet,BackupItem,RestoreRun,BackupSchedule,InventoryItem,InventoryLink,EntraGroup,Finding,EntraRoleDefinition,TenantPermission}.php -- [ ] T003 [P] Identify audit logging entrypoints that must set workspace_id in app/Services/Intune/AuditLogger.php and app/Services/Audit/WorkspaceAuditLogger.php +- [X] T001 Verify feature docs are present and consistent in specs/093-scope-001-workspace-id-isolation/{spec.md,plan.md,research.md,data-model.md,contracts/,quickstart.md,tasks.md} +- [X] T002 [P] Inventory target models exist for the 12 tables in app/Models/{Policy,PolicyVersion,BackupSet,BackupItem,RestoreRun,BackupSchedule,InventoryItem,InventoryLink,EntraGroup,Finding,EntraRoleDefinition,TenantPermission}.php +- [X] T003 [P] Identify audit logging entrypoints that must set workspace_id in app/Services/Intune/AuditLogger.php and app/Services/Audit/WorkspaceAuditLogger.php --- @@ -35,9 +35,9 @@ ## Phase 2: Foundational (Blocking Prerequisites) **⚠️ CRITICAL**: No user story work can begin until this phase is complete -- [ ] T004 Create shared model concern for workspace binding + tenant immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php -- [ ] T005 [P] Add supporting exception type for mismatch/immutability errors in app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php -- [ ] T006 Add a small list of tenant-owned table names for reuse (command + tests) in app/Support/WorkspaceIsolation/TenantOwnedTables.php +- [X] T004 Create shared model concern for workspace binding + tenant immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php +- [X] T005 [P] Add supporting exception type for mismatch/immutability errors in app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php +- [X] T006 Add a small list of tenant-owned table names for reuse (command + tests) in app/Support/WorkspaceIsolation/TenantOwnedTables.php **Checkpoint**: Foundation ready (shared enforcement building blocks exist) @@ -55,44 +55,44 @@ ### Tests for User Story 1 > NOTE: Write these tests first and ensure they fail before implementation. -- [ ] T007 [P] [US1] Add unit-level enforcement tests for the shared concern in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php -- [ ] T007a [P] [US1] Add immutability test case (attempt to change tenant_id is rejected) in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php +- [X] T007 [P] [US1] Add unit-level enforcement tests for the shared concern in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php +- [X] T007a [P] [US1] Add immutability test case (attempt to change tenant_id is rejected) in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php ### Implementation for User Story 1 -- [ ] T008 [P] [US1] Add nullable workspace_id + indexes to policies in database/migrations/*_add_workspace_id_to_policies_table.php -- [ ] T009 [P] [US1] Add nullable workspace_id + indexes to policy_versions in database/migrations/*_add_workspace_id_to_policy_versions_table.php -- [ ] T010 [P] [US1] Add nullable workspace_id + indexes to backup_sets in database/migrations/*_add_workspace_id_to_backup_sets_table.php -- [ ] T011 [P] [US1] Add nullable workspace_id + indexes to backup_items in database/migrations/*_add_workspace_id_to_backup_items_table.php -- [ ] T012 [P] [US1] Add nullable workspace_id + indexes to restore_runs in database/migrations/*_add_workspace_id_to_restore_runs_table.php -- [ ] T013 [P] [US1] Add nullable workspace_id + indexes to backup_schedules in database/migrations/*_add_workspace_id_to_backup_schedules_table.php -- [ ] T014 [P] [US1] Add nullable workspace_id + indexes to inventory_items in database/migrations/*_add_workspace_id_to_inventory_items_table.php -- [ ] T015 [P] [US1] Add nullable workspace_id + indexes to inventory_links in database/migrations/*_add_workspace_id_to_inventory_links_table.php -- [ ] T016 [P] [US1] Add nullable workspace_id + indexes to entra_groups in database/migrations/*_add_workspace_id_to_entra_groups_table.php -- [ ] T017 [P] [US1] Add nullable workspace_id + indexes to findings in database/migrations/*_add_workspace_id_to_findings_table.php -- [ ] T018 [P] [US1] Add nullable workspace_id + indexes to entra_role_definitions in database/migrations/*_add_workspace_id_to_entra_role_definitions_table.php -- [ ] T019 [P] [US1] Add nullable workspace_id + indexes to tenant_permissions in database/migrations/*_add_workspace_id_to_tenant_permissions_table.php +- [X] T008 [P] [US1] Add nullable workspace_id + indexes to policies in database/migrations/*_add_workspace_id_to_policies_table.php +- [X] T009 [P] [US1] Add nullable workspace_id + indexes to policy_versions in database/migrations/*_add_workspace_id_to_policy_versions_table.php +- [X] T010 [P] [US1] Add nullable workspace_id + indexes to backup_sets in database/migrations/*_add_workspace_id_to_backup_sets_table.php +- [X] T011 [P] [US1] Add nullable workspace_id + indexes to backup_items in database/migrations/*_add_workspace_id_to_backup_items_table.php +- [X] T012 [P] [US1] Add nullable workspace_id + indexes to restore_runs in database/migrations/*_add_workspace_id_to_restore_runs_table.php +- [X] T013 [P] [US1] Add nullable workspace_id + indexes to backup_schedules in database/migrations/*_add_workspace_id_to_backup_schedules_table.php +- [X] T014 [P] [US1] Add nullable workspace_id + indexes to inventory_items in database/migrations/*_add_workspace_id_to_inventory_items_table.php +- [X] T015 [P] [US1] Add nullable workspace_id + indexes to inventory_links in database/migrations/*_add_workspace_id_to_inventory_links_table.php +- [X] T016 [P] [US1] Add nullable workspace_id + indexes to entra_groups in database/migrations/*_add_workspace_id_to_entra_groups_table.php +- [X] T017 [P] [US1] Add nullable workspace_id + indexes to findings in database/migrations/*_add_workspace_id_to_findings_table.php +- [X] T018 [P] [US1] Add nullable workspace_id + indexes to entra_role_definitions in database/migrations/*_add_workspace_id_to_entra_role_definitions_table.php +- [X] T019 [P] [US1] Add nullable workspace_id + indexes to tenant_permissions in database/migrations/*_add_workspace_id_to_tenant_permissions_table.php -- [ ] T019a [US1] Enforce tenant_id immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php (reject updates when tenant_id differs from original) +- [X] T019a [US1] Enforce tenant_id immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php (reject updates when tenant_id differs from original) -- [ ] T020 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Policy model in app/Models/Policy.php -- [ ] T021 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to PolicyVersion model in app/Models/PolicyVersion.php -- [ ] T022 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSet model in app/Models/BackupSet.php -- [ ] T023 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupItem model in app/Models/BackupItem.php -- [ ] T024 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to RestoreRun model in app/Models/RestoreRun.php -- [ ] T025 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSchedule model in app/Models/BackupSchedule.php -- [ ] T026 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryItem model in app/Models/InventoryItem.php -- [ ] T027 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryLink model in app/Models/InventoryLink.php -- [ ] T028 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraGroup model in app/Models/EntraGroup.php -- [ ] T029 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Finding model in app/Models/Finding.php -- [ ] T030 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraRoleDefinition model in app/Models/EntraRoleDefinition.php -- [ ] T031 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to TenantPermission model in app/Models/TenantPermission.php +- [X] T020 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Policy model in app/Models/Policy.php +- [X] T021 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to PolicyVersion model in app/Models/PolicyVersion.php +- [X] T022 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSet model in app/Models/BackupSet.php +- [X] T023 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupItem model in app/Models/BackupItem.php +- [X] T024 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to RestoreRun model in app/Models/RestoreRun.php +- [X] T025 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSchedule model in app/Models/BackupSchedule.php +- [X] T026 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryItem model in app/Models/InventoryItem.php +- [X] T027 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryLink model in app/Models/InventoryLink.php +- [X] T028 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraGroup model in app/Models/EntraGroup.php +- [X] T029 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Finding model in app/Models/Finding.php +- [X] T030 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraRoleDefinition model in app/Models/EntraRoleDefinition.php +- [X] T031 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to TenantPermission model in app/Models/TenantPermission.php -- [ ] T032 [US1] Add tenants composite uniqueness to support composite FK in database/migrations/*_add_tenants_id_workspace_id_unique.php +- [X] T032 [US1] Add tenants composite uniqueness to support composite FK in database/migrations/*_add_tenants_id_workspace_id_unique.php **Post-backfill constraints (depends on US2 completion):** -- [ ] T033 [US1] Enforce NOT NULL workspace_id for the 12 tenant-owned tables in database/migrations/*_enforce_workspace_id_not_null_on_tenant_owned_tables.php -- [ ] T034 [US1] Add FK workspace_id → workspaces.id + composite FK (tenant_id, workspace_id) → tenants(id, workspace_id) for the 12 tables in database/migrations/*_add_workspace_isolation_constraints_to_tenant_owned_tables.php +- [X] T033 [US1] Enforce NOT NULL workspace_id for the 12 tenant-owned tables in database/migrations/*_enforce_workspace_id_not_null_on_tenant_owned_tables.php +- [X] T034 [US1] Add FK workspace_id → workspaces.id + composite FK (tenant_id, workspace_id) → tenants(id, workspace_id) for the 12 tables in database/migrations/*_add_workspace_isolation_constraints_to_tenant_owned_tables.php **Checkpoint**: US1 complete once new writes are safe and DB constraints are enforceable after backfill. @@ -111,20 +111,20 @@ ### Tests for User Story 2 > NOTE: Write these tests first and ensure they fail before implementation. -- [ ] T035 [P] [US2] Add backfill command tests in tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php +- [X] T035 [P] [US2] Add backfill command tests in tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php ### Implementation for User Story 2 -- [ ] T036 [US2] Implement operator command skeleton + options in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php -- [ ] T036a [US2] Implement queued job runner for batch/table backfills in app/Jobs/BackfillWorkspaceIdsJob.php -- [ ] T036b [US2] Dispatch jobs from app/Console/Commands/TenantpilotBackfillWorkspaceIds.php and ensure “start → dispatch → view run” flow -- [ ] T037 [US2] Add concurrency lock (Cache::lock) to prevent concurrent backfills in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php -- [ ] T038 [US2] Implement dry-run counts + per-table reporting in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php -- [ ] T039 [US2] Implement per-workspace OperationRun creation in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/OperationRunService.php (ensureWorkspaceRunWithIdentity) -- [ ] T040 [US2] Write start/end/outcome AuditLog summaries per workspace in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/Audit/WorkspaceAuditLogger.php -- [ ] T041 [US2] Implement backfill updates for all 12 tables in app/Jobs/BackfillWorkspaceIdsJob.php (UPDATE ... FROM tenants WHERE workspace_id IS NULL) -- [ ] T042 [US2] Implement abort-and-report behavior when tenant workspace_id is missing/unresolvable in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php -- [ ] T043 [US2] Implement progress tracking (counts + last processed id) persisted into OperationRun context from app/Jobs/BackfillWorkspaceIdsJob.php +- [X] T036 [US2] Implement operator command skeleton + options in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php +- [X] T036a [US2] Implement queued job runner for batch/table backfills in app/Jobs/BackfillWorkspaceIdsJob.php +- [X] T036b [US2] Dispatch jobs from app/Console/Commands/TenantpilotBackfillWorkspaceIds.php and ensure “start → dispatch → view run” flow +- [X] T037 [US2] Add concurrency lock (Cache::lock) to prevent concurrent backfills in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php +- [X] T038 [US2] Implement dry-run counts + per-table reporting in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php +- [X] T039 [US2] Implement per-workspace OperationRun creation in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/OperationRunService.php (ensureWorkspaceRunWithIdentity) +- [X] T040 [US2] Write start/end/outcome AuditLog summaries per workspace in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/Audit/WorkspaceAuditLogger.php +- [X] T041 [US2] Implement backfill updates for all 12 tables in app/Jobs/BackfillWorkspaceIdsJob.php (UPDATE ... FROM tenants WHERE workspace_id IS NULL) +- [X] T042 [US2] Implement abort-and-report behavior when tenant workspace_id is missing/unresolvable in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php +- [X] T043 [US2] Implement progress tracking (counts + last processed id) persisted into OperationRun context from app/Jobs/BackfillWorkspaceIdsJob.php **Checkpoint**: US2 complete when backfill is safe to run repeatedly and produces OperationRun + AuditLog observability. @@ -143,13 +143,13 @@ ### Tests for User Story 3 > NOTE: Write these tests first and ensure they fail before implementation. -- [ ] T044 [P] [US3] Add audit invariant tests for tenant/workspace/platform scopes in tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php +- [X] T044 [P] [US3] Add audit invariant tests for tenant/workspace/platform scopes in tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php ### Implementation for User Story 3 -- [ ] T045 [US3] Ensure tenant-scoped audit writes include workspace_id in app/Services/Intune/AuditLogger.php -- [ ] T046 [US3] Add migration to backfill audit_logs.workspace_id where tenant_id is present (join tenants) in database/migrations/*_backfill_workspace_id_on_audit_logs.php -- [ ] T047 [US3] Add check constraint enforcing tenant_id IS NULL OR workspace_id IS NOT NULL in database/migrations/*_add_audit_logs_scope_check_constraint.php +- [X] T045 [US3] Ensure tenant-scoped audit writes include workspace_id in app/Services/Intune/AuditLogger.php +- [X] T046 [US3] Add migration to backfill audit_logs.workspace_id where tenant_id is present (join tenants) in database/migrations/*_backfill_workspace_id_on_audit_logs.php +- [X] T047 [US3] Add check constraint enforcing tenant_id IS NULL OR workspace_id IS NOT NULL in database/migrations/*_add_audit_logs_scope_check_constraint.php **Checkpoint**: US3 complete when invariant is enforced in both app writes and DB. @@ -157,8 +157,8 @@ ### Implementation for User Story 3 ## Phase 6: Polish & Cross-Cutting Concerns -- [ ] T048 [P] Add validation SQL snippets for operators in specs/093-scope-001-workspace-id-isolation/quickstart.md -- [ ] T049 Ensure tasks and rollout order remain accurate after implementation changes in specs/093-scope-001-workspace-id-isolation/plan.md +- [X] T048 [P] Add validation SQL snippets for operators in specs/093-scope-001-workspace-id-isolation/quickstart.md +- [X] T049 Ensure tasks and rollout order remain accurate after implementation changes in specs/093-scope-001-workspace-id-isolation/plan.md --- diff --git a/tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php b/tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php new file mode 100644 index 0000000..e03a87a --- /dev/null +++ b/tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php @@ -0,0 +1,89 @@ +create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $log = app(AuditLogger::class)->log( + tenant: $tenant, + action: 'workspace_isolation.audit_logger_test', + context: ['source' => 'test'], + status: 'success', + ); + + expect((int) $log->tenant_id)->toBe((int) $tenant->getKey()); + expect((int) $log->workspace_id)->toBe((int) $workspace->getKey()); +}); + +it('allows workspace only and platform only audit scopes', function (): void { + $workspace = Workspace::factory()->create(); + + $workspaceScoped = AuditLog::query()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => null, + 'actor_email' => null, + 'actor_name' => null, + 'action' => 'workspace_isolation.workspace_scope', + 'resource_type' => 'workspace', + 'resource_id' => (string) $workspace->getKey(), + 'status' => 'success', + 'metadata' => [], + 'recorded_at' => now(), + ]); + + $platformScoped = AuditLog::query()->create([ + 'tenant_id' => null, + 'workspace_id' => null, + 'actor_id' => null, + 'actor_email' => null, + 'actor_name' => null, + 'action' => 'workspace_isolation.platform_scope', + 'resource_type' => null, + 'resource_id' => null, + 'status' => 'success', + 'metadata' => [], + 'recorded_at' => now(), + ]); + + expect($workspaceScoped->exists)->toBeTrue(); + expect($platformScoped->exists)->toBeTrue(); +}); + +it('rejects tenant scoped audit rows without workspace_id at the database layer', function (): void { + if (DB::getDriverName() === 'sqlite') { + $this->markTestSkipped('Audit scope check constraint is enforced on pgsql/mysql migrations.'); + } + + $workspace = Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + expect(fn () => DB::table('audit_logs')->insert([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'actor_id' => null, + 'actor_email' => null, + 'actor_name' => null, + 'action' => 'workspace_isolation.invalid_scope', + 'resource_type' => 'tenant', + 'resource_id' => (string) $tenant->getKey(), + 'status' => 'failed', + 'metadata' => json_encode([], JSON_THROW_ON_ERROR), + 'recorded_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]))->toThrow(QueryException::class); +}); diff --git a/tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php b/tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php new file mode 100644 index 0000000..309c16c --- /dev/null +++ b/tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php @@ -0,0 +1,144 @@ +create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + DB::table('policies')->insert([ + [ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'external_id' => 'legacy-policy-a', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Legacy Policy A', + 'metadata' => json_encode([], JSON_THROW_ON_ERROR), + 'last_synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'external_id' => 'legacy-policy-b', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Legacy Policy B', + 'metadata' => json_encode([], JSON_THROW_ON_ERROR), + 'last_synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->artisan('tenantpilot:backfill-workspace-ids', [ + '--table' => 'policies', + '--batch-size' => 1, + ])->assertSuccessful(); + + $missingAfter = DB::table('policies') + ->where('tenant_id', (int) $tenant->getKey()) + ->whereNull('workspace_id') + ->count(); + + expect($missingAfter)->toBe(0); + + $workspaceIds = DB::table('policies') + ->where('tenant_id', (int) $tenant->getKey()) + ->pluck('workspace_id') + ->map(static fn (mixed $workspaceId): int => (int) $workspaceId) + ->unique() + ->values() + ->all(); + + expect($workspaceIds)->toBe([(int) $workspace->getKey()]); + + $run = OperationRun::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('type', 'workspace_isolation_backfill_workspace_ids') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect((int) data_get($run?->summary_counts, 'processed', 0))->toBe(2); + + $actions = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->pluck('action') + ->all(); + + expect($actions)->toContain('workspace_isolation.backfill_workspace_ids.started'); + expect($actions)->toContain('workspace_isolation.backfill_workspace_ids.dispatched'); +}); + +it('is idempotent when re-run after backfill', function (): void { + $workspace = Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + DB::table('policies')->insert([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'external_id' => 'legacy-policy-retry', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Legacy Retry Policy', + 'metadata' => json_encode([], JSON_THROW_ON_ERROR), + 'last_synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('tenantpilot:backfill-workspace-ids', ['--table' => 'policies'])->assertSuccessful(); + + $this->artisan('tenantpilot:backfill-workspace-ids', ['--table' => 'policies']) + ->expectsOutputToContain('No rows require workspace_id backfill.') + ->assertSuccessful(); + + $missingAfter = DB::table('policies') + ->where('tenant_id', (int) $tenant->getKey()) + ->whereNull('workspace_id') + ->count(); + + expect($missingAfter)->toBe(0); +}); + +it('aborts and reports when tenant to workspace mapping is unresolvable', function (): void { + $tenant = Tenant::factory()->create(); + $tenant->forceFill(['workspace_id' => null])->save(); + + DB::table('policies')->insert([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => null, + 'external_id' => 'legacy-policy-unresolvable', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Legacy Unresolvable Policy', + 'metadata' => json_encode([], JSON_THROW_ON_ERROR), + 'last_synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('tenantpilot:backfill-workspace-ids', ['--table' => 'policies']) + ->expectsOutputToContain('Unresolvable tenant->workspace mapping') + ->assertFailed(); + + $missingAfter = DB::table('policies') + ->where('tenant_id', (int) $tenant->getKey()) + ->whereNull('workspace_id') + ->count(); + + expect($missingAfter)->toBe(1); +}); diff --git a/tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php b/tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php new file mode 100644 index 0000000..81655e1 --- /dev/null +++ b/tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php @@ -0,0 +1,72 @@ +create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-derived-workspace', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Derived Workspace Policy', + 'platform' => 'windows', + ]); + + expect((int) $policy->workspace_id)->toBe((int) $tenant->workspace_id); +}); + +it('rejects create when workspace_id mismatches tenant workspace', function (): void { + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + ]); + + expect(fn () => Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspaceB->getKey(), + 'external_id' => 'policy-workspace-mismatch', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Workspace Mismatch Policy', + 'platform' => 'windows', + ]))->toThrow(WorkspaceIsolationViolation::class); +}); + +it('rejects tenant_id changes after create', function (): void { + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + ]); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'external_id' => 'policy-tenant-immutable', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Tenant Immutable Policy', + 'platform' => 'windows', + ]); + + expect(fn () => $policy->update([ + 'tenant_id' => (int) $tenantB->getKey(), + ]))->toThrow(WorkspaceIsolationViolation::class); + + $policy->refresh(); + + expect((int) $policy->tenant_id)->toBe((int) $tenantA->getKey()); + expect((int) $policy->workspace_id)->toBe((int) $tenantA->workspace_id); +});