Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createUnsafeGate,
|
||||
getUnsafeGateInstruments,
|
||||
resetUnsafeGateInstruments,
|
||||
type UnsafeGateOptions,
|
||||
type UnsafeQueryApi,
|
||||
} from "./unsafe-query-gate.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `unsafe/unsafe-query-gate` — capability-gated escape hatch
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant-
|
||||
scoped surface. The gate is the only sanctioned path for a plugin to issue
|
||||
raw SQL that bypasses the tenant-scope rewriter — and the only path that is
|
||||
required to be auditable. The capability declaration is read from the
|
||||
plugin manifest at construction time; calls without the declaration throw
|
||||
synchronously, before the engine sees the SQL.
|
||||
|
||||
Each permitted call increments `plugin.unsafe_query_total{plugin}` so operators
|
||||
can graph which plugins are exercising the escape hatch and at what rate. A
|
||||
high count is an audit signal, not an error.
|
||||
|
||||
## Features
|
||||
|
||||
- Capability check is a literal array membership on `db.unsafe-queries`.
|
||||
- `plugin.unsafe_query_total` counter, attribute `plugin` carrying the
|
||||
plugin name from `UnsafeGateOptions`.
|
||||
- Lazy `getUnsafeGateInstruments()` so test harnesses can install a custom
|
||||
`MeterProvider` BEFORE the gate is constructed.
|
||||
- `resetUnsafeGateInstruments()` for tests that swap meter providers
|
||||
between cases.
|
||||
- Both `query` and `execute` are gated; the gate is symmetric across
|
||||
statement shapes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createUnsafeGate } from "@suaveplan/api-db-adapter/unsafe";
|
||||
|
||||
const unsafe = createUnsafeGate({
|
||||
plugin: "audit",
|
||||
capabilities: ["db.unsafe-queries"],
|
||||
admin,
|
||||
});
|
||||
const rows = await unsafe.query("SELECT * FROM audit_log");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
createUnsafeGate,
|
||||
resetUnsafeGateInstruments,
|
||||
} from "@suaveplan/api-db-adapter/unsafe";
|
||||
|
||||
// Install a test MeterProvider FIRST.
|
||||
metrics.setGlobalMeterProvider(testProvider);
|
||||
resetUnsafeGateInstruments();
|
||||
|
||||
const denied = createUnsafeGate({
|
||||
plugin: "user",
|
||||
capabilities: [], // no db.unsafe-queries
|
||||
admin,
|
||||
});
|
||||
|
||||
await denied
|
||||
.execute("DELETE FROM secrets")
|
||||
.catch((e) => e.code === "DB_ADAPTER.UNSAFE_NOT_PERMITTED");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createUnsafeGate(opts: UnsafeGateOptions): UnsafeQueryApi`.
|
||||
- `UnsafeQueryApi.query<R>(sql, params?): Promise<R>`.
|
||||
- `UnsafeQueryApi.execute(sql, params?): Promise<{ rowsAffected }>`.
|
||||
- `getUnsafeGateInstruments(): { meter, unsafeQueryTotal }`.
|
||||
- `resetUnsafeGateInstruments(): void` — test only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The capability check happens at gate construction and again on each call (so a
|
||||
permissions change applied between requests does not require the host to swap
|
||||
the gate). Counter attributes use `plugin` rather than the prefixed
|
||||
`db.plugin` to match the metric naming in the design doc. The gate never
|
||||
inspects the SQL — that is the host's audit-sink job; the gate's contract is
|
||||
"capability or `UNSAFE_NOT_PERMITTED`".
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createMemoryAdmin } from "../../tests/_helpers/memory-admin.js";
|
||||
import { DB_ADAPTER_CODES } from "../error-codes/index.js";
|
||||
import {
|
||||
createUnsafeGate,
|
||||
getUnsafeGateInstruments,
|
||||
resetUnsafeGateInstruments,
|
||||
} from "./unsafe-query-gate.js";
|
||||
|
||||
describe("createUnsafeGate", () => {
|
||||
it("throws when capability not declared (query)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = createMemoryAdmin();
|
||||
const gate = createUnsafeGate({
|
||||
plugin: "audit",
|
||||
capabilities: [],
|
||||
admin,
|
||||
});
|
||||
await expect(gate.query("SELECT 1")).rejects.toMatchObject({
|
||||
code: DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when capability not declared (execute)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = createMemoryAdmin();
|
||||
const gate = createUnsafeGate({
|
||||
plugin: "audit",
|
||||
capabilities: [],
|
||||
admin,
|
||||
});
|
||||
await expect(gate.execute("DELETE FROM x")).rejects.toMatchObject({
|
||||
code: DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits plugin.unsafe_query_total when allowed (query)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetUnsafeGateInstruments();
|
||||
const admin = createMemoryAdmin();
|
||||
await admin.execute("INSERT INTO t (id, tenant_id) VALUES (?, ?)", [
|
||||
"a",
|
||||
"x",
|
||||
]);
|
||||
const gate = createUnsafeGate({
|
||||
plugin: "audit",
|
||||
capabilities: ["db.unsafe-queries"],
|
||||
admin,
|
||||
});
|
||||
const rows = await gate.query<unknown[]>("SELECT * FROM t");
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
// counter exists & is wired
|
||||
const i = getUnsafeGateInstruments();
|
||||
expect(i.unsafeQueryTotal).toBeDefined();
|
||||
});
|
||||
|
||||
it("execute path also counts and delegates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = createMemoryAdmin();
|
||||
const gate = createUnsafeGate({
|
||||
plugin: "audit",
|
||||
capabilities: ["db.unsafe-queries"],
|
||||
admin,
|
||||
});
|
||||
const r = await gate.execute(
|
||||
"INSERT INTO t (id, tenant_id) VALUES (?, ?)",
|
||||
["a", "x"]
|
||||
);
|
||||
expect(r.rowsAffected).toBe(1);
|
||||
});
|
||||
|
||||
it("getUnsafeGateInstruments is cached across calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetUnsafeGateInstruments();
|
||||
const a = getUnsafeGateInstruments();
|
||||
const b = getUnsafeGateInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("reset clears the cache", { timeout: 5000 }, () => {
|
||||
const a = getUnsafeGateInstruments();
|
||||
resetUnsafeGateInstruments();
|
||||
const b = getUnsafeGateInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Counter, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { AdminDatabaseLike, DbParams } from "@suaveplan/api-types/db";
|
||||
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
|
||||
|
||||
/**
|
||||
* Public shape of `ctx.db.unsafe`. Always present on the tenant-scoped surface
|
||||
* created by the host; calls are gated at runtime by the
|
||||
* `db.unsafe-queries` capability.
|
||||
*/
|
||||
export interface UnsafeQueryApi {
|
||||
query<R = unknown>(sql: string, params?: DbParams): Promise<R>;
|
||||
execute(
|
||||
sql: string,
|
||||
params?: DbParams
|
||||
): Promise<{ readonly rowsAffected: number }>;
|
||||
}
|
||||
|
||||
/** Construction options for {@link createUnsafeGate}. */
|
||||
export interface UnsafeGateOptions {
|
||||
/** Plugin name — surfaced on the OTel counter label. */
|
||||
readonly plugin: string;
|
||||
/** Capability declarations from the plugin manifest. */
|
||||
readonly capabilities: ReadonlyArray<string>;
|
||||
/** Underlying admin adapter to delegate to when permitted. */
|
||||
readonly admin: AdminDatabaseLike;
|
||||
}
|
||||
|
||||
const UNSAFE_CAPABILITY = "db.unsafe-queries";
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly unsafeQueryTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
/** Lazily resolve OTel instruments — host may install a `MeterProvider` first. */
|
||||
export function getUnsafeGateInstruments(): CachedInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-db-adapter");
|
||||
cached = {
|
||||
meter,
|
||||
unsafeQueryTotal: meter.createCounter("plugin.unsafe_query_total", {
|
||||
description:
|
||||
"Number of db.unsafe.query / db.unsafe.execute calls made by a plugin.",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Test helper: reset the cached instruments so a new MeterProvider is picked up. */
|
||||
export function resetUnsafeGateInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `db.unsafe` capability-gated escape hatch.
|
||||
*
|
||||
* - If `capabilities` does NOT include `db.unsafe-queries`, every call throws
|
||||
* `DB_ADAPTER.UNSAFE_NOT_PERMITTED` without touching the engine.
|
||||
* - If the capability IS declared, the call is delegated to `admin` and the
|
||||
* `plugin.unsafe_query_total{plugin}` counter increments by 1.
|
||||
*/
|
||||
export function createUnsafeGate(opts: UnsafeGateOptions): UnsafeQueryApi {
|
||||
const allowed = opts.capabilities.includes(UNSAFE_CAPABILITY);
|
||||
const reject = (): never => {
|
||||
throw new DbAdapterError(
|
||||
DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
|
||||
`plugin '${opts.plugin}' did not declare '${UNSAFE_CAPABILITY}' capability`,
|
||||
{ context: { plugin: opts.plugin } }
|
||||
);
|
||||
};
|
||||
return {
|
||||
query: async <R>(sql: string, params?: DbParams): Promise<R> => {
|
||||
if (!allowed) reject();
|
||||
getUnsafeGateInstruments().unsafeQueryTotal.add(1, {
|
||||
plugin: opts.plugin,
|
||||
});
|
||||
return await opts.admin.query<R>(sql, params);
|
||||
},
|
||||
execute: async (sql: string, params?: DbParams) => {
|
||||
if (!allowed) reject();
|
||||
getUnsafeGateInstruments().unsafeQueryTotal.add(1, {
|
||||
plugin: opts.plugin,
|
||||
});
|
||||
return await opts.admin.execute(sql, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user