mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
* 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>
144 lines
3.9 KiB
TypeScript
144 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|