diff --git a/composer.json b/composer.json index 3478e5d..29c045c 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,9 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "test:pgsql": [ + "@php vendor/bin/pest -c phpunit.pgsql.xml" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi", diff --git a/database/migrations/2026_02_15_005041_ensure_workspace_id_fks_on_tenant_owned_tables.php b/database/migrations/2026_02_15_005041_ensure_workspace_id_fks_on_tenant_owned_tables.php new file mode 100644 index 0000000..5b491b3 --- /dev/null +++ b/database/migrations/2026_02_15_005041_ensure_workspace_id_fks_on_tenant_owned_tables.php @@ -0,0 +1,102 @@ + + */ + 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', + ]; + } + + private function ensureWorkspaceForeignKey(string $tableName, string $constraintName): void + { + // Add as NOT VALID (fast) then VALIDATE (safe) — Postgres best practice. + DB::unprepared(<<tenantOwnedTables() as $tableName) { + $constraintName = sprintf('%s_workspace_fk', $tableName); + + $this->ensureWorkspaceForeignKey($tableName, $constraintName); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (DB::getDriverName() !== 'pgsql') { + return; + } + + foreach ($this->tenantOwnedTables() as $tableName) { + $constraintName = sprintf('%s_workspace_fk', $tableName); + + DB::unprepared(<< + + + + tests/Unit + + + tests/Feature + + + + + + app + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php b/tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php new file mode 100644 index 0000000..ac44034 --- /dev/null +++ b/tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php @@ -0,0 +1,48 @@ +markTestSkipped('Postgres-only: validates FK constraints via pg_constraint.'); + } + + $tables = [ + 'policies', + 'policy_versions', + 'backup_sets', + 'backup_items', + 'restore_runs', + 'backup_schedules', + 'inventory_items', + 'inventory_links', + 'entra_groups', + 'findings', + 'entra_role_definitions', + 'tenant_permissions', + ]; + + foreach ($tables as $table) { + $sql = <<<'SQL' + SELECT c.conname, c.convalidated + FROM pg_constraint c + JOIN pg_class rel ON rel.oid = c.conrelid + JOIN pg_class ref ON ref.oid = c.confrelid + JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = ANY(c.conkey) + WHERE c.contype = 'f' + AND rel.relname = ? + AND ref.relname = 'workspaces' + AND att.attname = 'workspace_id' + SQL; + + $constraints = DB::select( + $sql, + [$table], + ); + + expect($constraints)->not->toBeEmpty(); + + $allValidated = collect($constraints)->every(fn ($c): bool => (bool) $c->convalidated); + expect($allValidated)->toBeTrue(); + } +});