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>
297 lines
9.0 KiB
TypeScript
297 lines
9.0 KiB
TypeScript
/**
|
|
* BI-P004 — ANALYTICS DATA CONTRACT.
|
|
*
|
|
* DoD: "Breaking dataset schema changes fail compatibility gate unless
|
|
* explicitly versioned." Each breaking change class below is tested TWICE:
|
|
* once under a minor bump (must be REFUSED) and once under a major bump (must
|
|
* be ACCEPTED). A gate that only ever says yes, or only ever says no, would
|
|
* pass a one-sided test; this one cannot.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
assertContractCompatible,
|
|
checkContractCompatibility,
|
|
contractHash,
|
|
diffContracts,
|
|
parseSemVer,
|
|
validateContract,
|
|
type DatasetContract,
|
|
} from '@/src/analytics/domain/contract';
|
|
|
|
function base(): DatasetContract {
|
|
return {
|
|
dataset_id: 'ds_vehicle_listings',
|
|
version: '1.2.0',
|
|
tier: 'CURATED',
|
|
fields: [
|
|
{ name: 'listing_id', type: 'string', nullable: false, role: 'identifier', primary_identifier: true },
|
|
{ name: 'make', type: 'string', nullable: false, role: 'dimension' },
|
|
{ name: 'asking_price', type: 'int64', nullable: true, role: 'measure' },
|
|
{ name: 'listed_at', type: 'timestamp', nullable: false, role: 'attribute' },
|
|
],
|
|
primary_identifiers: ['listing_id'],
|
|
dimensions: ['make'],
|
|
measures: ['asking_price'],
|
|
freshness_slo_seconds: 3600,
|
|
retention_days: 730,
|
|
pii_classification: 'none',
|
|
source_lineage: ['src_dealer_feed'],
|
|
quality_rules: [
|
|
{ rule_id: 'qr_listing_id_unique', kind: 'unique', field_name: 'listing_id', severity: 'block' },
|
|
],
|
|
allowed_purposes: ['market_analytics'],
|
|
};
|
|
}
|
|
|
|
/** Apply a mutation and set the version. */
|
|
function next(mutate: (c: DatasetContract) => void, version: string): DatasetContract {
|
|
const c: DatasetContract = JSON.parse(JSON.stringify(base()));
|
|
mutate(c);
|
|
c.version = version;
|
|
return c;
|
|
}
|
|
|
|
const BREAKING_CASES: { name: string; mutate: (c: DatasetContract) => void; code: string }[] = [
|
|
{
|
|
name: 'field removed',
|
|
mutate: (c) => {
|
|
c.fields = c.fields.filter((f) => f.name !== 'make');
|
|
c.dimensions = [];
|
|
},
|
|
code: 'field_removed',
|
|
},
|
|
{
|
|
name: 'field type changed',
|
|
mutate: (c) => {
|
|
c.fields[2].type = 'float64';
|
|
},
|
|
code: 'field_type_changed',
|
|
},
|
|
{
|
|
name: 'field became nullable',
|
|
mutate: (c) => {
|
|
c.fields[1].nullable = true;
|
|
},
|
|
code: 'field_became_nullable',
|
|
},
|
|
{
|
|
name: 'field role changed',
|
|
mutate: (c) => {
|
|
c.fields[1].role = 'measure';
|
|
},
|
|
code: 'field_role_changed',
|
|
},
|
|
{
|
|
name: 'required field added',
|
|
mutate: (c) => {
|
|
c.fields.push({ name: 'vat_rate', type: 'float64', nullable: false, role: 'attribute' });
|
|
},
|
|
code: 'required_field_added',
|
|
},
|
|
{
|
|
name: 'primary identifier changed',
|
|
mutate: (c) => {
|
|
c.primary_identifiers = ['make'];
|
|
},
|
|
code: 'primary_identifier_changed',
|
|
},
|
|
{
|
|
name: 'dimension removed',
|
|
mutate: (c) => {
|
|
c.dimensions = [];
|
|
},
|
|
code: 'dimension_removed',
|
|
},
|
|
{
|
|
name: 'measure removed',
|
|
mutate: (c) => {
|
|
c.measures = [];
|
|
},
|
|
code: 'measure_removed',
|
|
},
|
|
{
|
|
name: 'freshness SLO loosened',
|
|
mutate: (c) => {
|
|
c.freshness_slo_seconds = 86400;
|
|
},
|
|
code: 'freshness_slo_loosened',
|
|
},
|
|
{
|
|
name: 'retention shortened',
|
|
mutate: (c) => {
|
|
c.retention_days = 30;
|
|
},
|
|
code: 'retention_shortened',
|
|
},
|
|
{
|
|
name: 'allowed purpose removed',
|
|
mutate: (c) => {
|
|
c.allowed_purposes = [];
|
|
},
|
|
code: 'purpose_removed',
|
|
},
|
|
{
|
|
name: 'PII classification raised',
|
|
mutate: (c) => {
|
|
c.pii_classification = 'personal';
|
|
},
|
|
code: 'pii_classification_raised',
|
|
},
|
|
{
|
|
name: 'blocking quality rule removed',
|
|
mutate: (c) => {
|
|
c.quality_rules = [];
|
|
},
|
|
code: 'blocking_quality_rule_removed',
|
|
},
|
|
{
|
|
name: 'blocking quality rule downgraded',
|
|
mutate: (c) => {
|
|
c.quality_rules[0].severity = 'warn';
|
|
},
|
|
code: 'blocking_quality_rule_downgraded',
|
|
},
|
|
{
|
|
name: 'tier changed',
|
|
mutate: (c) => {
|
|
c.tier = 'SERVING';
|
|
},
|
|
code: 'tier_changed',
|
|
},
|
|
];
|
|
|
|
describe('BI-P004 compatibility gate', () => {
|
|
it.each(BREAKING_CASES)('REFUSES $name under a minor bump', ({ mutate, code }) => {
|
|
const verdict = checkContractCompatibility(base(), next(mutate, '1.3.0'));
|
|
expect(verdict.compatible).toBe(false);
|
|
expect(verdict.breaking.map((b) => b.code)).toContain(code);
|
|
expect(verdict.reason).toMatch(/MAJOR version bump/);
|
|
});
|
|
|
|
it.each(BREAKING_CASES)('ACCEPTS $name once explicitly versioned (major bump)', ({ mutate, code }) => {
|
|
const verdict = checkContractCompatibility(base(), next(mutate, '2.0.0'));
|
|
expect(verdict.compatible).toBe(true);
|
|
expect(verdict.explicitly_versioned).toBe(true);
|
|
expect(verdict.breaking.map((b) => b.code)).toContain(code);
|
|
});
|
|
|
|
it('a patch bump is equally refused — the gate is about the MAJOR, not about moving at all', () => {
|
|
const verdict = checkContractCompatibility(
|
|
base(),
|
|
next((c) => c.fields.pop(), '1.2.1'),
|
|
);
|
|
expect(verdict.compatible).toBe(false);
|
|
});
|
|
|
|
it('allows genuinely additive changes without a major bump', () => {
|
|
const additive = next((c) => {
|
|
c.fields.push({ name: 'mileage_km', type: 'int64', nullable: true, role: 'measure' });
|
|
c.measures.push('mileage_km');
|
|
c.quality_rules.push({
|
|
rule_id: 'qr_mileage_range',
|
|
kind: 'range',
|
|
field_name: 'mileage_km',
|
|
severity: 'warn',
|
|
});
|
|
c.allowed_purposes.push('valuation');
|
|
c.retention_days = 1095;
|
|
c.freshness_slo_seconds = 1800;
|
|
}, '1.3.0');
|
|
const verdict = checkContractCompatibility(base(), additive);
|
|
expect(verdict.compatible).toBe(true);
|
|
expect(verdict.breaking).toEqual([]);
|
|
expect(verdict.explicitly_versioned).toBe(false);
|
|
});
|
|
|
|
it('refuses a version that does not move forward', () => {
|
|
expect(
|
|
checkContractCompatibility(
|
|
base(),
|
|
next(() => {}, '1.2.0'),
|
|
).compatible,
|
|
).toBe(false);
|
|
expect(
|
|
checkContractCompatibility(
|
|
base(),
|
|
next(() => {}, '1.1.9'),
|
|
).compatible,
|
|
).toBe(false);
|
|
expect(
|
|
checkContractCompatibility(
|
|
base(),
|
|
next(() => {}, '0.9.0'),
|
|
).compatible,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('treats date -> timestamp as a safe widening, and int64 -> float64 as NOT one', () => {
|
|
const widened = next((c) => {
|
|
c.fields.push({ name: 'sold_on', type: 'date', nullable: true, role: 'attribute' });
|
|
}, '1.3.0');
|
|
const later = JSON.parse(JSON.stringify(widened)) as DatasetContract;
|
|
later.fields[later.fields.length - 1].type = 'timestamp';
|
|
later.version = '1.4.0';
|
|
expect(checkContractCompatibility(widened, later).compatible).toBe(true);
|
|
|
|
const lossy = next((c) => {
|
|
c.fields[2].type = 'float64';
|
|
}, '1.3.0');
|
|
expect(checkContractCompatibility(base(), lossy).compatible).toBe(false);
|
|
});
|
|
|
|
it('assertContractCompatible throws a CONFLICT rather than returning a verdict nobody reads', () => {
|
|
expect(() =>
|
|
assertContractCompatible(
|
|
base(),
|
|
next((c) => c.fields.pop(), '1.3.0'),
|
|
),
|
|
).toThrowError(/MAJOR/);
|
|
expect(() =>
|
|
assertContractCompatible(
|
|
base(),
|
|
next(() => {}, '1.3.0'),
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('refuses to compare contracts for different datasets', () => {
|
|
const other = next((c) => {
|
|
c.dataset_id = 'ds_other';
|
|
}, '1.3.0');
|
|
expect(() => diffContracts(base(), other)).toThrowError(/SAME dataset/);
|
|
});
|
|
|
|
it('validateContract catches internally inconsistent contracts', () => {
|
|
expect(() => validateContract(base())).not.toThrow();
|
|
expect(() => validateContract({ ...base(), version: '1.2' })).toThrowError(/major.minor.patch/);
|
|
expect(() => validateContract({ ...base(), fields: [] })).toThrowError(/no fields/);
|
|
expect(() => validateContract({ ...base(), dimensions: ['nope'] })).toThrowError(/unknown field/);
|
|
expect(() => validateContract({ ...base(), primary_identifiers: [] })).toThrowError(
|
|
/no primary identifier/,
|
|
);
|
|
expect(() => validateContract({ ...base(), allowed_purposes: [] })).toThrowError(/allowed_purposes/);
|
|
expect(() => validateContract({ ...base(), freshness_slo_seconds: 0 })).toThrowError(/freshness/);
|
|
const dup = base();
|
|
dup.fields.push({ ...dup.fields[0] });
|
|
expect(() => validateContract(dup)).toThrowError(/duplicate field/);
|
|
});
|
|
|
|
it('contractHash is order-insensitive over fields but sensitive to meaning', () => {
|
|
const a = base();
|
|
const reordered = JSON.parse(JSON.stringify(a)) as DatasetContract;
|
|
reordered.fields.reverse();
|
|
expect(contractHash(reordered)).toBe(contractHash(a));
|
|
|
|
const changed = JSON.parse(JSON.stringify(a)) as DatasetContract;
|
|
changed.fields[1].nullable = true;
|
|
expect(contractHash(changed)).not.toBe(contractHash(a));
|
|
});
|
|
|
|
it('parseSemVer rejects anything that is not major.minor.patch', () => {
|
|
expect(parseSemVer('2.10.3')).toEqual({ major: 2, minor: 10, patch: 3 });
|
|
for (const bad of ['v1.0.0', '1.0', '1.0.0-rc1', 'latest', '']) {
|
|
expect(() => parseSemVer(bad)).toThrowError(/major.minor.patch/);
|
|
}
|
|
});
|
|
});
|