# 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.

