diff --git a/lib/db/migrations/003_resources.sql b/lib/db/migrations/003_resources.sql index 491c3cf..6b9f534 100644 --- a/lib/db/migrations/003_resources.sql +++ b/lib/db/migrations/003_resources.sql @@ -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,''))); diff --git a/lib/db/repos/resources.js b/lib/db/repos/resources.js index 2d975e9..972e1df 100644 --- a/lib/db/repos/resources.js +++ b/lib/db/repos/resources.js @@ -54,10 +54,16 @@ export async function del(id, actor) { export async function addDependency(resource_id, depends_on, kind) { if (resource_id === depends_on) throw new Error('resource cannot depend on itself'); + // Derive space_id from the source resource; the composite FK rejects + // cross-space links at the DB layer. + const { rows: [src] } = await pool.query( + `SELECT space_id FROM resources WHERE id=$1`, [resource_id] + ); + if (!src) throw new Error(`resource ${resource_id} not found`); await pool.query( - `INSERT INTO resource_dependencies(resource_id, depends_on, kind) - VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, - [resource_id, depends_on, kind || null] + `INSERT INTO resource_dependencies(resource_id, depends_on, space_id, kind) + VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [resource_id, depends_on, src.space_id, kind || null] ); } @@ -76,10 +82,15 @@ export async function listDependencies(resource_id) { } export async function addCredential(resource_id, { label, vault_path, kind, notes }) { + // Derive space_id from the resource so caller can't fake cross-tenant assignment. + const { rows: [src] } = await pool.query( + `SELECT space_id FROM resources WHERE id=$1`, [resource_id] + ); + if (!src) throw new Error(`resource ${resource_id} not found`); const { rows: [r] } = await pool.query( - `INSERT INTO resource_credentials(resource_id, label, vault_path, kind, notes) - VALUES($1,$2,$3,$4,$5) RETURNING *`, - [resource_id, label, vault_path, kind || null, notes || null] + `INSERT INTO resource_credentials(resource_id, space_id, label, vault_path, kind, notes) + VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, + [resource_id, src.space_id, label, vault_path, kind || null, notes || null] ); return r; } diff --git a/tests/db/migration_003.test.js b/tests/db/migration_003.test.js index b872b82..e70409a 100644 --- a/tests/db/migration_003.test.js +++ b/tests/db/migration_003.test.js @@ -15,4 +15,43 @@ describe('migration 003 — resources', () => { } }); }); + + it('resource_dependencies rejects cross-space endpoints', async () => { + await withClient(async (c) => { + const { rows: [s1] } = await c.query(`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id;`); + const { rows: [s2] } = await c.query(`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id;`); + const { rows: [a] } = await c.query( + `INSERT INTO resources(space_id, slug, name, runtime_type) VALUES($1,'a','A','lxc') RETURNING id;`, + [s1.id]); + const { rows: [b] } = await c.query( + `INSERT INTO resources(space_id, slug, name, runtime_type) VALUES($1,'b','B','lxc') RETURNING id;`, + [s2.id]); + await expect(c.query( + `INSERT INTO resource_dependencies(resource_id, depends_on, space_id) VALUES($1,$2,$3);`, + [a.id, b.id, s1.id] + )).rejects.toThrow(/foreign key/i); + }); + }); + + it('resource_credentials rejects assignment to a resource in another space', async () => { + await withClient(async (c) => { + const { rows: [s1] } = await c.query(`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id;`); + const { rows: [s2] } = await c.query(`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id;`); + const { rows: [r] } = await c.query( + `INSERT INTO resources(space_id, slug, name, runtime_type) VALUES($1,'r','R','lxc') RETURNING id;`, + [s1.id]); + await expect(c.query( + `INSERT INTO resource_credentials(resource_id, space_id, label, vault_path) VALUES($1,$2,'l','env:X');`, + [r.id, s2.id] + )).rejects.toThrow(/foreign key/i); + }); + }); + + it('source_docs.resource_id is NOT NULL', async () => { + await withClient(async (c) => { + await expect(c.query( + `INSERT INTO source_docs(name, upstream_url) VALUES('n','https://x');` + )).rejects.toThrow(/not.null|null value/i); + }); + }); });