CI (SIAX Cloud) / quality (push) Successful in 50s
CI (SIAX Cloud) / integration (push) Successful in 35s
CI (SIAX Cloud) / security (push) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / quality (pull_request) Successful in 46s
CI (SIAX Cloud) / integration (pull_request) Successful in 35s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
AnalyticsSQLSession is a SEPARATE class, not a method on SiaxAnalyticsEngine.
executeMetric has no raw-SQL surface at all; on the same object the closed DSL
would be one refactor away from growing one. ClickHouseAnalyticsEngine
.executeQuery stays a documented refusal.
FOUR LAYERS, and the guard is deliberately called the weakest:
1 role — the CL0UD analyst role is checked when the SESSION IS OPENED, so
a caller without it never obtains an object that can run SQL.
2 guard — statement admission. Comments and literals removed with
ClickHouse's own lexical rules, ASCII-only outside literals, one
statement, SELECT/WITH only, and every table reference POSITIVELY
resolved against the tenant's catalog. Deny-lists are a second
opinion, never the mechanism.
3 grants — a per-tenant ClickHouse user with SELECT on that tenant's catalog
alone, plus a restrictive row policy on the base tables.
4 views — the tenant catalog holds only VIEWS, each baking
`WHERE tenant_id = '<tenant>'`, and tenant_id is not projected.
61 bypass attempts, in one shared corpus, run TWICE: against the guard, and
against a live ClickHouse 24.8.14.39 with LAYER 2 REMOVED. The live assertion
is not "the engine errored" — it is "no canary came back", with BETA_CANARY
seeded into the other tenant's rows and SECRET_CANARY into an ungranted table.
The counterfactual runs first: as admin the same base table returns 100 rows
and BETA_CANARY IS readable, so the isolation numbers mean something.
Result: 0 of 61 leaked. Evidence: evidence/bi-p017/analyst-sql-live-bypass.json.
FOUR DEFECTS FOUND BY WRITING TESTS THAT CAN FAIL — three of them mine:
* `CREATE ROW POLICY IF NOT EXISTS` is a SILENT NO-OP on re-provisioning.
Dropping the user empties the policy's apply-to list; the next run skips
re-creation, so the policy survives with `apply_to_list = []`, applying to
nobody, while `system.row_policies` still shows the correct select_filter.
The restricted user read 100 of 100 rows — BOTH TENANTS — from the base
table. Fixed with OR REPLACE + `rowPolicyVerificationSql`, which turns
"the policy is applied" from an assumption into a query, and a test that
asserts applied_to_user = 1 per object.
* `CREATE SETTINGS PROFILE IF NOT EXISTS` has the identical shape: a changed
budget never lands. A stale 60-row ceiling survived re-provisioning and
starved every legitimate statement, with the DDL reporting success
throughout. Also OR REPLACE now.
* `describe.skipIf(!reachable)` where `reachable` is set in `beforeAll`
skips ALWAYS — vitest evaluates skipIf at collection time. Ten tests
reported green while skipping against a live, reachable server. Replaced
with the repo's existing env-var gate, which FAILS when CLICKHOUSE_URL is
set and the server is down rather than degrading to a skip.
* MEASURED, and it changes the design: a ClickHouse user whose profile
carries `readonly = 1` cannot have ANY setting changed from the request
(Code: 164). The BI-P012 ceilings therefore cannot travel with an analyst
statement; they are baked into the user's SETTINGS PROFILE at provisioning.
Proven both ways: over-budget scan fails with code 158 from the profile
with no setting sent, and an attempt to raise it from the request fails
with code 164.
Honest about what the guard does NOT catch: `SELECT tenant_id FROM
t_alpha_sql.sales` is structurally legitimate and IS admitted. It fails at the
engine because the view does not project the column. Recorded in its own
GUARD_ADMITS_ENGINE_REFUSES list and asserted live, rather than deleted from
the corpus.
evidence/bi-p017 is pinned by tests/analytics/analyst-sql-evidence.test.ts,
which never skips: adding an attack without re-running the live suite breaks
the build, and the pin also checks the counterfactual numbers so the evidence
cannot be a recording of an empty database.
246 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
/**
|
|
* BI-P002 DoD: "Database migrations + persistence + tenant isolation +
|
|
* restore tests."
|
|
*
|
|
* REAL Postgres, same honest-degrade convention as
|
|
* tests/p0/source-registry-postgres.realinfra.test.ts: gated on
|
|
* ANALYTICS_TEST_DATABASE_URL, SKIPS HONESTLY when unset, never fabricates.
|
|
*
|
|
* docker run -d --name opt-bi-pg -p 15455:5432 \
|
|
* -e POSTGRES_USER=opt -e POSTGRES_PASSWORD=opt_bi_throwaway \
|
|
* -e POSTGRES_DB=opt_bi_test postgres:16-alpine
|
|
* ANALYTICS_TEST_DATABASE_URL=postgres://opt:opt_bi_throwaway@localhost:15455/opt_bi_test \
|
|
* pnpm vitest run tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
|
*
|
|
* The RESTORE half is the part that catches migration-chain rot: it replays
|
|
* sql/001..008 in order into a FRESH database, exactly as
|
|
* docs/runbook/backup-restore.md describes, and only then runs the
|
|
* persistence assertions. sql/003_datasets.sql exists because that same drill
|
|
* found a table no file ever created; this suite keeps that class of defect
|
|
* from coming back through sql/008.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import postgres from 'postgres';
|
|
import {
|
|
ANALYTICS_DOMAIN_ENTITIES,
|
|
ANALYTICS_ENTITY_TABLES,
|
|
IMMUTABLE_VERSION_ENTITIES,
|
|
} from '@/src/analytics/domain/entities';
|
|
import { AnalyticsDomainRepository } from '@/src/analytics/repositories/analytics-domain-repository';
|
|
import { contractHash, type DatasetContract } from '@/src/analytics/domain/contract';
|
|
|
|
const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL;
|
|
const run = TEST_DATABASE_URL ? describe : describe.skip;
|
|
|
|
/** A schema of its own so the drill starts from genuinely nothing. */
|
|
const SCHEMA = `bi_p002_${Date.now().toString(36)}`;
|
|
|
|
let sql: ReturnType<typeof postgres> | undefined;
|
|
let reachable = false;
|
|
let unreachableReason = '';
|
|
/** Which sql/*.sql files the restore drill actually applied. */
|
|
const applied: string[] = [];
|
|
|
|
const ALPHA = { tenant_id: 't_alpha', project_id: 'p_main' };
|
|
const BETA = { tenant_id: 't_beta', project_id: 'p_main' };
|
|
|
|
function repo(): AnalyticsDomainRepository {
|
|
return new AnalyticsDomainRepository(sql!);
|
|
}
|
|
|
|
function contract(datasetId: string): DatasetContract {
|
|
return {
|
|
dataset_id: datasetId,
|
|
version: '1.0.0',
|
|
tier: 'CURATED',
|
|
fields: [{ name: 'id', type: 'string', nullable: false, role: 'identifier', primary_identifier: true }],
|
|
primary_identifiers: ['id'],
|
|
dimensions: [],
|
|
measures: [],
|
|
freshness_slo_seconds: 3600,
|
|
retention_days: 365,
|
|
pii_classification: 'none',
|
|
source_lineage: ['src_x'],
|
|
quality_rules: [],
|
|
allowed_purposes: ['analytics'],
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
if (!TEST_DATABASE_URL) {
|
|
console.warn(
|
|
'[realinfra] ANALYTICS_TEST_DATABASE_URL not set — skipping honestly, not fabricating success.',
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
sql = postgres(TEST_DATABASE_URL, { prepare: false, ssl: false, max: 3 });
|
|
await Promise.race([
|
|
sql`SELECT 1`,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('connect timeout')), 5000)),
|
|
]);
|
|
// RESTORE DRILL: a brand-new schema, then every migration in order.
|
|
await sql.unsafe(`CREATE SCHEMA ${SCHEMA}`);
|
|
await sql.unsafe(`SET search_path TO ${SCHEMA}, public`);
|
|
const dir = path.join(process.cwd(), 'sql');
|
|
for (const file of fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith('.sql'))
|
|
.sort()) {
|
|
const body = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
try {
|
|
await sql.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`);
|
|
applied.push(file);
|
|
} catch (err) {
|
|
// 001 needs the `vector` extension, which a stock postgres:16-alpine
|
|
// does not carry. Record the skip rather than hiding it — the files
|
|
// sql/008 actually depends on must still apply.
|
|
applied.push(`${file} (FAILED: ${String(err).slice(0, 120)})`);
|
|
}
|
|
}
|
|
reachable = true;
|
|
} catch (err) {
|
|
reachable = false;
|
|
unreachableReason = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (sql && reachable) {
|
|
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
|
|
}
|
|
await sql?.end({ timeout: 5 });
|
|
});
|
|
|
|
run('BI-P002 analytics domain model against a LIVE Postgres', () => {
|
|
it('restore drill: sql/008 applies cleanly on top of the replayed chain', (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
expect(unreachableReason).toBe('');
|
|
const p008 = applied.find((f) => f.startsWith('008_'));
|
|
expect(p008, `sql/008 was not applied. Applied: ${applied.join(', ')}`).toBe(
|
|
'008_analytics_domain_model.sql',
|
|
);
|
|
});
|
|
|
|
it('all nineteen entity tables really exist after the restore', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const rows = await sql!<{ table_name: string }[]>`
|
|
SELECT table_name FROM information_schema.tables WHERE table_schema = ${SCHEMA}`;
|
|
const present = new Set(rows.map((r) => r.table_name));
|
|
const missing = ANALYTICS_DOMAIN_ENTITIES.filter((e) => !present.has(ANALYTICS_ENTITY_TABLES[e]));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('replaying sql/008 a second time is a no-op, not an error', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const body = fs.readFileSync(path.join(process.cwd(), 'sql', '008_analytics_domain_model.sql'), 'utf8');
|
|
await expect(sql!.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('persists a dataset and reads it back through the repository', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
const created = await r.createDataset(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
name: 'Alpha listings',
|
|
tier: 'CURATED',
|
|
schema_contract_id: 'dc_alpha',
|
|
schema_contract_version: '1.0.0',
|
|
owner_id: 'team_data',
|
|
source_ref: 'src_feed',
|
|
freshness_slo_seconds: 3600,
|
|
quality_rule_ids: [],
|
|
retention_days: 365,
|
|
classification: 'none',
|
|
status: 'active',
|
|
created_by: 'usr_1',
|
|
});
|
|
expect(created.tenant_id).toBe('t_alpha');
|
|
const read = await r.getDataset(ALPHA, 'ds_alpha_1');
|
|
expect(read?.name).toBe('Alpha listings');
|
|
expect(read?.freshness_slo_seconds).toBe(3600);
|
|
// Cross-check with direct SQL — proving the row is really in the table,
|
|
// not an artifact of the repository's own bookkeeping.
|
|
const direct = await sql!<{ c: string }[]>`
|
|
SELECT count(*)::text AS c FROM opt_analytics_dataset WHERE dataset_id = 'ds_alpha_1'`;
|
|
expect(Number(direct[0].c)).toBe(1);
|
|
});
|
|
|
|
it("TENANT ISOLATION: t_beta cannot read t_alpha's dataset, even with the exact id", async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
expect(await r.getDataset(BETA, 'ds_alpha_1')).toBeUndefined();
|
|
expect(await r.listDatasets(BETA)).toEqual([]);
|
|
// And the row IS there — the empty result is scoping, not absence.
|
|
const direct = await sql!<{ c: string }[]>`SELECT count(*)::text AS c FROM opt_analytics_dataset`;
|
|
expect(Number(direct[0].c)).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("TENANT ISOLATION: a version cannot be minted against another tenant's dataset", async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
repo().createDatasetVersion(BETA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: 'deadbeef',
|
|
schema_contract_version: '1.0.0',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
});
|
|
|
|
it('dataset versions are monotonic and carry the contract hash', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
const c = contract('ds_alpha_1');
|
|
const v1 = await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: contractHash(c),
|
|
schema_contract_version: '1.0.0',
|
|
});
|
|
const v2 = await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: contractHash({ ...c, version: '2.0.0' }),
|
|
schema_contract_version: '2.0.0',
|
|
});
|
|
expect(v1.version).toBe(1);
|
|
expect(v2.version).toBe(2);
|
|
expect(v2.contract_hash).not.toBe(v1.contract_hash);
|
|
expect((await r.listDatasetVersions(ALPHA, 'ds_alpha_1')).map((v) => v.version)).toEqual([1, 2]);
|
|
});
|
|
|
|
it('IMMUTABILITY is enforced by the DATABASE: UPDATE and DELETE on a version row are refused', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
// Direct SQL, bypassing every TypeScript `readonly` — the only test that
|
|
// proves the guarantee survives a psql session.
|
|
await expect(
|
|
sql!.unsafe(
|
|
`UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash = 'tampered' WHERE dataset_id = 'ds_alpha_1'`,
|
|
),
|
|
).rejects.toThrow(/immutable/i);
|
|
await expect(
|
|
sql!.unsafe(`DELETE FROM ${SCHEMA}.opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`),
|
|
).rejects.toThrow(/immutable/i);
|
|
// The rows are untouched.
|
|
const rows = await sql!<{ contract_hash: string }[]>`
|
|
SELECT contract_hash FROM opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`;
|
|
expect(rows).toHaveLength(2);
|
|
for (const r of rows) expect(r.contract_hash).not.toBe('tampered');
|
|
});
|
|
|
|
it('all four immutable entities refuse an UPDATE, not just the one that was tested first', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await r.createDataset(ALPHA, {
|
|
dataset_id: 'ds_imm',
|
|
name: 'Immutability fixture',
|
|
tier: 'SERVING',
|
|
schema_contract_id: 'dc_i',
|
|
schema_contract_version: '1.0.0',
|
|
owner_id: 'team_data',
|
|
source_ref: 'src',
|
|
freshness_slo_seconds: 60,
|
|
quality_rule_ids: [],
|
|
retention_days: 30,
|
|
classification: 'none',
|
|
status: 'active',
|
|
});
|
|
const metric = await r.createMetric(ALPHA, {
|
|
metric_id: 'm_imm',
|
|
name: 'immutable metric',
|
|
dataset_id: 'ds_imm',
|
|
aggregation: 'sum',
|
|
measure_field: 'value',
|
|
allowed_dimension_ids: [],
|
|
owner_id: 'team_data',
|
|
});
|
|
await r.createMetricVersion(ALPHA, { metric_id: metric.metric_id, definition_json: '{"v":1}' });
|
|
await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_imm',
|
|
contract_hash: 'h1',
|
|
schema_contract_version: '1.0.0',
|
|
});
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_semantic_model
|
|
(semantic_model_id, tenant_id, project_id, name, version, model_json, model_hash, owner_id)
|
|
VALUES ('sm_1','t_alpha','p_main','model_a',1,'{}','h','team_data')`);
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_definition
|
|
(dashboard_id, tenant_id, project_id, name, owner_id) VALUES ('db_1','t_alpha','p_main','dash','team_data')`);
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_version
|
|
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
|
VALUES ('dv_1','db_1','t_alpha','p_main',1,'{}','h')`);
|
|
|
|
const updates: Record<string, string> = {
|
|
DatasetVersion: `UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash='x' WHERE dataset_id='ds_imm'`,
|
|
MetricVersion: `UPDATE ${SCHEMA}.opt_analytics_metric_version SET definition_json='{}' WHERE metric_id='m_imm'`,
|
|
SemanticModel: `UPDATE ${SCHEMA}.opt_analytics_semantic_model SET model_json='{}' WHERE semantic_model_id='sm_1'`,
|
|
DashboardVersion: `UPDATE ${SCHEMA}.opt_analytics_dashboard_version SET layout_json='{}' WHERE dashboard_version_id='dv_1'`,
|
|
};
|
|
for (const entity of IMMUTABLE_VERSION_ENTITIES) {
|
|
await expect(sql!.unsafe(updates[entity]), `${entity} is NOT immutable`).rejects.toThrow(/immutable/i);
|
|
}
|
|
});
|
|
|
|
it('a metric version after the first must state WHY the number changed meaning', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await expect(
|
|
r.createMetricVersion(ALPHA, { metric_id: 'm_imm', definition_json: '{"v":2}' }),
|
|
).rejects.toThrow();
|
|
const v2 = await r.createMetricVersion(ALPHA, {
|
|
metric_id: 'm_imm',
|
|
definition_json: '{"v":2}',
|
|
change_reason: 'excluded internal test accounts from the numerator',
|
|
});
|
|
expect(v2.version).toBe(2);
|
|
});
|
|
|
|
it('a DatasetGrant without a purpose is refused by the DATABASE (purpose limitation)', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
// Regression pin: the first version of this constraint was written
|
|
// `array_length(allowed_purposes, 1) >= 1`, which is NULL for '{}' — and a
|
|
// CHECK evaluating to NULL PASSES in Postgres. It could never fail. This
|
|
// test found that; keep it.
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
|
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
|
VALUES ('g_1','ds_imm','t_alpha','p_main','usr_1','user','read', '{}')`),
|
|
).rejects.toThrow(/purposes_nonempty/);
|
|
// A grant WITH a purpose is accepted, so the constraint is not simply
|
|
// rejecting everything.
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
|
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
|
VALUES ('g_ok','ds_imm','t_alpha','p_main','usr_1','user','read', ARRAY['market_analytics'])`),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it('a BIApplicationBinding without a purpose is refused too, and carries no credential column', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_bi_application_binding
|
|
(binding_id, tenant_id, project_id, kind, name, principal_id, allowed_purposes)
|
|
VALUES ('b_1','t_alpha','p_main','superset','sup','svc_1','{}')`),
|
|
).rejects.toThrow(/purposes_nonempty/);
|
|
|
|
const cols = await sql!<{ column_name: string }[]>`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = ${SCHEMA} AND table_name = 'opt_analytics_bi_application_binding'`;
|
|
const names = cols.map((c) => c.column_name.toLowerCase());
|
|
for (const forbidden of ['password', 'secret', 'dsn', 'token', 'connection_string', 'clickhouse_url']) {
|
|
expect(names, `BI binding must never store ${forbidden}`).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it('the dimension catalog is tenant-scoped and FAILS CLOSED for a tenant with none', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await r.createDimension(ALPHA, {
|
|
dimension_id: 'dim_1',
|
|
name: 'make',
|
|
dataset_id: 'ds_imm',
|
|
field_name: 'vehicle_make',
|
|
field_type: 'string',
|
|
classification: 'none',
|
|
});
|
|
expect(await r.dimensionCatalog(ALPHA)).toEqual({ make: 'vehicle_make' });
|
|
// t_beta declared nothing, so it may reference nothing.
|
|
expect(await r.dimensionCatalog(BETA)).toEqual({});
|
|
});
|
|
|
|
it('a dimension whose physical field is not identifier-safe is refused by the DATABASE', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dimension_definition
|
|
(dimension_id, tenant_id, project_id, name, dataset_id, field_name, field_type)
|
|
VALUES ('dim_bad','t_alpha','p_main','evil','ds_imm', 'x''; DROP TABLE opt_analytics_dataset; --','string')`),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('the repository refuses to act without a verified tenant scope', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await expect(r.getDataset({ tenant_id: '', project_id: 'p' }, 'ds_imm')).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
await expect(r.listDatasets({ tenant_id: 't_alpha', project_id: ' ' })).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
});
|
|
});
|