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

@@ -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;
}