# 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-001’s 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 spec’s 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 constitution’s 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.