fix(ingest): enforce SL target connection scope

This commit is contained in:
Andrey Avtomonov 2026-05-17 22:11:44 +02:00
parent 5ec639602b
commit 9d756b2c6c
10 changed files with 200 additions and 2 deletions

View file

@ -50,6 +50,22 @@ describe('isolated diff patch contract', () => {
).toThrow(/slDisallowed WorkUnit lookml-mismatch touched semantic-layer\/c1\/orders.yaml/);
});
it('rejects semantic-layer paths outside allowed target connections', () => {
const patch =
'diff --git a/semantic-layer/finance/orders.yaml b/semantic-layer/finance/orders.yaml\nindex 1..2 100644\n';
expect(() =>
assertPatchAllowedForWorkUnit({
unitKey: 'wu-finance',
patch,
slDisallowed: false,
allowedTargetConnectionIds: new Set(['warehouse']),
}),
).toThrow(
/semantic-layer target connection not allowed: semantic-layer\/finance\/orders.yaml \(finance\); allowed: warehouse/,
);
});
it('rejects executable and binary changes under known text artifact roots', () => {
expect(textArtifactRoots).toEqual(['wiki/', 'semantic-layer/']);

View file

@ -1,3 +1,5 @@
import { assertSemanticLayerTargetPathsAllowed } from '../semantic-layer-target-policy.js';
export const textArtifactRoots = ['wiki/', 'semantic-layer/'] as const;
export interface PatchTouchedPath {
@ -12,6 +14,7 @@ export interface PatchPolicyInput {
unitKey: string;
patch: string;
slDisallowed: boolean;
allowedTargetConnectionIds?: ReadonlySet<string>;
}
function stripPrefix(path: string): string {
@ -74,6 +77,12 @@ export function parsePatchTouchedPaths(patch: string): PatchTouchedPath[] {
export function assertPatchAllowedForWorkUnit(input: PatchPolicyInput): PatchTouchedPath[] {
const touched = parsePatchTouchedPaths(input.patch);
if (input.allowedTargetConnectionIds) {
assertSemanticLayerTargetPathsAllowed({
paths: touched.map((entry) => entry.path),
allowedConnectionIds: input.allowedTargetConnectionIds,
});
}
for (const entry of touched) {
if (input.slDisallowed && entry.path.startsWith('semantic-layer/')) {
throw new Error(`slDisallowed WorkUnit ${input.unitKey} touched ${entry.path}`);

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
assertSemanticLayerTargetPathsAllowed,
findDisallowedSemanticLayerTargetPaths,
semanticLayerConnectionIdFromPath,
} from './semantic-layer-target-policy.js';
describe('semantic-layer target policy', () => {
it('extracts connection ids from semantic-layer paths', () => {
expect(semanticLayerConnectionIdFromPath('semantic-layer/warehouse/orders.yaml')).toBe('warehouse');
expect(semanticLayerConnectionIdFromPath('a/semantic-layer/finance/orders.yaml')).toBe('finance');
expect(semanticLayerConnectionIdFromPath('wiki/global/orders.md')).toBeNull();
});
it('finds semantic-layer paths outside the allowed target connections', () => {
expect(
findDisallowedSemanticLayerTargetPaths({
paths: [
'semantic-layer/warehouse/orders.yaml',
'semantic-layer/finance/orders.yaml',
'wiki/global/orders.md',
],
allowedConnectionIds: new Set(['warehouse']),
}),
).toEqual([{ path: 'semantic-layer/finance/orders.yaml', connectionId: 'finance' }]);
});
it('throws a deterministic error for unauthorized semantic-layer targets', () => {
expect(() =>
assertSemanticLayerTargetPathsAllowed({
paths: ['semantic-layer/finance/orders.yaml', 'semantic-layer/marketing/accounts.yaml'],
allowedConnectionIds: new Set(['warehouse']),
}),
).toThrow(
/semantic-layer target connection not allowed: semantic-layer\/finance\/orders\.yaml \(finance\), semantic-layer\/marketing\/accounts\.yaml \(marketing\); allowed: warehouse/,
);
});
});

View file

@ -0,0 +1,42 @@
export interface SemanticLayerTargetPolicyInput {
paths: readonly string[];
allowedConnectionIds: ReadonlySet<string>;
}
export interface SemanticLayerTargetPolicyViolation {
path: string;
connectionId: string;
}
export function semanticLayerConnectionIdFromPath(path: string): string | null {
const normalized = path.replace(/^[ab]\//, '');
const match = /^semantic-layer\/([^/]+)\//.exec(normalized);
return match?.[1] ?? null;
}
export function findDisallowedSemanticLayerTargetPaths(
input: SemanticLayerTargetPolicyInput,
): SemanticLayerTargetPolicyViolation[] {
return input.paths
.map((path) => ({ path, connectionId: semanticLayerConnectionIdFromPath(path) }))
.filter((entry): entry is SemanticLayerTargetPolicyViolation => {
return entry.connectionId !== null && !input.allowedConnectionIds.has(entry.connectionId);
})
.sort((left, right) => {
const byConnection = left.connectionId.localeCompare(right.connectionId);
return byConnection === 0 ? left.path.localeCompare(right.path) : byConnection;
});
}
export function assertSemanticLayerTargetPathsAllowed(input: SemanticLayerTargetPolicyInput): void {
const violations = findDisallowedSemanticLayerTargetPaths(input);
if (violations.length === 0) {
return;
}
const allowed = [...input.allowedConnectionIds].sort();
throw new Error(
`semantic-layer target connection not allowed: ${violations
.map((violation) => `${violation.path} (${violation.connectionId})`)
.join(', ')}; allowed: ${allowed.length > 0 ? allowed.join(', ') : '(none)'}`,
);
}

View file

@ -99,6 +99,27 @@ describe('SlEditSourceTool — session gating', () => {
);
});
it('rejects session-scoped edits outside allowed target connections', async () => {
const { tool } = makeTool();
const session = makeSession({
allowedConnectionNames: new Set(['warehouse']),
});
const context: ToolContext = { ...baseContext, session };
const result = await tool.call(
{
connectionId: 'finance',
sourceName: 'orders',
yaml_edits: [{ oldText: 'measures: []', newText: 'measures: []' }],
} as any,
context,
);
expect(result.structured.success).toBe(false);
expect(result.markdown).toContain('connectionId "finance" is outside this ingest session');
expect(session.actions).toEqual([]);
});
it('indexes normally when no session is present', async () => {
const { tool, slSearchService } = makeTool();
const result = await tool.call(

View file

@ -1,6 +1,12 @@
import YAML from 'yaml';
import { z } from 'zod';
import { addTouchedSlSource, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js';
import {
addTouchedSlSource,
type ToolContext,
type ToolOutput,
validateActionRawPaths,
validateActionTargetConnection,
} from '../../tools/index.js';
import { applySqlEdits } from '../../tools/sql-edit-replacer.js';
import { normalizeSemanticLayerDescriptions } from '../description-normalization.js';
import type { SemanticLayerSource } from '../types.js';
@ -79,6 +85,10 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService;
const skipIndex = context.session?.isWorktreeScoped === true;
const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId);
if (!targetConnectionValidation.ok) {
return this.buildOutput(false, [targetConnectionValidation.error], sourceName);
}
const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths);
if (!rawPathValidation.ok) {
return this.buildOutput(false, [rawPathValidation.error], sourceName);

View file

@ -133,6 +133,34 @@ describe('SlWriteSourceTool — session gating', () => {
);
});
it('rejects session-scoped writes outside allowed target connections', async () => {
const { tool } = makeTool();
const session = makeSession({
allowedConnectionNames: new Set(['warehouse']),
});
const context: ToolContext = { ...baseContext, session };
const result = await tool.call(
{
connectionId: 'finance',
sourceName: 'finance_orders',
source: {
name: 'finance_orders',
table: 'public.orders',
grain: ['id'],
columns: [{ name: 'id', type: 'string' }],
measures: [],
joins: [],
} as any,
} as any,
context,
);
expect(result.structured.success).toBe(false);
expect(result.markdown).toContain('connectionId "finance" is outside this ingest session');
expect(session.actions).toEqual([]);
});
it('indexes normally when no session is present', async () => {
const { tool, slSearchService } = makeTool();
const result = await tool.call(

View file

@ -1,6 +1,12 @@
import YAML from 'yaml';
import { z } from 'zod';
import { addTouchedSlSource, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js';
import {
addTouchedSlSource,
type ToolContext,
type ToolOutput,
validateActionRawPaths,
validateActionTargetConnection,
} from '../../tools/index.js';
import { sourceOverlaySchema } from '../schemas.js';
import type { SemanticLayerService } from '../semantic-layer.service.js';
import type { SemanticLayerSource } from '../types.js';
@ -106,6 +112,10 @@ Do NOT join back to a table that the SQL already aggregates from if the grain co
const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService;
const skipIndex = context.session?.isWorktreeScoped === true;
const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId);
if (!targetConnectionValidation.ok) {
return this.buildOutput(false, [targetConnectionValidation.error], sourceName);
}
const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths);
if (!rawPathValidation.ok) {
return this.buildOutput(false, [rawPathValidation.error], sourceName);

View file

@ -0,0 +1,23 @@
import type { ToolSession } from './tool-session.js';
type ActionTargetConnectionValidation = { ok: true } | { ok: false; error: string };
export function validateActionTargetConnection(
session: ToolSession | undefined,
connectionId: string,
): ActionTargetConnectionValidation {
const allowed = session?.allowedConnectionNames;
if (!allowed) {
return { ok: true };
}
if (allowed.has(connectionId)) {
return { ok: true };
}
const allowedList = [...allowed].sort();
return {
ok: false,
error: `connectionId "${connectionId}" is outside this ingest session's allowed target connections: ${
allowedList.length > 0 ? allowedList.join(', ') : '(none)'
}`,
};
}

View file

@ -32,6 +32,7 @@ export type { SqlEdit } from './sql-edit-replacer.js';
export { applySqlEdits } from './sql-edit-replacer.js';
export type { IngestToolMetadata, MemoryAction, ToolSession } from './tool-session.js';
export { validateActionRawPaths } from './action-raw-paths.js';
export { validateActionTargetConnection } from './action-target-connection.js';
export type { TouchedSlSource, TouchedSlSourceSet } from './touched-sl-sources.js';
export {
addTouchedSlSource,