TenantAtlas/specs/093-scope-001-workspace-id-isolation/research.md
ahmido 92a36ab89e SCOPE-001: DB-level workspace isolation via workspace_id (#112)
Implements Spec 093 (SCOPE-001) workspace isolation at the data layer.

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

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

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

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

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

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #112
2026-02-14 22:34:02 +00:00

85 lines
3.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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