fix(schema): tighten tenant boundaries on resources/deps/creds/source_docs

Apply same composite-FK pattern as 001/002 for migration 003:
- resources: add UNIQUE (id, space_id) as FK target.
- resource_dependencies: denormalize space_id, composite FKs on both endpoints
  (enforces both ends of a dep live in the same space at the DB layer).
- resource_credentials: denormalize space_id, composite FK to resources.
- source_docs.resource_id: NOT NULL (tenancy anchor); resource_id was already
  absent from the update FIELDS list so docs cannot move resources.

Repos derive space_id from the resource on addDependency/addCredential so callers
can't fake cross-tenant assignment. 3 regression tests added.
This commit is contained in:
root
2026-05-31 10:33:17 +10:00
parent 9dd944226d
commit 6086cf9a7a
3 changed files with 76 additions and 13 deletions

View File

@@ -16,30 +16,41 @@ CREATE TABLE resources (
maintenance_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
UNIQUE (space_id, slug),
-- Composite key target for child tables enforcing same-space references
UNIQUE (id, space_id)
);
-- Both endpoints of a dependency must live in the same space.
CREATE TABLE resource_dependencies (
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
depends_on uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
resource_id uuid NOT NULL,
depends_on uuid NOT NULL,
space_id uuid NOT NULL,
kind text,
PRIMARY KEY (resource_id, depends_on),
CHECK (resource_id <> depends_on)
CHECK (resource_id <> depends_on),
FOREIGN KEY (resource_id, space_id) REFERENCES resources(id, space_id) ON DELETE CASCADE,
FOREIGN KEY (depends_on, space_id) REFERENCES resources(id, space_id) ON DELETE CASCADE
);
-- Credentials inherit the resource's space; cross-tenant assignment impossible at DB layer.
CREATE TABLE resource_credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
resource_id uuid NOT NULL,
space_id uuid NOT NULL,
label text NOT NULL,
vault_path text NOT NULL,
kind text,
notes text,
created_at timestamptz NOT NULL DEFAULT now()
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (resource_id, space_id) REFERENCES resources(id, space_id) ON DELETE CASCADE
);
-- source_docs: resource_id is NOT NULL (anchors tenancy) and not in repo update field list,
-- so a source doc cannot be moved to a different resource. Tenancy inherited transitively.
CREATE TABLE source_docs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id uuid REFERENCES resources(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
name text NOT NULL,
upstream_url text NOT NULL,
version text,
@@ -55,6 +66,8 @@ CREATE TABLE source_docs (
);
CREATE INDEX idx_resources_space ON resources(space_id);
CREATE INDEX idx_resource_deps_space ON resource_dependencies(space_id);
CREATE INDEX idx_resource_creds_resource ON resource_credentials(resource_id, space_id);
CREATE INDEX idx_source_docs_resource ON source_docs(resource_id);
CREATE INDEX idx_source_docs_fts ON source_docs
USING GIN (to_tsvector('english', coalesce(body_text,'')));