SCOPE-001: DB-level workspace isolation via workspace_id (#112)

Implements Spec 093 (SCOPE-001) workspace isolation at the data layer.

What changed
- Adds `workspace_id` to 12 tenant-owned tables and enforces correct binding.
- Model write-path enforcement derives workspace from tenant + rejects mismatches.
- Prevents `tenant_id` changes (immutability) on tenant-owned records.
- Adds queued backfill command + job (`tenantpilot:backfill-workspace-ids`) with OperationRun + AuditLog observability.
- Enforces DB constraints (NOT NULL + FK `workspace_id` → `workspaces.id` + composite FK `(tenant_id, workspace_id)` → `tenants(id, workspace_id)`), plus audit_logs invariant.

UI / operator visibility
- Monitor backfill runs in **Monitoring → Operations** (OperationRun).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/WorkspaceIsolation`

Notes
- Backfill is queued: ensure a queue worker is running (`vendor/bin/sail artisan queue:work`).

Spec package
- `specs/093-scope-001-workspace-id-isolation/` (plan, tasks, contracts, quickstart, research)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #112
This commit is contained in:
ahmido 2026-02-14 22:34:02 +00:00
parent 3ddf8c3fd6
commit 92a36ab89e
47 changed files with 2673 additions and 0 deletions

View File

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillWorkspaceIdsJob;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class TenantpilotBackfillWorkspaceIds extends Command
{
protected $signature = 'tenantpilot:backfill-workspace-ids
{--dry-run : Print per-table counts only}
{--table= : Restrict to a single tenant-owned table}
{--batch-size=5000 : Rows per queued chunk}
{--resume-from=0 : Resume from id cursor}
{--max-rows= : Maximum rows to process per table job}';
protected $description = 'Backfill missing workspace_id across tenant-owned tables.';
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): int
{
$tables = $this->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<int, string>
*/
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<int, string> $tables
* @return array<int, array{table: string, missing: int, unresolvable: int, sample_ids: array<int, int>}>
*/
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<int, string> $tables
* @return array<int, array{total: int, tables: array<string, int>}>
*/
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;
}
}

View File

@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class BackfillWorkspaceIdsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $operationRunId,
public int $workspaceId,
public string $table,
public int $batchSize = 5000,
public ?int $maxRows = null,
public int $resumeFrom = 0,
) {}
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): void
{
if (! TenantOwnedTables::contains($this->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]);
});
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 = [];

View File

@ -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 = [];

View File

@ -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';

View File

@ -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 = [];

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 = [];

View File

@ -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,

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Support\Concerns;
use App\Models\Tenant;
use App\Support\WorkspaceIsolation\WorkspaceIsolationViolation;
use Illuminate\Database\Eloquent\Model;
trait DerivesWorkspaceIdFromTenant
{
public static function bootDerivesWorkspaceIdFromTenant(): void
{
static::creating(static function (Model $model): void {
self::enforceWorkspaceBinding($model);
});
static::updating(static function (Model $model): void {
self::enforceWorkspaceBinding($model);
});
}
private static function enforceWorkspaceBinding(Model $model): void
{
$tenantId = self::resolveTenantId($model);
self::ensureTenantIdIsImmutable($model, $tenantId);
$tenantWorkspaceId = self::resolveTenantWorkspaceId($model, $tenantId);
$workspaceId = $model->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;
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Support\WorkspaceIsolation;
class TenantOwnedTables
{
/**
* @return array<int, string>
*/
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);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support\WorkspaceIsolation;
use RuntimeException;
class WorkspaceIsolationViolation extends RuntimeException
{
public static function missingTenantId(string $modelClass): self
{
return new self(sprintf('%s must include a tenant_id.', $modelClass));
}
public static function tenantNotFound(string $modelClass, int $tenantId): self
{
return new self(sprintf('%s references missing tenant_id %d.', $modelClass, $tenantId));
}
public static function tenantWorkspaceMissing(string $modelClass, int $tenantId): self
{
return new self(sprintf('%s tenant_id %d has no workspace_id mapping.', $modelClass, $tenantId));
}
public static function workspaceMismatch(string $modelClass, int $tenantId, int $expectedWorkspaceId, int $actualWorkspaceId): self
{
return new self(sprintf(
'%s tenant_id %d requires workspace_id %d, received %d.',
$modelClass,
$tenantId,
$expectedWorkspaceId,
$actualWorkspaceId,
));
}
public static function tenantImmutable(string $modelClass, int $originalTenantId, int $updatedTenantId): self
{
return new self(sprintf(
'%s tenant_id is immutable (%d -> %d).',
$modelClass,
$originalTenantId,
$updatedTenantId,
));
}
}

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('policies') || Schema::hasColumn('policies', 'workspace_id')) {
return;
}
Schema::table('policies', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('policy_versions') || Schema::hasColumn('policy_versions', 'workspace_id')) {
return;
}
Schema::table('policy_versions', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('backup_sets') || Schema::hasColumn('backup_sets', 'workspace_id')) {
return;
}
Schema::table('backup_sets', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('backup_items') || Schema::hasColumn('backup_items', 'workspace_id')) {
return;
}
Schema::table('backup_items', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('restore_runs') || Schema::hasColumn('restore_runs', 'workspace_id')) {
return;
}
Schema::table('restore_runs', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('backup_schedules') || Schema::hasColumn('backup_schedules', 'workspace_id')) {
return;
}
Schema::table('backup_schedules', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('inventory_items') || Schema::hasColumn('inventory_items', 'workspace_id')) {
return;
}
Schema::table('inventory_items', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('inventory_links') || Schema::hasColumn('inventory_links', 'workspace_id')) {
return;
}
Schema::table('inventory_links', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('entra_groups') || Schema::hasColumn('entra_groups', 'workspace_id')) {
return;
}
Schema::table('entra_groups', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('findings') || Schema::hasColumn('findings', 'workspace_id')) {
return;
}
Schema::table('findings', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): 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->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('tenant_permissions') || Schema::hasColumn('tenant_permissions', 'workspace_id')) {
return;
}
Schema::table('tenant_permissions', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('tenants') || ! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
Schema::table('tenants', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* @return array<int, string>
*/
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));
}
}
}
};

View File

@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* @return array<int, string>
*/
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);
});
}
}
};

View File

@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('audit_logs') || ! Schema::hasColumn('audit_logs', 'workspace_id')) {
return;
}
$driver = DB::getDriverName();
if ($driver === 'pgsql') {
DB::statement(<<<'SQL'
UPDATE audit_logs
SET workspace_id = tenants.workspace_id
FROM tenants
WHERE audit_logs.tenant_id = tenants.id
AND audit_logs.tenant_id IS NOT NULL
AND audit_logs.workspace_id IS NULL
SQL);
return;
}
if ($driver === 'mysql') {
DB::statement(<<<'SQL'
UPDATE audit_logs
JOIN tenants ON tenants.id = audit_logs.tenant_id
SET audit_logs.workspace_id = tenants.workspace_id
WHERE audit_logs.tenant_id IS NOT NULL
AND audit_logs.workspace_id IS NULL
SQL);
return;
}
DB::table('audit_logs')
->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.
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('audit_logs')) {
return;
}
$driver = DB::getDriverName();
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_tenant_workspace_scope_check CHECK (tenant_id IS NULL OR workspace_id IS NOT NULL)');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_tenant_workspace_scope_check CHECK (tenant_id IS NULL OR workspace_id IS NOT NULL)');
}
}
public function down(): void
{
if (! Schema::hasTable('audit_logs')) {
return;
}
$driver = DB::getDriverName();
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS audit_logs_tenant_workspace_scope_check');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE audit_logs DROP CHECK audit_logs_tenant_workspace_scope_check');
}
}
};

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: SCOPE-001 Workspace ID Isolation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-14
**Feature**: ./spec.md
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All checklist items pass; spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,48 @@
# CLI Contract — 093 Workspace ID Backfill
This feature adds an operator-only Artisan command to backfill missing `workspace_id` on tenant-owned tables.
## Command
- Name (proposed): `tenantpilot:backfill-workspace-ids`
## Flags / Options (proposed)
- `--dry-run` (default: false)
- Prints counts per table and exits without writing.
- `--table=<name>` (optional)
- Restrict execution to a single table.
- `--batch-size=<n>` (default: 5_000)
- Batch size for updates (where chunking is used).
- `--resume-from=<cursor>` (optional)
- Resume from a saved cursor/checkpoint (implementation-defined).
- `--max-rows=<n>` (optional)
- Safety valve for partial runs.
## Safety + Observability
Execution strategy (queued):
- The command is a start surface only: authorize → acquire lock → create/reuse `OperationRun` → dispatch queued jobs → print a “View run” pointer.
- The backfill mutations MUST execute inside queued jobs (batch/table scoped) to support large datasets.
Safety + observability requirements:
- Must acquire a lock (cache/DB-backed lock) to prevent concurrent runs.
- Must create/reuse an `OperationRun` for visibility and progress tracking.
- Must write an `AuditLog` entry for start and end (outcome, counts, duration).
- Must abort and report when a tenant→workspace mapping cannot be resolved.
## Output
- Printed “Run started” summary:
- `OperationRun` identifier (or URL/route reference when available)
- jobs dispatched count
- selected tables / scope
- Per-table totals:
- scanned rows
- rows missing `workspace_id`
- rows updated
- Final summary + recommended validation SQL.

View File

@ -0,0 +1,10 @@
openapi: 3.0.3
info:
title: TenantPilot — Spec 093 Contracts
version: 1.0.0
description: |
Spec 093 introduces no new HTTP routes.
This OpenAPI file is intentionally minimal to document that the rollout
is implemented via database migrations + an operator-only Artisan command.
paths: {}

View File

@ -0,0 +1,92 @@
# Data Model — 093 SCOPE-001 Workspace ID Isolation
## Core Entities
### Workspace
- `workspaces`:
- `id` (bigint)
- `name`, `slug`, timestamps
### Tenant
- `tenants`:
- `id` (bigint)
- `workspace_id` (bigint, intended non-null logically; currently nullable in schema)
**Ownership rule**: A tenant belongs to exactly one workspace; this mapping is the source of truth for deriving workspace bindings.
## Tenant-owned Tables (must become workspace-bound)
For each table below:
- Add `workspace_id` (bigint FK to `workspaces.id`)
- Enforce `workspace_id` derived from tenant (DB-level composite FK on Postgres/MySQL)
- Keep `tenant_id` immutable (application enforcement)
### policies
- Existing: `tenant_id`, `external_id`, `policy_type`, etc.
- Add: `workspace_id`
### policy_versions
- Existing: `tenant_id`, `policy_id`, `snapshot`, etc.
- Add: `workspace_id`
### backup_sets
- Existing: `tenant_id`, status/count, etc.
- Add: `workspace_id`
### backup_items
- Existing: `tenant_id`, `backup_set_id`, `payload`, etc.
- Add: `workspace_id`
### restore_runs
- Existing: `tenant_id`, `backup_set_id`, status, preview/results, etc.
- Add: `workspace_id`
### backup_schedules
- Existing: `tenant_id`, enabled/frequency/schedule fields
- Add: `workspace_id`
### inventory_items
- Existing: `tenant_id`, policy identifiers, `meta_jsonb`, last_seen fields
- Add: `workspace_id`
### inventory_links
- Existing: `tenant_id`, source/target relationship identifiers
- Add: `workspace_id`
### entra_groups
- Existing: `tenant_id`, entra_id, display fields
- Add: `workspace_id`
### findings
- Existing: `tenant_id`, fingerprint, status/severity, run references
- Add: `workspace_id`
### entra_role_definitions
- Existing: `tenant_id`, entra_id, display fields
- Add: `workspace_id`
### tenant_permissions
- Existing: `tenant_id`, permission_key, status
- Add: `workspace_id`
## Audit Logs (scope invariants)
### audit_logs
- Existing: `tenant_id` nullable, `workspace_id` nullable, action/resource fields
**Invariant**:
- Tenant-scoped audit entry: `tenant_id != null` implies `workspace_id != null`.
- Workspace-only audit entry: `workspace_id != null` and `tenant_id == null` is allowed.
- Platform-only audit entry: both null is allowed.
## Relationship + Constraint Strategy
### Tenant-owned enforcement (Postgres/MySQL)
- Composite FK on each tenant-owned table:
- `(tenant_id, workspace_id) → tenants(id, workspace_id)`
- Standard FK on `workspace_id → workspaces.id`
### SQLite
- Foreign key / composite constraint enforcement is limited.
- Testing relies on application enforcement + basic NOT NULL where feasible.

View File

@ -0,0 +1,155 @@
# Implementation Plan: Spec 093 — SCOPE-001 Workspace ID Isolation
**Branch**: `093-scope-001-workspace-id-isolation` | **Date**: 2026-02-14
**Spec**: `specs/093-scope-001-workspace-id-isolation/spec.md`
**Spec (absolute)**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/093-scope-001-workspace-id-isolation/spec.md`
**Input**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/093-scope-001-workspace-id-isolation/spec.md`
## Summary
Enforce DB-level workspace isolation for tenant-owned data by adding `workspace_id` to 12 tenant-owned tables, safely backfilling legacy rows, and then enforcing NOT NULL + referential integrity.
Additionally, fix the audit trail invariant: if an `audit_logs` entry references a tenant, it must also reference a workspace.
Rollout is staged to avoid downtime:
1) Add nullable `workspace_id` columns.
2) Enforce write-path derivation + mismatch rejection.
3) Backfill in batches with resumability, locking, and observability (`OperationRun` + `AuditLog`).
4) Enforce constraints and add final indexes.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
**Storage**: PostgreSQL (primary), with SQLite support patterns used in migrations for tests/CI
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
**Target Platform**: Web (admin SaaS)
**Project Type**: Laravel monolith (Filament panels + Livewire + Artisan commands)
**Performance Goals**:
- Backfill updates run in batches to avoid long locks.
- Postgres uses `CONCURRENTLY` for large index creation where applicable.
**Constraints**:
- No new HTTP routes/pages.
- No planned downtime; staged rollout.
- Backfill is idempotent, resumable, and aborts on tenant→workspace mapping failures.
**Scale/Scope**: Potentially large datasets (unknown upper bound); plan assumes millions of rows are possible across inventory/backup/history tables.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshots: PASS (schema-only + backfill; no changes to inventory/snapshot semantics).
- Read/write separation: PASS (writes are limited to migrations + operator backfill; no UI “write surfaces” are added).
- Graph contract path: PASS (no Graph calls).
- Deterministic capabilities: PASS (no capability resolver changes).
- Workspace isolation: PASS (strengthens isolation by enforcing workspace binding at the data layer).
- Tenant isolation: PASS (tenant-owned tables remain tenant-scoped; DB constraints prevent cross-workspace mismatches).
- RBAC-UX / planes: PASS (no changes to `/admin` vs `/system`; no new access surfaces).
- Run observability: PASS (backfill is operationally relevant and will be tracked via `OperationRun` + `AuditLog`).
- Filament Action Surface Contract: N/A (no Filament Resource/Page changes).
## Project Structure
### Documentation (this feature)
```text
specs/093-scope-001-workspace-id-isolation/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── openapi.yaml
│ └── cli.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Console/
│ └── Commands/
├── Models/
└── Support/ (or Services/)
database/
└── migrations/
tests/
└── Feature/
```
**Structure Decision**: Implement as Laravel migrations + an Artisan operator command + model-level enforcement helpers, with Pest feature tests.
## Phase Plan
### Phase 0 — Research (complete)
Outputs:
- `specs/093-scope-001-workspace-id-isolation/research.md`
Key decisions captured:
- Tenant↔workspace consistency will be enforced with composite FKs on Postgres/MySQL.
- Audit invariant enforced with a check constraint.
### Phase 1 — Design & Contracts (complete)
Outputs:
- `specs/093-scope-001-workspace-id-isolation/data-model.md`
- `specs/093-scope-001-workspace-id-isolation/contracts/openapi.yaml` (no new routes)
- `specs/093-scope-001-workspace-id-isolation/contracts/cli.md` (Artisan backfill contract)
- `specs/093-scope-001-workspace-id-isolation/quickstart.md`
**Post-design constitution re-check**: PASS (no new external calls; operational backfill is observable).
### Phase 2 — Implementation Planning (next)
Implementation will be delivered as small, test-driven slices aligned to the staged rollout.
1) Phase 1 migrations — add nullable `workspace_id`
- Add `workspace_id` (nullable) + index to the 12 tenant-owned tables.
- Add baseline scoping indexes for expected query patterns (at minimum `workspace_id` and `(workspace_id, tenant_id)` where useful).
- Ensure migrations follow existing multi-driver patterns (SQLite fallbacks where needed).
2) Phase 1.5 — write-path enforcement (application)
- For each affected model/write path:
- On create: derive `workspace_id` from `tenant.workspace_id`.
- On update: reject changes to `tenant_id` (immutability) and reject explicit workspace mismatches.
- Ensure audit log writer sets `workspace_id` when `tenant_id` is present.
3) Phase 2 — backfill command (operator-only)
- Add `tenantpilot:backfill-workspace-ids`.
- Safety requirements:
- Acquire lock to prevent concurrent execution.
- Batch updates per table and allow resume/checkpoint.
- Abort and report table + sample IDs if a tenant→workspace mapping cannot be resolved.
- Observability:
- Create/reuse an `OperationRun` describing the backfill run.
- Write `AuditLog` summary entries for start/end/outcome.
- Execution strategy (queued):
- The command MUST be a lightweight start surface: authorize → acquire lock → create/reuse OperationRun → dispatch queued jobs → print a “View run” pointer.
- The actual backfill mutations MUST execute inside queued jobs (batch/table scoped) so large datasets do not require a single long-running synchronous CLI process.
- Implementation maps to `app/Console/Commands/TenantpilotBackfillWorkspaceIds.php` + `app/Jobs/BackfillWorkspaceIdsJob.php`.
- Jobs MUST update OperationRun progress/counters and record failures with stable reason codes + sanitized messages.
4) Phase 3 — constraints + validation + final indexes
- Tenant-owned tables:
- Set `workspace_id` to NOT NULL (after validation).
- Add FK `workspace_id → workspaces.id`.
- Add composite FK `(tenant_id, workspace_id) → tenants(id, workspace_id)` on Postgres/MySQL.
- For Postgres, prefer `NOT VALID` then `VALIDATE CONSTRAINT` to reduce lock time.
- Tenants:
- Add a unique constraint/index on `(id, workspace_id)` to support composite FKs.
- Audit logs:
- Backfill `workspace_id` for rows where `tenant_id` is present.
- Add check constraint: `tenant_id IS NULL OR workspace_id IS NOT NULL`.
- Index strategy:
- Use `CREATE INDEX CONCURRENTLY` on Postgres for large tables (migrations must not run in a transaction).
5) Pest tests (minimal, high-signal)
- Backfill correctness on a representative table (seed missing `workspace_id`, run backfill, assert set).
- DB constraint tests (where supported by test DB):
- `tenant_id` + mismatched `workspace_id` cannot be persisted after Phase 3 constraints.
- audit invariant: tenant-scoped audit requires workspace; workspace-only and platform-only are allowed.

View File

@ -0,0 +1,65 @@
# Quickstart — 093 Workspace ID Isolation
## Goal
Run the staged rollout locally/staging to ensure all tenant-owned tables are workspace-bound and audit invariants hold.
## Prereqs
- Sail is running: `vendor/bin/sail up -d`
- DB is migrated: `vendor/bin/sail artisan migrate`
- Queue worker is running (for Phase 2 jobs): `vendor/bin/sail artisan queue:work`
## Rollout Order
1) **Phase 1** — Add nullable `workspace_id` columns + indexes
- Deploy migrations
2) **Phase 1.5** — Deploy app write-path enforcement
- New/updated tenant-owned writes derive `workspace_id` from `tenant.workspace_id`
- Mismatches are rejected
3) **Phase 2** — Backfill existing rows
- Dry-run:
- `vendor/bin/sail artisan tenantpilot:backfill-workspace-ids --dry-run`
- Execute:
- `vendor/bin/sail artisan tenantpilot:backfill-workspace-ids`
Notes:
- This command dispatches queued jobs. If no queue worker is running, the run will be created but no rows will be updated.
- Monitor progress in the UI under **Monitoring → Operations** (the run will be recorded as an `OperationRun`).
4) **Phase 3** — Enforce constraints + validate + final indexes
- Apply NOT NULL + FKs + composite FKs
- Add audit_logs check constraint
## Validation SQL (Postgres)
Run these to confirm no missing bindings remain.
Tenant-owned tables:
- `SELECT count(*) FROM policies WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM policy_versions WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM backup_sets WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM backup_items WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM restore_runs WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM backup_schedules WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM inventory_items WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM inventory_links WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM entra_groups WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM findings WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM entra_role_definitions WHERE workspace_id IS NULL;`
- `SELECT count(*) FROM tenant_permissions WHERE workspace_id IS NULL;`
Audit invariant:
- `SELECT count(*) FROM audit_logs WHERE tenant_id IS NOT NULL AND workspace_id IS NULL;`
Tenant/workspace mismatch spot checks:
- `SELECT count(*) FROM policies p JOIN tenants t ON t.id = p.tenant_id WHERE p.workspace_id IS NOT NULL AND p.workspace_id <> 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.

View File

@ -0,0 +1,84 @@
# Research — 093 SCOPE-001 Workspace ID Isolation
**Date**: 2026-02-14
**Branch**: `093-scope-001-workspace-id-isolation`
## Current State (repo evidence)
### Workspace/Tenant relationship
- `workspaces.id` is a Laravel `$table->id()` (bigint).
- `tenants.workspace_id` exists and is nullable with an index; constraints are applied for non-sqlite drivers.
### Tenant-owned tables (target scope)
The 12 tenant-owned tables currently have `tenant_id` and do **not** have `workspace_id`:
- `policies` (created 2025_12_10_000110)
- `policy_versions` (created 2025_12_10_000120)
- `backup_sets` (created 2025_12_10_000130)
- `backup_items` (created 2025_12_10_000140)
- `restore_runs` (created 2025_12_10_000150)
- `backup_schedules` (created 2026_01_05_011014)
- `inventory_items` (created 2026_01_07_142720)
- `inventory_links` (created 2026_01_07_150000)
- `entra_groups` (created 2026_01_11_120003)
- `findings` (created 2026_01_13_223311)
- `entra_role_definitions` (created 2026_02_10_133238)
- `tenant_permissions` (created 2025_12_11_122423)
### Audit logs
- `audit_logs.tenant_id` is nullable.
- `audit_logs.workspace_id` exists and is nullable.
- There is **no** DB-level invariant today preventing `tenant_id != null` with `workspace_id == null`.
### Migration patterns already used in the repo
- Multi-driver migrations (`pgsql`, `mysql`, `sqlite`) exist.
- SQLite rebuild migrations are used when needed (rename old table, recreate, chunk copy).
- Postgres/MySQL NOT NULL enforcement is sometimes done with `DB::statement(...)`.
- Partial unique indexes are used via `DB::statement(...)`.
## Decisions
### Decision 1 — How to enforce tenant↔workspace consistency
**Decision**: Use a composite FK for tenant-owned tables on Postgres/MySQL: `(tenant_id, workspace_id)` references `tenants(id, workspace_id)`.
**Rationale**:
- Two independent FKs (`tenant_id → tenants.id` and `workspace_id → workspaces.id`) do not prevent mismatches.
- A composite FK makes the “workspace derived from tenant” rule enforceable at the DB level, aligning with SCOPE-001s intent.
**Alternatives considered**:
- App-only validation (insufficient for DB-level isolation goals).
- Triggers (more complex to deploy/test, harder to reason about).
- Postgres RLS (high operational cost; broad scope).
**Notes/requirements implied**:
- Add a unique constraint/index on `tenants (id, workspace_id)` (likely with `workspace_id IS NOT NULL`).
- For SQLite: skip composite FK enforcement (SQLite limitations) while keeping tests green; rely on application enforcement during tests.
### Decision 2 — Staged rollout
**Decision**: Follow the specs 4-phase rollout:
1) Add `workspace_id` nullable columns + indexes.
2) Enforce write-path assignment + mismatch rejection in the app.
3) Backfill missing `workspace_id` via an operator command (idempotent, resumable, locked).
4) Enforce constraints + validate + add final indexes.
**Rationale**: Avoid downtime and allow safe production backfill.
### Decision 3 — Audit log invariant
**Decision**: Add a DB check constraint on `audit_logs`:
- `tenant_id IS NULL OR workspace_id IS NOT NULL`
**Rationale**: Directly enforces FR-008 while preserving workspace-only and platform-only events.
**Alternative considered**:
- Enforce in application only (not sufficient for invariants).
### Decision 4 — Backfill observability
**Decision**: The backfill command creates/reuses an `OperationRun` and writes `AuditLog` entries for start/end/outcome.
**Rationale**: Matches FR-012 and the constitutions observability rules for operationally relevant actions.
## Open Questions (resolved by spec clarifications)
- Mismatch handling: reject writes when tenant/workspace mismatch is provided.
- Invalid mapping during backfill: abort and report.
- Tenant immutability: reject tenant_id updates.
- Query/view refactors: out of scope.

View File

@ -0,0 +1,165 @@
#+#+#+#+markdown
# Feature Specification: SCOPE-001 Workspace ID Isolation
**Feature Branch**: `093-scope-001-workspace-id-isolation`
**Created**: 2026-02-14
**Status**: Draft
**Input**: Enforce workspace isolation by binding all tenant-owned records to a workspace, with a safe staged rollout and corrected audit invariants.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: No new pages/routes. Affects all create/write paths that persist tenant-owned records in the tables listed below, plus operational tooling for a one-time backfill.
- **Data Ownership**:
- Tenant-owned tables impacted (must become explicitly workspace-bound):
- policies
- policy_versions
- backup_sets
- backup_items
- restore_runs
- backup_schedules
- inventory_items
- inventory_links
- entra_groups
- findings
- entra_role_definitions
- tenant_permissions
- Audit trail invariants impacted: audit_logs
- **RBAC**: No user-facing permissions change. Backfill execution is an operator-only workflow (platform/ops).
## Clarifications
### Session 2026-02-14
- Q: For the 12 tenant-owned tables, how strict should deterministic workspace binding be when a caller explicitly provides a workspace binding? → A: Reject any mismatch; if a record references a tenant, its workspace binding MUST equal the tenants workspace.
- Q: During backfill, what should happen if a rows tenant reference is invalid (tenant missing / cannot map tenant → workspace)? → A: Abort the backfill run and report the offending table plus sample identifiers for remediation.
- Q: Should tenant_id be allowed to change on existing rows in the 12 tenant-owned tables? → A: No; tenant_id is immutable and updates are rejected.
- Q: How should the backfill workflow be recorded for observability/audit? → A: Create/reuse an OperationRun for the backfill and also write an AuditLog summary for start/end/outcome.
- Q: Should this feature include updating existing canonical/operational queries/views to scope by workspace binding? → A: No; query/view changes are out of scope for 093 (follow-up later).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Enforce workspace ownership at the data layer (Priority: P1)
As a platform owner, I want every tenant-owned record to be explicitly bound to a workspace so that workspace isolation does not depend on application-only guardrails.
**Why this priority**: This closes a class of potential cross-workspace data leakage and makes operational reporting safer and simpler.
**Independent Test**: Can be tested by creating a tenant-owned record with a tenant reference and asserting that `workspace_id` is derived from the tenant and that any explicit mismatched `workspace_id` is rejected; backfill behavior is validated in User Story 2.
**Acceptance Scenarios**:
1. **Given** tenant-owned records exist without a workspace binding, **When** the staged rollout completes, **Then** all tenant-owned records are workspace-bound.
2. **Given** the rollout is in progress, **When** the system is used normally, **Then** there is no required downtime for administrators.
---
### User Story 2 - Safely backfill existing production data (Priority: P2)
As an operator, I want a safe, resumable way to backfill missing workspace bindings so that large datasets can be migrated without risky one-shot operations.
**Why this priority**: Without a safe backfill, enforcing data-level constraints risks outages and operational incidents.
**Independent Test**: Can be tested by seeding rows with missing workspace bindings, running the backfill workflow twice, and confirming idempotent outcomes.
**Acceptance Scenarios**:
1. **Given** a table contains rows missing workspace bindings, **When** the backfill is executed, **Then** those rows are updated to the correct workspace.
2. **Given** the backfill is interrupted and restarted, **When** it runs again, **Then** it resumes safely without corrupting already-correct rows.
---
### User Story 3 - Make audit logs unambiguous across scopes (Priority: P3)
As an auditor, I want tenant-scoped audit events to always be workspace-bound so that workspace isolation semantics are preserved in audit trails.
**Why this priority**: Audit trails are only reliable if their scope is structurally consistent and queryable.
**Independent Test**: Can be tested by attempting to store a tenant-scoped audit entry without a workspace binding and verifying it is rejected, while allowing workspace-only and platform-only entries.
**Acceptance Scenarios**:
1. **Given** an audit log entry references a tenant, **When** it is persisted, **Then** it must also reference a workspace.
2. **Given** an audit log entry is workspace-only or platform-only, **When** it is persisted, **Then** it remains valid according to the documented rules.
---
### Edge Cases
- Tenant-owned row references a tenant that no longer exists (or is otherwise invalid).
- Backfill is executed concurrently (must not produce conflicting outcomes).
- New rows are created while the backfill is in progress (must not reintroduce missing bindings).
- Partial completion: some tables are fully backfilled while others are pending.
- Mixed-scope audit events: tenant-scoped vs workspace-only vs platform-only.
- Attempt to change tenant_id on an existing tenant-owned record.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
### Functional Requirements
- **FR-001 (Schema coverage)**: The system MUST represent a workspace binding for every tenant-owned record in the 12 listed tables.
- **FR-002 (No missing bindings post-rollout)**: After completion of the rollout, the system MUST prevent creation or persistence of tenant-owned records without a workspace binding.
- **FR-003 (Deterministic binding for new writes)**: For any new or updated tenant-owned record, the system MUST derive the workspace binding from the referenced tenant (not from user/session context alone).
- **FR-003a (Mismatch handling)**: If a caller provides a workspace binding that does not match the referenced tenants workspace, the write MUST be rejected.
- **FR-004 (Safe staged rollout)**: The system MUST support a staged rollout that allows introducing the new field, deploying write-path enforcement, backfilling existing data, and only then enforcing strict constraints.
- **FR-005 (Idempotent backfill)**: Operators MUST be able to re-run the backfill safely; repeated runs MUST not regress already-correct data.
- **FR-006 (Operational safety)**: The backfill workflow MUST be safe for large datasets (batching/resume) and MUST prevent concurrent executions.
- **FR-006a (Invalid mapping handling)**: If the backfill workflow encounters a tenant-owned row that cannot be mapped from tenant → workspace, it MUST abort and report the offending table and sample identifiers for operator remediation.
- **FR-007 (Validation)**: Operators MUST be able to validate that no tenant-owned records remain without a workspace binding before strict constraints are enforced.
- **FR-011 (Tenant immutability)**: For tenant-owned records, tenant identity MUST be immutable after creation; attempts to change tenant_id MUST be rejected.
- **FR-012 (Backfill observability and audit)**: The backfill workflow MUST be observable via an OperationRun (progress + outcome) and MUST write an audit log summary entry for start/end/outcome.
- **FR-013 (Query/view scope)**: This feature MUST NOT require broad refactors of canonical/operational queries; it MUST focus on making workspace scoping structurally correct at the data layer.
- **FR-008 (Audit log invariant)**: If an audit log entry references a tenant, it MUST also reference a workspace.
- **FR-009 (Audit scope flexibility)**: The audit log MUST continue to support workspace-only events (workspace present, tenant absent) and platform-only events (both absent).
- **FR-010 (Canonical view scoping readiness)**: After rollout, operational/canonical queries SHOULD be able to scope tenant-owned data by workspace without relying on implicit joins or assumptions.
### Assumptions & Dependencies
- Each tenant belongs to exactly one workspace, and that mapping is the source of truth for deriving workspace bindings.
- The 12 listed tables are “tenant-owned” per SCOPE-001 and are expected to remain tenant-owned.
- Any existing tooling that creates tenant-owned records has enough information to reference the tenant (directly or indirectly) at write time.
### Key Entities *(include if feature involves data)*
- **Workspace**: The top-level isolation boundary for data access and operations.
- **Tenant**: A unit of configuration/data that is owned by exactly one workspace.
- **Tenant-owned record**: Any record that must be both tenant-scoped and workspace-scoped.
- **Audit event**: An immutable entry describing an action/event, which may be tenant-scoped, workspace-scoped, or platform-scoped.
- **Backfill run**: A controlled operational execution that updates legacy data and reports progress/outcomes.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Completeness)**: 100% of records in the 12 tenant-owned tables have a workspace binding after backfill and validation.
- **SC-002 (Integrity)**: After strict constraints are enabled, creating a tenant-owned record without a workspace binding is rejected.
- **SC-003 (Audit correctness)**: 100% of tenant-scoped audit events include a workspace binding; workspace-only and platform-only audit events remain valid.
- **SC-004 (Operational safety)**: The rollout requires no planned downtime for administrators.
- **SC-005 (Repeatability)**: Re-running the backfill after completion does not change already-correct records and can be used as a safety check.

View File

@ -0,0 +1,210 @@
# Tasks: 093 — SCOPE-001 Workspace ID Isolation
**Input**: Design documents from `/specs/093-scope-001-workspace-id-isolation/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
**Operations**: This feature introduces an operator command (long-running), so tasks include creating/reusing and updating a canonical `OperationRun` and creating `AuditLog` entries before/after.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include file paths in descriptions
## Path Conventions
- Laravel app code: `app/`
- Migrations: `database/migrations/`
- Pest tests: `tests/Feature/`
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm design inputs + existing code entrypoints
- [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
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared building blocks used across all stories
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [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)
---
## Phase 3: User Story 1 — Enforce workspace ownership at the data layer (Priority: P1) 🎯 MVP
**Goal**: Every tenant-owned record becomes explicitly workspace-bound, and the system prevents new tenant-owned writes without a correct workspace binding.
**Independent Test**:
- Creating a tenant-owned record without workspace_id results in workspace_id being derived from tenant.
- Creating/updating a tenant-owned record with a mismatched workspace_id is rejected.
### Tests for User Story 1
> NOTE: Write these tests first and ensure they fail before implementation.
- [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
- [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
- [X] T019a [US1] Enforce tenant_id immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php (reject updates when tenant_id differs from original)
- [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
- [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):**
- [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.
---
## Phase 4: User Story 2 — Safely backfill existing production data (Priority: P2)
**Goal**: Operators can safely backfill missing workspace_id across all 12 tables without downtime.
**Independent Test**:
- With seeded rows missing workspace_id, running the command sets workspace_id correctly.
- Re-running the command is idempotent.
- If a tenant→workspace mapping cannot be resolved, the command aborts and reports.
### Tests for User Story 2
> NOTE: Write these tests first and ensure they fail before implementation.
- [X] T035 [P] [US2] Add backfill command tests in tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php
### Implementation for User Story 2
- [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.
---
## Phase 5: User Story 3 — Make audit logs unambiguous across scopes (Priority: P3)
**Goal**: If an audit log entry references a tenant, it must also reference a workspace.
**Independent Test**:
- Tenant-scoped audit logs always store workspace_id.
- DB prevents tenant_id set with workspace_id null.
- Workspace-only and platform-only logs remain allowed.
### Tests for User Story 3
> NOTE: Write these tests first and ensure they fail before implementation.
- [X] T044 [P] [US3] Add audit invariant tests for tenant/workspace/platform scopes in tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php
### Implementation for User Story 3
- [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.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [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
---
## Dependencies & Execution Order
### Story order
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US1 constraints (T033T034) → US3 (Phase 5) → Polish
### Dependency graph (story-level)
- US1 (nullable columns + app enforcement) → blocks US2 (backfill)
- US2 (backfill) → blocks US1 constraints (T033T034)
- US3 can be started after Foundational, but DB check constraint should land after US2 backfill if historical audit_logs need repair first.
---
## Parallel execution examples
### Parallel Example: US1
- [P] T008T019 can run in parallel (independent migrations per table)
- [P] T020T031 can run in parallel (independent model updates)
### Parallel Example: US2
- [P] T035 (tests) can be written while T036T038 (command skeleton + reporting) are implemented
### Parallel Example: US3
- [P] T044 (tests) can be written while T045T047 are implemented
---
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 1 (Setup)
2. Complete Phase 2 (Foundational)
3. Complete US1 through app enforcement + nullable columns (T007T032)
4. STOP and validate US1 tests pass independently
### Incremental Delivery
1. Add US2 (backfill) and validate idempotency + observability (T035T043)
2. Enforce US1 post-backfill DB constraints (T033T034)
3. Add US3 audit invariant (T044T047)
4. Final polish/runbook validation (T048T049)

View File

@ -0,0 +1,89 @@
<?php
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Intune\AuditLogger;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
it('stores workspace_id for tenant scoped audit writes', function (): void {
$workspace = Workspace::factory()->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);
});

View File

@ -0,0 +1,144 @@
<?php
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
it('backfills missing workspace_id values for tenant-owned rows', 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-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);
});

View File

@ -0,0 +1,72 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\WorkspaceIsolation\WorkspaceIsolationViolation;
it('derives workspace_id from tenant when missing on create', function (): void {
$workspace = Workspace::factory()->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);
});