Files
0pt/src/services/source-query-service.ts
T
admin 12080fc517 feat(0pt): PII-Gate GDPR-guardrail for personal-data sources (fail-closed)
PiiGateService makes any future personal-data source (ratsit.se and other
directory domains) technically GDPR-OK before the first byte is stored:
- recorded legal basis (art. 6) with basis_ref evidence, no policy => fail-closed
- data minimisation via field allowlist (art. 5(1)(c))
- storage limitation + enforceRetention purge (art. 5(1)(e))
- sha256-only identity keys + log redaction (art. 25/32)
- accessStatement / erase subject-rights surfaces (art. 15/17)
- robots/rate confirmation required for web+api sources; no stealth/bypass
- audit trail via AUD0 at risk L3 (0pt.pii_*)
- gate enabled by default in SourceQueryService

10 new tests; full suite 166 pass | 1 skip; typecheck (blocking gate) clean.
2026-09-15 02:40:52 +02:00

239 lines
9.6 KiB
TypeScript

/**
* src/services/source-query-service.ts
* =======================================
* 0pt.query_source — bounded, timeout-guarded synchronous query execution
* against a registered Source (source_type = "database",
* adapter_id = "fabric_onelake_sql" today; extensible via `ADAPTER_REGISTRY`).
*
* This is deliberately NOT a general-purpose query engine: it enforces a
* max-rows and max-time ceiling on every call and is meant for small,
* interactive/preview-style reads. Anything heavier (large exports, long
* scans, scheduled pulls) must go through N0D as a job — see
* docs/P0_QUERY_SOURCE_FLOW.md — the same "synchronous vs. N0D job" split
* `quality-check-service.ts` uses for quality checks.
*
* Reuses the existing SourceService (src/services/source-service.ts) purely
* through its public `getSource()` — this file does not modify that class or
* the Source/CreateSourceInput types, which already carry the `adapter_id`
* field this capability depends on.
*/
import type { Source } from "../types/source";
import { SourceService } from "./source-service";
import { PiiGateService } from "./pii-gate-service";
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
import { FabricOneLakeAdapter, type FabricOneLakeConnectionConfig, type AdapterQueryResult } from "../adapters/fabric-onelake-adapter";
/** Default bound applied when the caller does not specify one. */
export const SOURCE_QUERY_DEFAULT_MAX_ROWS = 1000;
/** Absolute ceiling — a caller cannot raise max_rows above this on the synchronous path. */
export const SOURCE_QUERY_HARD_MAX_ROWS = 10_000;
/** Default timeout applied when the caller does not specify one. */
export const SOURCE_QUERY_DEFAULT_TIMEOUT_MS = 10_000;
/** Absolute ceiling — a caller cannot raise timeout_ms above this on the synchronous path. */
export const SOURCE_QUERY_HARD_MAX_TIMEOUT_MS = 30_000;
/** Adapter ids supported by the synchronous query path today. */
export type SupportedQueryAdapterId = "fabric_onelake_sql";
export class SourceQueryTimeoutError extends Error {}
export class SourceQueryUnsupportedError extends Error {}
export interface QuerySourceInput {
source_id: string;
query: string;
params?: Record<string, unknown>;
tenant_id: string;
project_id: string;
actor_id?: string;
correlation_id?: string;
/** Clamped to [1, SOURCE_QUERY_HARD_MAX_ROWS]. Defaults to SOURCE_QUERY_DEFAULT_MAX_ROWS. */
max_rows?: number;
/** Clamped to [1, SOURCE_QUERY_HARD_MAX_TIMEOUT_MS]. Defaults to SOURCE_QUERY_DEFAULT_TIMEOUT_MS. */
timeout_ms?: number;
/**
* Connection config for the source's adapter. Never persisted by this
* service and never defaulted from an env var — tenant-agnostic by
* construction, same as the adapter itself. Today only the
* "fabric_onelake_sql" shape is supported.
*/
connection_config: FabricOneLakeConnectionConfig;
}
export interface QuerySourceResult {
source_id: string;
rows: Record<string, unknown>[];
row_count: number;
truncated: boolean;
duration_ms: number;
bounds: { max_rows: number; timeout_ms: number };
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
export class SourceQueryService {
private sourceService: SourceService;
private aud0: Aud0Client;
/** GDPR guardrail — on by default, can be shared via opts so callers use one registry. */
private piiGate: PiiGateService;
constructor(opts?: { sourceService?: SourceService; aud0BaseUrl?: string; piiGate?: PiiGateService }) {
// Accepts an existing SourceService so callers (e.g. the API route) share
// one in-memory registry instead of each constructing an empty one.
this.sourceService = opts?.sourceService ?? new SourceService();
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
this.piiGate = opts?.piiGate ?? new PiiGateService({ aud0BaseUrl: opts?.aud0BaseUrl });
}
async querySource(input: QuerySourceInput): Promise<QuerySourceResult> {
const startedAt = Date.now();
// GDPR fail-closed: personal-data sources (self-flagged or known directory
// domains such as ratsit.se) may not be processed without a PiiPolicy.
// Runs BEFORE the capability/type gate so the lawful-basis error is the
// one the caller sees for personal data.
const preflight = await this.sourceService.getSource(input.source_id);
await this.piiGate.assertCanStore(preflight, [], input.tenant_id);
const source = await this.getQueryableSource(input.source_id);
const maxRows = clamp(input.max_rows ?? SOURCE_QUERY_DEFAULT_MAX_ROWS, 1, SOURCE_QUERY_HARD_MAX_ROWS);
const timeoutMs = clamp(input.timeout_ms ?? SOURCE_QUERY_DEFAULT_TIMEOUT_MS, 1, SOURCE_QUERY_HARD_MAX_TIMEOUT_MS);
const correlationId = input.correlation_id ?? `corr_0pt_query_source_${input.source_id}_${startedAt}`;
const adapter = this.buildAdapter(source, input.connection_config);
try {
const { rows, durationMs } = await this.runWithTimeout(adapter.query(input.query, input.params), timeoutMs);
const truncated = rows.length > maxRows;
const boundedRows = truncated ? rows.slice(0, maxRows) : rows;
await this.emitQueriedEvent({
source,
input,
correlationId,
rowCount: boundedRows.length,
truncated,
durationMs,
ok: true,
});
return {
source_id: source.source_id,
rows: boundedRows,
row_count: boundedRows.length,
truncated,
duration_ms: durationMs,
bounds: { max_rows: maxRows, timeout_ms: timeoutMs },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.emitQueriedEvent({
source,
input,
correlationId,
rowCount: 0,
truncated: false,
durationMs: Date.now() - startedAt,
ok: false,
error: message,
});
throw err instanceof Error ? err : new Error(message);
} finally {
// Each call opens its own short-lived adapter/pool (see buildAdapter) —
// always release it, whether the query succeeded, failed, or timed out.
await adapter.close().catch(() => {});
}
}
private async getQueryableSource(sourceId: string): Promise<Source> {
const source = await this.sourceService.getSource(sourceId);
if (source.source_type !== "database") {
throw new SourceQueryUnsupportedError(
`Source ${sourceId} has source_type "${source.source_type}", not "database" — the synchronous 0pt.query_source path only ` +
`supports database sources. Read non-database sources through a N0D job instead.`,
);
}
if (source.adapter_id !== "fabric_onelake_sql") {
throw new SourceQueryUnsupportedError(
`Source ${sourceId} has adapter_id "${source.adapter_id ?? "(none)"}" — the synchronous 0pt.query_source path currently only ` +
`supports adapter_id "fabric_onelake_sql". Register the source with that adapter_id, or run the read through N0D as a job instead.`,
);
}
return source;
}
private buildAdapter(source: Source, connectionConfig: FabricOneLakeConnectionConfig): FabricOneLakeAdapter {
if (!connectionConfig) {
throw new Error(`Source ${source.source_id}: connection_config is required to query a fabric_onelake_sql source`);
}
return new FabricOneLakeAdapter(connectionConfig);
}
/**
* Races the adapter query against a timeout. Note this bounds *waiting*
* time on the caller's side — it does not cancel an in-flight TDS request
* on timeout (the `mssql` driver has no cooperative cancellation hook used
* here), so a timed-out query may still complete against the warehouse
* after this call has already thrown. That is an accepted trade-off for a
* synchronous, best-effort preview path; anything where that matters
* belongs on the N0D job path instead.
*/
private async runWithTimeout<T>(promise: Promise<AdapterQueryResult<T>>, timeoutMs: number): Promise<AdapterQueryResult<T>> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new SourceQueryTimeoutError(
`source query exceeded the bounded synchronous timeout of ${timeoutMs}ms — run this query through N0D as a job instead of the synchronous 0pt.query_source path`,
),
);
}, timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
private async emitQueriedEvent(args: {
source: Source;
input: QuerySourceInput;
correlationId: string;
rowCount: number;
truncated: boolean;
durationMs: number;
ok: boolean;
error?: string;
}): Promise<void> {
const { source, input, correlationId, rowCount, truncated, durationMs, ok, error } = args;
await this.aud0.ingestEvent({
event_id: `evt_0pt_source_queried_${source.source_id}_${Date.now()}`,
event_type: "0pt.source_queried",
tenant_id: input.tenant_id,
project_id: input.project_id,
actor_id: input.actor_id ?? "system",
app_id: "0pt",
capability_id: "0pt.query_source",
resource_type: "source",
resource_id: source.source_id,
risk_level: "L4",
payload: {
adapter_id: source.adapter_id,
ok,
row_count: rowCount,
truncated,
duration_ms: durationMs,
query_preview: input.query.slice(0, 200),
...(error ? { error } : {}),
},
created_at: new Date().toISOString(),
correlation_id: correlationId,
});
}
}