feat: enforce workspace isolation
This commit is contained in:
parent
f8763a8f26
commit
49e2b5072b
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal file
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal 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;
|
||||
}
|
||||
}
|
||||
207
app/Jobs/BackfillWorkspaceIdsJob.php
Normal file
207
app/Jobs/BackfillWorkspaceIdsJob.php
Normal 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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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,
|
||||
|
||||
115
app/Support/Concerns/DerivesWorkspaceIdFromTenant.php
Normal file
115
app/Support/Concerns/DerivesWorkspaceIdFromTenant.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Support/WorkspaceIsolation/TenantOwnedTables.php
Normal file
34
app/Support/WorkspaceIsolation/TenantOwnedTables.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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.
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -119,7 +119,7 @@ ### Phase 2 — Implementation Planning (next)
|
||||
- Ensure audit log writer sets `workspace_id` when `tenant_id` is present.
|
||||
|
||||
3) Phase 2 — backfill command (operator-only)
|
||||
- Add `tenantpilot:backfill-workspace-ids` (name TBD).
|
||||
- Add `tenantpilot:backfill-workspace-ids`.
|
||||
- Safety requirements:
|
||||
- Acquire lock to prevent concurrent execution.
|
||||
- Batch updates per table and allow resume/checkpoint.
|
||||
@ -131,6 +131,7 @@ ### Phase 2 — Implementation Planning (next)
|
||||
- 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
|
||||
@ -152,4 +153,3 @@ ### Phase 2 — Implementation Planning (next)
|
||||
- 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.
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ ## 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
|
||||
|
||||
@ -24,6 +25,10 @@ ## Rollout Order
|
||||
- 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
|
||||
@ -49,8 +54,12 @@ ## Validation SQL (Postgres)
|
||||
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.
|
||||
|
||||
|
||||
@ -23,9 +23,9 @@ ## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm design inputs + existing code entrypoints
|
||||
|
||||
- [ ] T001 Verify feature docs are present and consistent in specs/093-scope-001-workspace-id-isolation/{spec.md,plan.md,research.md,data-model.md,contracts/,quickstart.md,tasks.md}
|
||||
- [ ] T002 [P] Inventory target models exist for the 12 tables in app/Models/{Policy,PolicyVersion,BackupSet,BackupItem,RestoreRun,BackupSchedule,InventoryItem,InventoryLink,EntraGroup,Finding,EntraRoleDefinition,TenantPermission}.php
|
||||
- [ ] T003 [P] Identify audit logging entrypoints that must set workspace_id in app/Services/Intune/AuditLogger.php and app/Services/Audit/WorkspaceAuditLogger.php
|
||||
- [X] T001 Verify feature docs are present and consistent in specs/093-scope-001-workspace-id-isolation/{spec.md,plan.md,research.md,data-model.md,contracts/,quickstart.md,tasks.md}
|
||||
- [X] T002 [P] Inventory target models exist for the 12 tables in app/Models/{Policy,PolicyVersion,BackupSet,BackupItem,RestoreRun,BackupSchedule,InventoryItem,InventoryLink,EntraGroup,Finding,EntraRoleDefinition,TenantPermission}.php
|
||||
- [X] T003 [P] Identify audit logging entrypoints that must set workspace_id in app/Services/Intune/AuditLogger.php and app/Services/Audit/WorkspaceAuditLogger.php
|
||||
|
||||
---
|
||||
|
||||
@ -35,9 +35,9 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [ ] T004 Create shared model concern for workspace binding + tenant immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php
|
||||
- [ ] T005 [P] Add supporting exception type for mismatch/immutability errors in app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php
|
||||
- [ ] T006 Add a small list of tenant-owned table names for reuse (command + tests) in app/Support/WorkspaceIsolation/TenantOwnedTables.php
|
||||
- [X] T004 Create shared model concern for workspace binding + tenant immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php
|
||||
- [X] T005 [P] Add supporting exception type for mismatch/immutability errors in app/Support/WorkspaceIsolation/WorkspaceIsolationViolation.php
|
||||
- [X] T006 Add a small list of tenant-owned table names for reuse (command + tests) in app/Support/WorkspaceIsolation/TenantOwnedTables.php
|
||||
|
||||
**Checkpoint**: Foundation ready (shared enforcement building blocks exist)
|
||||
|
||||
@ -55,44 +55,44 @@ ### Tests for User Story 1
|
||||
|
||||
> NOTE: Write these tests first and ensure they fail before implementation.
|
||||
|
||||
- [ ] T007 [P] [US1] Add unit-level enforcement tests for the shared concern in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php
|
||||
- [ ] T007a [P] [US1] Add immutability test case (attempt to change tenant_id is rejected) in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php
|
||||
- [X] T007 [P] [US1] Add unit-level enforcement tests for the shared concern in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php
|
||||
- [X] T007a [P] [US1] Add immutability test case (attempt to change tenant_id is rejected) in tests/Feature/WorkspaceIsolation/DerivesWorkspaceIdFromTenantTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T008 [P] [US1] Add nullable workspace_id + indexes to policies in database/migrations/*_add_workspace_id_to_policies_table.php
|
||||
- [ ] T009 [P] [US1] Add nullable workspace_id + indexes to policy_versions in database/migrations/*_add_workspace_id_to_policy_versions_table.php
|
||||
- [ ] T010 [P] [US1] Add nullable workspace_id + indexes to backup_sets in database/migrations/*_add_workspace_id_to_backup_sets_table.php
|
||||
- [ ] T011 [P] [US1] Add nullable workspace_id + indexes to backup_items in database/migrations/*_add_workspace_id_to_backup_items_table.php
|
||||
- [ ] T012 [P] [US1] Add nullable workspace_id + indexes to restore_runs in database/migrations/*_add_workspace_id_to_restore_runs_table.php
|
||||
- [ ] T013 [P] [US1] Add nullable workspace_id + indexes to backup_schedules in database/migrations/*_add_workspace_id_to_backup_schedules_table.php
|
||||
- [ ] T014 [P] [US1] Add nullable workspace_id + indexes to inventory_items in database/migrations/*_add_workspace_id_to_inventory_items_table.php
|
||||
- [ ] T015 [P] [US1] Add nullable workspace_id + indexes to inventory_links in database/migrations/*_add_workspace_id_to_inventory_links_table.php
|
||||
- [ ] T016 [P] [US1] Add nullable workspace_id + indexes to entra_groups in database/migrations/*_add_workspace_id_to_entra_groups_table.php
|
||||
- [ ] T017 [P] [US1] Add nullable workspace_id + indexes to findings in database/migrations/*_add_workspace_id_to_findings_table.php
|
||||
- [ ] T018 [P] [US1] Add nullable workspace_id + indexes to entra_role_definitions in database/migrations/*_add_workspace_id_to_entra_role_definitions_table.php
|
||||
- [ ] T019 [P] [US1] Add nullable workspace_id + indexes to tenant_permissions in database/migrations/*_add_workspace_id_to_tenant_permissions_table.php
|
||||
- [X] T008 [P] [US1] Add nullable workspace_id + indexes to policies in database/migrations/*_add_workspace_id_to_policies_table.php
|
||||
- [X] T009 [P] [US1] Add nullable workspace_id + indexes to policy_versions in database/migrations/*_add_workspace_id_to_policy_versions_table.php
|
||||
- [X] T010 [P] [US1] Add nullable workspace_id + indexes to backup_sets in database/migrations/*_add_workspace_id_to_backup_sets_table.php
|
||||
- [X] T011 [P] [US1] Add nullable workspace_id + indexes to backup_items in database/migrations/*_add_workspace_id_to_backup_items_table.php
|
||||
- [X] T012 [P] [US1] Add nullable workspace_id + indexes to restore_runs in database/migrations/*_add_workspace_id_to_restore_runs_table.php
|
||||
- [X] T013 [P] [US1] Add nullable workspace_id + indexes to backup_schedules in database/migrations/*_add_workspace_id_to_backup_schedules_table.php
|
||||
- [X] T014 [P] [US1] Add nullable workspace_id + indexes to inventory_items in database/migrations/*_add_workspace_id_to_inventory_items_table.php
|
||||
- [X] T015 [P] [US1] Add nullable workspace_id + indexes to inventory_links in database/migrations/*_add_workspace_id_to_inventory_links_table.php
|
||||
- [X] T016 [P] [US1] Add nullable workspace_id + indexes to entra_groups in database/migrations/*_add_workspace_id_to_entra_groups_table.php
|
||||
- [X] T017 [P] [US1] Add nullable workspace_id + indexes to findings in database/migrations/*_add_workspace_id_to_findings_table.php
|
||||
- [X] T018 [P] [US1] Add nullable workspace_id + indexes to entra_role_definitions in database/migrations/*_add_workspace_id_to_entra_role_definitions_table.php
|
||||
- [X] T019 [P] [US1] Add nullable workspace_id + indexes to tenant_permissions in database/migrations/*_add_workspace_id_to_tenant_permissions_table.php
|
||||
|
||||
- [ ] T019a [US1] Enforce tenant_id immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php (reject updates when tenant_id differs from original)
|
||||
- [X] T019a [US1] Enforce tenant_id immutability in app/Support/Concerns/DerivesWorkspaceIdFromTenant.php (reject updates when tenant_id differs from original)
|
||||
|
||||
- [ ] T020 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Policy model in app/Models/Policy.php
|
||||
- [ ] T021 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to PolicyVersion model in app/Models/PolicyVersion.php
|
||||
- [ ] T022 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSet model in app/Models/BackupSet.php
|
||||
- [ ] T023 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupItem model in app/Models/BackupItem.php
|
||||
- [ ] T024 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to RestoreRun model in app/Models/RestoreRun.php
|
||||
- [ ] T025 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSchedule model in app/Models/BackupSchedule.php
|
||||
- [ ] T026 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryItem model in app/Models/InventoryItem.php
|
||||
- [ ] T027 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryLink model in app/Models/InventoryLink.php
|
||||
- [ ] T028 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraGroup model in app/Models/EntraGroup.php
|
||||
- [ ] T029 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Finding model in app/Models/Finding.php
|
||||
- [ ] T030 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraRoleDefinition model in app/Models/EntraRoleDefinition.php
|
||||
- [ ] T031 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to TenantPermission model in app/Models/TenantPermission.php
|
||||
- [X] T020 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Policy model in app/Models/Policy.php
|
||||
- [X] T021 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to PolicyVersion model in app/Models/PolicyVersion.php
|
||||
- [X] T022 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSet model in app/Models/BackupSet.php
|
||||
- [X] T023 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupItem model in app/Models/BackupItem.php
|
||||
- [X] T024 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to RestoreRun model in app/Models/RestoreRun.php
|
||||
- [X] T025 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to BackupSchedule model in app/Models/BackupSchedule.php
|
||||
- [X] T026 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryItem model in app/Models/InventoryItem.php
|
||||
- [X] T027 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to InventoryLink model in app/Models/InventoryLink.php
|
||||
- [X] T028 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraGroup model in app/Models/EntraGroup.php
|
||||
- [X] T029 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to Finding model in app/Models/Finding.php
|
||||
- [X] T030 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to EntraRoleDefinition model in app/Models/EntraRoleDefinition.php
|
||||
- [X] T031 [P] [US1] Apply DerivesWorkspaceIdFromTenant concern to TenantPermission model in app/Models/TenantPermission.php
|
||||
|
||||
- [ ] T032 [US1] Add tenants composite uniqueness to support composite FK in database/migrations/*_add_tenants_id_workspace_id_unique.php
|
||||
- [X] T032 [US1] Add tenants composite uniqueness to support composite FK in database/migrations/*_add_tenants_id_workspace_id_unique.php
|
||||
|
||||
**Post-backfill constraints (depends on US2 completion):**
|
||||
- [ ] T033 [US1] Enforce NOT NULL workspace_id for the 12 tenant-owned tables in database/migrations/*_enforce_workspace_id_not_null_on_tenant_owned_tables.php
|
||||
- [ ] T034 [US1] Add FK workspace_id → workspaces.id + composite FK (tenant_id, workspace_id) → tenants(id, workspace_id) for the 12 tables in database/migrations/*_add_workspace_isolation_constraints_to_tenant_owned_tables.php
|
||||
- [X] T033 [US1] Enforce NOT NULL workspace_id for the 12 tenant-owned tables in database/migrations/*_enforce_workspace_id_not_null_on_tenant_owned_tables.php
|
||||
- [X] T034 [US1] Add FK workspace_id → workspaces.id + composite FK (tenant_id, workspace_id) → tenants(id, workspace_id) for the 12 tables in database/migrations/*_add_workspace_isolation_constraints_to_tenant_owned_tables.php
|
||||
|
||||
**Checkpoint**: US1 complete once new writes are safe and DB constraints are enforceable after backfill.
|
||||
|
||||
@ -111,20 +111,20 @@ ### Tests for User Story 2
|
||||
|
||||
> NOTE: Write these tests first and ensure they fail before implementation.
|
||||
|
||||
- [ ] T035 [P] [US2] Add backfill command tests in tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php
|
||||
- [X] T035 [P] [US2] Add backfill command tests in tests/Feature/WorkspaceIsolation/BackfillWorkspaceIdsCommandTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T036 [US2] Implement operator command skeleton + options in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [ ] T036a [US2] Implement queued job runner for batch/table backfills in app/Jobs/BackfillWorkspaceIdsJob.php
|
||||
- [ ] T036b [US2] Dispatch jobs from app/Console/Commands/TenantpilotBackfillWorkspaceIds.php and ensure “start → dispatch → view run” flow
|
||||
- [ ] T037 [US2] Add concurrency lock (Cache::lock) to prevent concurrent backfills in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [ ] T038 [US2] Implement dry-run counts + per-table reporting in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [ ] T039 [US2] Implement per-workspace OperationRun creation in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/OperationRunService.php (ensureWorkspaceRunWithIdentity)
|
||||
- [ ] T040 [US2] Write start/end/outcome AuditLog summaries per workspace in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/Audit/WorkspaceAuditLogger.php
|
||||
- [ ] T041 [US2] Implement backfill updates for all 12 tables in app/Jobs/BackfillWorkspaceIdsJob.php (UPDATE ... FROM tenants WHERE workspace_id IS NULL)
|
||||
- [ ] T042 [US2] Implement abort-and-report behavior when tenant workspace_id is missing/unresolvable in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [ ] T043 [US2] Implement progress tracking (counts + last processed id) persisted into OperationRun context from app/Jobs/BackfillWorkspaceIdsJob.php
|
||||
- [X] T036 [US2] Implement operator command skeleton + options in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [X] T036a [US2] Implement queued job runner for batch/table backfills in app/Jobs/BackfillWorkspaceIdsJob.php
|
||||
- [X] T036b [US2] Dispatch jobs from app/Console/Commands/TenantpilotBackfillWorkspaceIds.php and ensure “start → dispatch → view run” flow
|
||||
- [X] T037 [US2] Add concurrency lock (Cache::lock) to prevent concurrent backfills in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [X] T038 [US2] Implement dry-run counts + per-table reporting in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [X] T039 [US2] Implement per-workspace OperationRun creation in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/OperationRunService.php (ensureWorkspaceRunWithIdentity)
|
||||
- [X] T040 [US2] Write start/end/outcome AuditLog summaries per workspace in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php using app/Services/Audit/WorkspaceAuditLogger.php
|
||||
- [X] T041 [US2] Implement backfill updates for all 12 tables in app/Jobs/BackfillWorkspaceIdsJob.php (UPDATE ... FROM tenants WHERE workspace_id IS NULL)
|
||||
- [X] T042 [US2] Implement abort-and-report behavior when tenant workspace_id is missing/unresolvable in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
|
||||
- [X] T043 [US2] Implement progress tracking (counts + last processed id) persisted into OperationRun context from app/Jobs/BackfillWorkspaceIdsJob.php
|
||||
|
||||
**Checkpoint**: US2 complete when backfill is safe to run repeatedly and produces OperationRun + AuditLog observability.
|
||||
|
||||
@ -143,13 +143,13 @@ ### Tests for User Story 3
|
||||
|
||||
> NOTE: Write these tests first and ensure they fail before implementation.
|
||||
|
||||
- [ ] T044 [P] [US3] Add audit invariant tests for tenant/workspace/platform scopes in tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php
|
||||
- [X] T044 [P] [US3] Add audit invariant tests for tenant/workspace/platform scopes in tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T045 [US3] Ensure tenant-scoped audit writes include workspace_id in app/Services/Intune/AuditLogger.php
|
||||
- [ ] T046 [US3] Add migration to backfill audit_logs.workspace_id where tenant_id is present (join tenants) in database/migrations/*_backfill_workspace_id_on_audit_logs.php
|
||||
- [ ] T047 [US3] Add check constraint enforcing tenant_id IS NULL OR workspace_id IS NOT NULL in database/migrations/*_add_audit_logs_scope_check_constraint.php
|
||||
- [X] T045 [US3] Ensure tenant-scoped audit writes include workspace_id in app/Services/Intune/AuditLogger.php
|
||||
- [X] T046 [US3] Add migration to backfill audit_logs.workspace_id where tenant_id is present (join tenants) in database/migrations/*_backfill_workspace_id_on_audit_logs.php
|
||||
- [X] T047 [US3] Add check constraint enforcing tenant_id IS NULL OR workspace_id IS NOT NULL in database/migrations/*_add_audit_logs_scope_check_constraint.php
|
||||
|
||||
**Checkpoint**: US3 complete when invariant is enforced in both app writes and DB.
|
||||
|
||||
@ -157,8 +157,8 @@ ### Implementation for User Story 3
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [ ] T048 [P] Add validation SQL snippets for operators in specs/093-scope-001-workspace-id-isolation/quickstart.md
|
||||
- [ ] T049 Ensure tasks and rollout order remain accurate after implementation changes in specs/093-scope-001-workspace-id-isolation/plan.md
|
||||
- [X] T048 [P] Add validation SQL snippets for operators in specs/093-scope-001-workspace-id-isolation/quickstart.md
|
||||
- [X] T049 Ensure tasks and rollout order remain accurate after implementation changes in specs/093-scope-001-workspace-id-isolation/plan.md
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user