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>
434 lines
19 KiB
TypeScript
434 lines
19 KiB
TypeScript
/**
|
|
* tests/integration/analyst-sql-clickhouse.integration.test.ts
|
|
* =============================================================
|
|
* BI-P017 — the proof that matters: the bypass corpus run against a LIVE
|
|
* ClickHouse **with the statement guard removed**.
|
|
*
|
|
* The guard (layer 2) is a text analysis of a dialect it does not implement.
|
|
* Testing only the guard proves the guard. What has to hold is that when the
|
|
* guard is WRONG, nothing leaks — so this file skips the guard entirely and
|
|
* sends each attack straight to the engine as the tenant's restricted user.
|
|
* Layer 3 (grants + `readonly = 1`) and layer 4 (views that bake the tenant
|
|
* predicate, over base tables carrying a row policy) must refuse it anyway.
|
|
*
|
|
* The assertion is deliberately NOT "the engine returned an error". It is
|
|
* "no foreign row came back", checked by canary values seeded into the other
|
|
* tenant's rows and into an ungranted table. An attack that succeeds and
|
|
* returns nothing interesting still fails the test if it returns a canary.
|
|
*
|
|
* Counterfactual, run first, because a test that would pass against an empty
|
|
* database proves nothing: as the ADMIN user the base table holds both
|
|
* tenants' rows, and `BETA_CANARY` IS readable. Every later assertion is
|
|
* therefore about isolation, not about absence of data.
|
|
*
|
|
* ── HOW THIS AVOIDS THE SILENT-SKIP TRAP ──────────────────────────────────
|
|
* l0re's 28 Postgres tests skipped silently for months and nobody noticed.
|
|
* This suite self-skips when ClickHouse is unreachable — but on a live run it
|
|
* WRITES `evidence/bi-p017/analyst-sql-live-bypass.json`, and
|
|
* tests/analytics/analyst-sql-evidence.test.ts (which never skips) fails if
|
|
* that file is missing, stale, or does not cover every attempt in the corpus.
|
|
* Adding an attack without re-running this suite breaks the build.
|
|
*
|
|
* Point it at a disposable instance:
|
|
* docker run -d --name ch -p 28123:8123 -e CLICKHOUSE_SKIP_USER_SETUP=1 \
|
|
* clickhouse/clickhouse-server:24.8
|
|
* ANALYTICS_CLICKHOUSE_URL=http://127.0.0.1:28123 pnpm vitest run \
|
|
* tests/integration/analyst-sql-clickhouse.integration.test.ts
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import {
|
|
BYPASS_CORPUS,
|
|
GUARD_ADMITS_ENGINE_REFUSES,
|
|
LEGITIMATE_CORPUS,
|
|
} from '../analytics/analyst-sql-bypass-corpus';
|
|
import { guardAnalystStatement } from '@/src/analytics/sql/statement-guard';
|
|
import {
|
|
rowPolicyVerificationSql,
|
|
tenantSqlCatalogDdl,
|
|
tenantSqlUserDdl,
|
|
} from '@/src/analytics/sql/tenant-catalog';
|
|
import { applyQuota, DEFAULT_BUDGETS } from '@/src/analytics/engine/resource-governance';
|
|
|
|
/**
|
|
* Gated on the ENV VAR, matching the convention already in
|
|
* tests/integration/analytics-tenant-isolation.integration.test.ts — and
|
|
* chosen over a liveness probe on purpose: with the variable set, a
|
|
* ClickHouse that is down makes this suite FAIL. A probe would have turned a
|
|
* broken server into a silent green, which is the failure mode this estate
|
|
* keeps shipping.
|
|
*/
|
|
const BASE_URL = process.env.CLICKHOUSE_URL ?? '';
|
|
const ADMIN_AUTH = process.env.CLICKHOUSE_AUTH ?? 'default:';
|
|
const DB = 'opt_analytics';
|
|
const ALPHA_PW = 'alpha_test_pw_not_a_secret';
|
|
const BETA_PW = 'beta_test_pw_not_a_secret';
|
|
const EVIDENCE_PATH = path.join(process.cwd(), 'evidence/bi-p017/analyst-sql-live-bypass.json');
|
|
|
|
/** Canaries. Their presence in a response body is the definition of a leak. */
|
|
const BETA_CANARY = 'BETA_CANARY_9f21c4';
|
|
const SECRET_CANARY = 'SECRET_CANARY_4d80ab';
|
|
const ALPHA_MARK = 'ALPHA_ROW';
|
|
|
|
interface ChResponse {
|
|
ok: boolean;
|
|
status: number;
|
|
body: string;
|
|
}
|
|
|
|
async function run(sql: string, auth: string, settings: Record<string, string> = {}): Promise<ChResponse> {
|
|
const url = new URL(BASE_URL);
|
|
url.searchParams.set('query', sql);
|
|
for (const [k, v] of Object.entries(settings)) url.searchParams.set(k, v);
|
|
const res = await fetch(url.toString(), {
|
|
method: 'POST',
|
|
headers: { Authorization: `Basic ${Buffer.from(auth).toString('base64')}` },
|
|
body: '',
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
return { ok: res.ok, status: res.status, body: await res.text() };
|
|
}
|
|
|
|
const admin = (sql: string) => run(sql, ADMIN_AUTH);
|
|
/**
|
|
* The session's real posture: the restricted user, and NO settings on the
|
|
* request.
|
|
*
|
|
* MEASURED: a user whose profile carries `readonly = 1` cannot have any
|
|
* setting changed from the request — ClickHouse answers `Code: 164. Cannot
|
|
* modify 'max_rows_to_read' setting in readonly mode.` So the BI-P012 ceilings
|
|
* cannot travel with the statement here the way they do for the non-readonly
|
|
* engine user; they are baked into the SETTINGS PROFILE by
|
|
* `tenantSqlUserDdl`, and the test below proves the profile enforces them.
|
|
*/
|
|
const asAlpha = (sql: string) => run(sql, `t_alpha_sql_user:${ALPHA_PW}`);
|
|
|
|
/**
|
|
* An earlier revision set a `reachable` flag inside `beforeAll` and used
|
|
* `describe.skipIf(!reachable)`. Vitest evaluates `skipIf` at COLLECTION time,
|
|
* before any hook runs, so the flag was always false and all ten tests skipped
|
|
* against a live, reachable ClickHouse — a suite that could never run,
|
|
* reporting green. Same shape as l0re's 28 silently-skipped Postgres tests.
|
|
* Recorded here because the next person will reach for `skipIf` too.
|
|
*/
|
|
const suite = BASE_URL ? describe : describe.skip;
|
|
let version = '';
|
|
|
|
/** The session budget for the main user: generous enough for real analyst work. */
|
|
const TEST_BUDGET = applyQuota(DEFAULT_BUDGETS.ANALYTICAL, {
|
|
maxRowsToRead: 100_000,
|
|
maxExecutionTimeSec: 10,
|
|
});
|
|
|
|
/**
|
|
* A SECOND user with a deliberately tiny `maxRowsToRead` (60), provisioned
|
|
* only to make the profile's enforcement observable: the tenant view holds 50
|
|
* rows, which passes; anything wider exceeds it and must fail with ClickHouse
|
|
* code 158. A ceiling nothing can breach proves nothing, and the first
|
|
* revision of this file proved exactly that by starving the legitimate corpus.
|
|
*/
|
|
const TINY_BUDGET = applyQuota(DEFAULT_BUDGETS.ANALYTICAL, { maxRowsToRead: 60, maxExecutionTimeSec: 10 });
|
|
const TINY_PW = 'tiny_test_pw_not_a_secret';
|
|
|
|
const OBJECTS = [
|
|
{ table: 'sales', columns: ['customer', 'amount', 'event_time'] },
|
|
{ table: 'events', columns: ['customer', 'kind', 'event_time'] },
|
|
];
|
|
|
|
beforeAll(async () => {
|
|
if (!BASE_URL) return;
|
|
// With CLICKHOUSE_URL set, an unreachable server FAILS here. It does not
|
|
// degrade to a skip.
|
|
const v = await admin('SELECT version()');
|
|
if (!v.ok)
|
|
throw new Error(
|
|
`CLICKHOUSE_URL is set but ClickHouse is not answering: ${v.status} ${v.body.slice(0, 200)}`,
|
|
);
|
|
version = v.body.trim();
|
|
|
|
await admin(`DROP DATABASE IF EXISTS ${DB}`);
|
|
await admin(`DROP DATABASE IF EXISTS t_alpha_sql`);
|
|
await admin(`DROP DATABASE IF EXISTS t_beta_sql`);
|
|
await admin(`DROP USER IF EXISTS t_alpha_sql_user`);
|
|
await admin(`DROP USER IF EXISTS t_beta_sql_user`);
|
|
await admin(`DROP USER IF EXISTS t_alpha_tiny_user`);
|
|
await admin(`CREATE DATABASE ${DB}`);
|
|
|
|
await admin(
|
|
`CREATE TABLE ${DB}.sales (tenant_id String, customer String, amount Float64, event_time DateTime) ` +
|
|
`ENGINE = MergeTree ORDER BY (tenant_id, event_time)`,
|
|
);
|
|
await admin(
|
|
`CREATE TABLE ${DB}.events (tenant_id String, customer String, kind String, event_time DateTime) ` +
|
|
`ENGINE = MergeTree ORDER BY (tenant_id, event_time)`,
|
|
);
|
|
await admin(
|
|
`CREATE TABLE ${DB}.secret_ledger (tenant_id String, note String) ENGINE = MergeTree ORDER BY tenant_id`,
|
|
);
|
|
// Also present, because several attacks name it: the BI-P010 event table.
|
|
await admin(
|
|
`CREATE TABLE ${DB}.opt_analytics_event (tenant_id String, project_id String, dataset_id String, ` +
|
|
`event_id String, metric String, value Float64, event_time DateTime) ENGINE = MergeTree ` +
|
|
`ORDER BY (tenant_id, event_time)`,
|
|
);
|
|
|
|
await admin(
|
|
`INSERT INTO ${DB}.sales SELECT 't_alpha', '${ALPHA_MARK}_' || toString(number), number, now() FROM numbers(50)`,
|
|
);
|
|
await admin(
|
|
`INSERT INTO ${DB}.sales SELECT 't_beta', '${BETA_CANARY}_' || toString(number), number, now() FROM numbers(50)`,
|
|
);
|
|
await admin(
|
|
`INSERT INTO ${DB}.events SELECT 't_alpha', '${ALPHA_MARK}_' || toString(number), 'click', now() FROM numbers(20)`,
|
|
);
|
|
await admin(
|
|
`INSERT INTO ${DB}.events SELECT 't_beta', '${BETA_CANARY}_' || toString(number), 'click', now() FROM numbers(20)`,
|
|
);
|
|
await admin(`INSERT INTO ${DB}.secret_ledger VALUES ('t_alpha', '${SECRET_CANARY}')`);
|
|
await admin(
|
|
`INSERT INTO ${DB}.opt_analytics_event SELECT 't_beta', 'p', 'd', toString(number), 'm', number, now() FROM numbers(10)`,
|
|
);
|
|
|
|
for (const [tenant, catalog, user, pw] of [
|
|
['t_alpha', 't_alpha_sql', 't_alpha_sql_user', ALPHA_PW],
|
|
['t_beta', 't_beta_sql', 't_beta_sql_user', BETA_PW],
|
|
] as const) {
|
|
const spec = { sourceDatabase: DB, tenantId: tenant, catalog, objects: OBJECTS };
|
|
for (const stmt of tenantSqlCatalogDdl(spec)) {
|
|
const r = await admin(stmt);
|
|
if (!r.ok) throw new Error(`catalog DDL failed: ${stmt} -> ${r.body.slice(0, 200)}`);
|
|
}
|
|
for (const stmt of tenantSqlUserDdl({ ...spec, userName: user, password: pw, budget: TEST_BUDGET })) {
|
|
const r = await admin(stmt);
|
|
if (!r.ok) throw new Error(`user DDL failed: ${stmt} -> ${r.body.slice(0, 200)}`);
|
|
}
|
|
}
|
|
|
|
const tinySpec = { sourceDatabase: DB, tenantId: 't_alpha', catalog: 't_alpha_sql', objects: OBJECTS };
|
|
for (const stmt of tenantSqlUserDdl({
|
|
...tinySpec,
|
|
userName: 't_alpha_tiny_user',
|
|
password: TINY_PW,
|
|
budget: TINY_BUDGET,
|
|
})) {
|
|
const r = await admin(stmt);
|
|
if (!r.ok) throw new Error(`tiny user DDL failed: ${stmt} -> ${r.body.slice(0, 200)}`);
|
|
}
|
|
}, 120_000);
|
|
|
|
suite('BI-P017 live — counterfactual: the data IS there and IS reachable without the layers', () => {
|
|
it('the shared base table holds both tenants, and BETA_CANARY is readable as admin', async () => {
|
|
const all = await admin(`SELECT count() FROM ${DB}.sales`);
|
|
expect(all.ok).toBe(true);
|
|
expect(Number(all.body.trim())).toBe(100);
|
|
const beta = await admin(`SELECT customer FROM ${DB}.sales WHERE tenant_id = 't_beta' LIMIT 1`);
|
|
expect(beta.body).toContain(BETA_CANARY);
|
|
const secret = await admin(`SELECT note FROM ${DB}.secret_ledger`);
|
|
expect(secret.body).toContain(SECRET_CANARY);
|
|
});
|
|
|
|
it('layer 4: the tenant view returns 50 of the 100 rows, and never the canary', async () => {
|
|
const scoped = await asAlpha(`SELECT count() FROM t_alpha_sql.sales`);
|
|
expect(scoped.ok).toBe(true);
|
|
expect(Number(scoped.body.trim())).toBe(50);
|
|
const rows = await asAlpha(`SELECT customer FROM t_alpha_sql.sales LIMIT 100`);
|
|
expect(rows.body).toContain(ALPHA_MARK);
|
|
expect(rows.body).not.toContain(BETA_CANARY);
|
|
});
|
|
});
|
|
|
|
suite('BI-P017 live — the bypass corpus WITH THE GUARD REMOVED', () => {
|
|
it('no attempt returns a foreign-tenant or ungranted-table row', async () => {
|
|
const results: Array<Record<string, unknown>> = [];
|
|
const leaks: string[] = [];
|
|
|
|
for (const attempt of [...BYPASS_CORPUS, ...GUARD_ADMITS_ENGINE_REFUSES]) {
|
|
// THE GUARD IS NOT CALLED HERE. That is the whole point.
|
|
const res = await asAlpha(attempt.sql);
|
|
const leakedBeta = res.body.includes(BETA_CANARY);
|
|
const leakedSecret = res.body.includes(SECRET_CANARY);
|
|
const chCode = /Code:\s*(\d+)/.exec(res.body)?.[1];
|
|
// MEASURED, and narrower than the obvious rule. ClickHouse filters
|
|
// `system.tables` / `system.columns` by the caller's own grants: as
|
|
// the restricted user those reads SUCCEED but return only this
|
|
// tenant's four objects — 4 rows, not the server's inventory.
|
|
// `system.query_log`, `system.processes` and `system.users` are
|
|
// ACCESS_DENIED (code 497). So "any successful system read is a leak"
|
|
// would be a false alarm; the real test is whether any FOREIGN name
|
|
// came back.
|
|
const leakedMetadata = res.ok && /\bt_beta\b|secret_ledger/.test(res.body);
|
|
|
|
if (leakedBeta || leakedSecret || leakedMetadata) {
|
|
leaks.push(`${attempt.id}: ${attempt.goal}`);
|
|
}
|
|
// For completeness the guard's own verdict is recorded alongside, so
|
|
// the evidence shows which layer caught what.
|
|
let guardVerdict = 'admitted';
|
|
try {
|
|
guardAnalystStatement(attempt.sql, {
|
|
allowedCatalog: 't_alpha_sql',
|
|
allowedObjects: ['sales', 'events'],
|
|
});
|
|
} catch (e) {
|
|
guardVerdict = `refused: ${(e as Error).message}`;
|
|
}
|
|
results.push({
|
|
id: attempt.id,
|
|
goal: attempt.goal,
|
|
engine_http_status: res.status,
|
|
engine_clickhouse_code: chCode ?? null,
|
|
engine_succeeded: res.ok,
|
|
leaked_foreign_tenant_row: leakedBeta,
|
|
leaked_ungranted_table_row: leakedSecret,
|
|
leaked_server_metadata: leakedMetadata,
|
|
guard_verdict_for_reference: guardVerdict,
|
|
engine_response_head: res.body.slice(0, 160).replace(/\s+/g, ' ').trim(),
|
|
});
|
|
}
|
|
|
|
// Legitimate work must still WORK as the restricted user, or "nothing
|
|
// leaked" would be trivially true because nothing runs.
|
|
const legitimate: Array<Record<string, unknown>> = [];
|
|
for (const l of LEGITIMATE_CORPUS) {
|
|
const res = await asAlpha(l.sql);
|
|
legitimate.push({
|
|
id: l.id,
|
|
ok: res.ok,
|
|
status: res.status,
|
|
contains_beta_canary: res.body.includes(BETA_CANARY),
|
|
});
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(EVIDENCE_PATH), { recursive: true });
|
|
fs.writeFileSync(
|
|
EVIDENCE_PATH,
|
|
`${JSON.stringify(
|
|
{
|
|
prompt: 'BI-P017',
|
|
claim:
|
|
"Every bypass attempt, executed directly against a live ClickHouse as the tenant's " +
|
|
'restricted user WITH THE STATEMENT GUARD REMOVED, fails to return a foreign-tenant ' +
|
|
'row, an ungranted-table row, or server metadata.',
|
|
clickhouse_version: version,
|
|
recorded_at: new Date().toISOString(),
|
|
base_table_rows_both_tenants: 100,
|
|
tenant_view_rows_alpha: 50,
|
|
attempts_total: results.length,
|
|
attempts_that_leaked: leaks.length,
|
|
legitimate_statements_that_succeeded: legitimate.filter((l) => l.ok).length,
|
|
legitimate_statements_total: legitimate.length,
|
|
attempts: results,
|
|
legitimate,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
expect(leaks, `LEAKS with the guard removed:\n${leaks.join('\n')}`).toEqual([]);
|
|
expect(legitimate.every((l) => l.ok)).toBe(true);
|
|
expect(legitimate.every((l) => l.contains_beta_canary === false)).toBe(true);
|
|
}, 300_000);
|
|
});
|
|
|
|
suite('BI-P017 live — layers 3 and 4 individually, each with the one above removed', () => {
|
|
it('the row policies are actually APPLIED TO THE USER, not merely present', async () => {
|
|
// The check that caught the real bug. `SHOW ROW POLICIES` listing a policy
|
|
// with the right filter is not evidence: after a user is dropped and
|
|
// re-created, `CREATE ROW POLICY IF NOT EXISTS` leaves the policy in place
|
|
// with an EMPTY apply-to list, protecting nobody while looking correct.
|
|
const res = await admin(
|
|
rowPolicyVerificationSql({
|
|
sourceDatabase: DB,
|
|
tenantId: 't_alpha',
|
|
catalog: 't_alpha_sql',
|
|
objects: OBJECTS,
|
|
userName: 't_alpha_sql_user',
|
|
password: 'unused',
|
|
}),
|
|
);
|
|
expect(res.ok).toBe(true);
|
|
const rows = JSON.parse(res.body).data as Array<{ table: string; applied_to_user: number }>;
|
|
expect(rows.length).toBe(OBJECTS.length);
|
|
for (const row of rows) {
|
|
expect(row.applied_to_user, `row policy on ${row.table} applies to nobody`).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('layer 3 (row policy): the restricted user reading the SHARED BASE TABLE sees only its own rows', async () => {
|
|
// The counterfactual is in the suite above: as admin this same statement
|
|
// returns 100. Returning 100 here would be a cross-tenant read.
|
|
const res = await asAlpha(`SELECT count() FROM ${DB}.sales`);
|
|
if (res.ok) {
|
|
expect(Number(res.body.trim()), 'restricted user read BOTH tenants from the base table').toBe(50);
|
|
} else {
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
}
|
|
});
|
|
|
|
it('layer 3 (row policy): the base table yields no foreign canary even when named directly', async () => {
|
|
const res = await asAlpha(`SELECT customer FROM ${DB}.sales LIMIT 1000`);
|
|
expect(res.body).not.toContain(BETA_CANARY);
|
|
});
|
|
|
|
it('BI-P012 ceilings are enforced by the SETTINGS PROFILE, not by the request', async () => {
|
|
const tiny = (sql: string) => run(sql, `t_alpha_tiny_user:${TINY_PW}`);
|
|
// Under the ceiling: succeeds.
|
|
const ok = await tiny(`SELECT count() FROM t_alpha_sql.sales`);
|
|
expect(ok.ok).toBe(true);
|
|
// Over it: ClickHouse code 158, from the profile — no setting was sent.
|
|
const over = await tiny(`SELECT count() FROM t_alpha_sql.sales a, t_alpha_sql.sales b`);
|
|
expect(over.ok).toBe(false);
|
|
expect(over.body).toContain('Code: 158');
|
|
// And it cannot be raised from the request: code 164, readonly mode.
|
|
const raise = await tiny(`SELECT count() FROM t_alpha_sql.sales`);
|
|
expect(raise.ok).toBe(true);
|
|
const raised = await run(
|
|
`SELECT count() FROM t_alpha_sql.sales a, t_alpha_sql.sales b`,
|
|
`t_alpha_tiny_user:${TINY_PW}`,
|
|
{
|
|
max_rows_to_read: '0',
|
|
},
|
|
);
|
|
expect(raised.ok).toBe(false);
|
|
expect(raised.body).toContain('Code: 164');
|
|
});
|
|
|
|
it('layer 3: the restricted user cannot read a table it was never granted', async () => {
|
|
const res = await asAlpha(`SELECT note FROM ${DB}.secret_ledger`);
|
|
expect(res.ok).toBe(false);
|
|
expect(res.body).not.toContain(SECRET_CANARY);
|
|
});
|
|
|
|
it("layer 3: the restricted user cannot read the other tenant's catalog", async () => {
|
|
const res = await asAlpha(`SELECT customer FROM t_beta_sql.sales LIMIT 5`);
|
|
expect(res.ok).toBe(false);
|
|
expect(res.body).not.toContain(BETA_CANARY);
|
|
});
|
|
|
|
it('layer 3: readonly=1 refuses a write even though the guard never saw the statement', async () => {
|
|
const res = await asAlpha(`INSERT INTO ${DB}.sales VALUES ('t_alpha', 'x', 1, now())`);
|
|
expect(res.ok).toBe(false);
|
|
const after = await admin(`SELECT count() FROM ${DB}.sales`);
|
|
expect(Number(after.body.trim())).toBe(100);
|
|
});
|
|
|
|
it('layer 3: readonly=1 refuses a statement that tries to raise its own ceiling', async () => {
|
|
const res = await asAlpha(`SELECT count() FROM t_alpha_sql.sales SETTINGS max_rows_to_read = 0`);
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it('layer 4: the tenant column the view does not project is not selectable', async () => {
|
|
// This is the case the GUARD ADMITS. It must fail here, or the honest
|
|
// note in the corpus would be covering a real hole.
|
|
const res = await asAlpha(`SELECT tenant_id FROM t_alpha_sql.sales LIMIT 1`);
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it('layer 4: a WHERE on the hidden tenant column cannot widen the view', async () => {
|
|
const res = await asAlpha(`SELECT count() FROM t_alpha_sql.sales WHERE tenant_id = 't_beta'`);
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
});
|