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:
@@ -16,30 +16,41 @@ CREATE TABLE resources (
|
|||||||
maintenance_until timestamptz,
|
maintenance_until timestamptz,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_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 (
|
CREATE TABLE resource_dependencies (
|
||||||
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
resource_id uuid NOT NULL,
|
||||||
depends_on uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
depends_on uuid NOT NULL,
|
||||||
|
space_id uuid NOT NULL,
|
||||||
kind text,
|
kind text,
|
||||||
PRIMARY KEY (resource_id, depends_on),
|
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 (
|
CREATE TABLE resource_credentials (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
label text NOT NULL,
|
||||||
vault_path text NOT NULL,
|
vault_path text NOT NULL,
|
||||||
kind text,
|
kind text,
|
||||||
notes 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 (
|
CREATE TABLE source_docs (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
name text NOT NULL,
|
||||||
upstream_url text NOT NULL,
|
upstream_url text NOT NULL,
|
||||||
version text,
|
version text,
|
||||||
@@ -55,6 +66,8 @@ CREATE TABLE source_docs (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_resources_space ON resources(space_id);
|
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_resource ON source_docs(resource_id);
|
||||||
CREATE INDEX idx_source_docs_fts ON source_docs
|
CREATE INDEX idx_source_docs_fts ON source_docs
|
||||||
USING GIN (to_tsvector('english', coalesce(body_text,'')));
|
USING GIN (to_tsvector('english', coalesce(body_text,'')));
|
||||||
|
|||||||
@@ -54,10 +54,16 @@ export async function del(id, actor) {
|
|||||||
|
|
||||||
export async function addDependency(resource_id, depends_on, kind) {
|
export async function addDependency(resource_id, depends_on, kind) {
|
||||||
if (resource_id === depends_on) throw new Error('resource cannot depend on itself');
|
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(
|
await pool.query(
|
||||||
`INSERT INTO resource_dependencies(resource_id, depends_on, kind)
|
`INSERT INTO resource_dependencies(resource_id, depends_on, space_id, kind)
|
||||||
VALUES($1,$2,$3) ON CONFLICT DO NOTHING`,
|
VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
|
||||||
[resource_id, depends_on, kind || null]
|
[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 }) {
|
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(
|
const { rows: [r] } = await pool.query(
|
||||||
`INSERT INTO resource_credentials(resource_id, label, vault_path, kind, notes)
|
`INSERT INTO resource_credentials(resource_id, space_id, label, vault_path, kind, notes)
|
||||||
VALUES($1,$2,$3,$4,$5) RETURNING *`,
|
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||||
[resource_id, label, vault_path, kind || null, notes || null]
|
[resource_id, src.space_id, label, vault_path, kind || null, notes || null]
|
||||||
);
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user