ktx/packages/cli/test/context/connections/query-policy.test.ts

145 lines
3.9 KiB
TypeScript
Raw Normal View History

feat: query_policy semantic-layer-only restricts agents to predefined semantic-layer measures (#334) * feat(sl): add predefined_measures_only guard to semantic query planning SemanticQuery gains a predefined_measures_only flag; the planner rejects any measure resolved with Provenance.COMPOSED (runtime aggregate expressions and query-time derivations) while predefined measures, predefined derived chains, dimensions, filters, and segments pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): add per-connection query_policy to warehouse connections query_policy: semantic-layer-only | read-only-sql (default) on the warehouse connection schema, plus a policy module with the raw-SQL guard, federated member restriction lookup, and the project-level predicate used to gate sql_execution registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): enforce query_policy on raw SQL through one shared executor ktx sql and the MCP sql_execution tool now share executeProjectRawSql (resolve, policy check, read-only validation, execute), collapsing their duplicated validate-then-execute paths. Restricted connections are rejected before validation; federated raw SQL is rejected when any member is restricted. sql_execution is not registered when every SQL connection is restricted, and connection_list marks restricted connections so agents route to sl_query. executeProjectReadOnlySql stays generic for ktx-internal SQL (scan, ingest, SL-generated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sl): compile queries with predefined_measures_only from query_policy compileLocalSlQuery injects the flag from the connection's query_policy, never from caller input, covering both ktx sl query and the MCP sl_query tool through the daemon compile path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document query_policy semantic-layer-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sl): close semantic-layer-only bypasses via filters and federated hint The predefined_measures_only guard only inspected query.measures, so a composed aggregate written into `filters` slipped through _classify_filters into a HAVING clause untouched — letting a restricted agent evaluate arbitrary aggregates (e.g. threshold-probing `sum(x) BETWEEN a AND b`). Reject filter clauses that compose an aggregate function; a HAVING that compares a predefined measure by name (`orders.revenue > 100`) still works. Also make the federated sl_query error policy-aware: when a member is restricted, raw federated SQL is disabled too, so stop directing the agent to `ktx sql -c _ktx_federated` / sql_execution (a guaranteed failure) and point to per-connection semantic-layer queries instead. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-03 01:54:17 -07:00
import { describe, expect, it } from 'vitest';
import {
assertRawSqlAllowed,
connectionQueryPolicy,
projectAllowsRawSql,
restrictedFederatedMemberIds,
} from '../../../src/context/connections/query-policy.js';
import { parseKtxProjectConfig } from '../../../src/context/project/config.js';
import { KtxQueryError } from '../../../src/errors.js';
const PROJECT_DIR = '/tmp/proj';
function config(yaml: string) {
return parseKtxProjectConfig(yaml);
}
describe('connectionQueryPolicy', () => {
it('defaults to read-only-sql when the field is absent or the connection is unknown', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(connectionQueryPolicy(parsed.connections.warehouse)).toBe('read-only-sql');
expect(connectionQueryPolicy(undefined)).toBe('read-only-sql');
});
it('reads semantic-layer-only from ktx.yaml', () => {
const parsed = config(`
connections:
warehouse:
driver: snowflake
url: env:SNOWFLAKE_URL
query_policy: semantic-layer-only
`);
expect(connectionQueryPolicy(parsed.connections.warehouse)).toBe('semantic-layer-only');
});
it('rejects unknown query_policy values at config parse time', () => {
expect(() =>
config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
query_policy: everything-goes
`),
).toThrow();
});
});
describe('assertRawSqlAllowed', () => {
it('allows raw SQL on an unrestricted connection', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).not.toThrow();
});
it('rejects raw SQL on a restricted connection with an expected error naming the policy', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
query_policy: semantic-layer-only
`);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).toThrow(KtxQueryError);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).toThrow(
/query_policy: semantic-layer-only/,
);
});
it('rejects federated raw SQL when any member connection is restricted', () => {
const parsed = config(`
connections:
sales:
driver: sqlite
url: file:sales.db
query_policy: semantic-layer-only
events:
driver: sqlite
url: file:events.db
`);
expect(restrictedFederatedMemberIds(parsed, PROJECT_DIR)).toEqual(['sales']);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, '_ktx_federated')).toThrow(/"sales"/);
});
it('allows federated raw SQL when no member is restricted', () => {
const parsed = config(`
connections:
sales:
driver: sqlite
url: file:sales.db
events:
driver: sqlite
url: file:events.db
`);
expect(restrictedFederatedMemberIds(parsed, PROJECT_DIR)).toEqual([]);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, '_ktx_federated')).not.toThrow();
});
});
describe('projectAllowsRawSql', () => {
it('is true when at least one SQL connection is unrestricted', () => {
const parsed = config(`
connections:
finance:
driver: postgres
url: env:FINANCE_URL
query_policy: semantic-layer-only
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(projectAllowsRawSql(parsed)).toBe(true);
});
it('is false when every SQL connection is restricted', () => {
const parsed = config(`
connections:
finance:
driver: postgres
url: env:FINANCE_URL
query_policy: semantic-layer-only
`);
expect(projectAllowsRawSql(parsed)).toBe(false);
});
it('is true for projects with no SQL-queryable connections', () => {
const parsed = config(`
connections:
docs:
driver: mongodb
url: mongodb://localhost:27017/app
`);
expect(projectAllowsRawSql(parsed)).toBe(true);
expect(projectAllowsRawSql(config('connections: {}'))).toBe(true);
});
});