Files
Void-Homelab/lib/db/migrations/002_knowledge.sql
root 9dd944226d fix(schema): tighten tenant boundaries on pages/refs
Three security-review findings on migration 002:
- pages.space_id and refs.space_id changed to NOT NULL + ON DELETE CASCADE
  (was nullable + SET NULL, which allowed orphan rows surviving space deletion).
- pages.parent_id wrapped in composite FK (parent_id, space_id) -> pages(id, space_id)
  to prevent cross-space parent linkage (same pattern as tasks.project_id in 001).
- idx_refs_external promoted to UNIQUE on (space_id, source_kind, external_id);
  upsertByExternal now requires space_id and dedups per-space, not globally.

Added 3 regression tests covering composite FK rejection, CASCADE-on-space-delete,
and per-space dedup independence.
2026-05-31 02:21:47 +10:00

70 lines
2.6 KiB
SQL

CREATE TABLE pages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
slug text NOT NULL,
title text NOT NULL,
body_md text NOT NULL DEFAULT '',
body_html text,
parent_id uuid,
-- Same-space parent enforcement (mirrors tasks.project_id pattern)
FOREIGN KEY (parent_id, space_id) REFERENCES pages(id, space_id)
ON DELETE SET NULL (parent_id),
embedding vector(1024),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug),
-- Composite key target for parent_id self-reference above + any future FK
UNIQUE (id, space_id)
);
CREATE TABLE page_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
body_md text NOT NULL,
edited_by text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE refs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
kind text NOT NULL
CHECK (kind IN ('url','video','pdf','image','file')),
source_url text,
title text,
description text,
summary text,
body_text text,
blob_path text,
thumbnail text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
embedding vector(1024),
status text NOT NULL DEFAULT 'ingested'
CHECK (status IN ('ingested','indexed','enriched')),
source_kind text,
external_id text,
captured_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_pages_space ON pages(space_id);
CREATE INDEX idx_pages_parent ON pages(parent_id);
CREATE INDEX idx_pages_fts ON pages
USING GIN (to_tsvector('english', title || ' ' || body_md));
CREATE INDEX idx_pages_embed ON pages
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_refs_space ON refs(space_id);
CREATE INDEX idx_refs_kind ON refs(kind);
-- Per-space uniqueness on external dedup (mirrors tenancy-first pattern of 001).
-- upsertByExternal MUST pass space_id; cross-space same-external_id creates distinct rows.
CREATE UNIQUE INDEX idx_refs_external_unique
ON refs(space_id, source_kind, external_id)
WHERE source_kind IS NOT NULL AND external_id IS NOT NULL;
CREATE INDEX idx_refs_fts ON refs USING GIN (
to_tsvector('english',
coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body_text,''))
);
CREATE INDEX idx_refs_embed ON refs USING hnsw (embedding vector_cosine_ops);