fix(ci): quote the 7 step names that start with "[" — the gate file was invalid YAML, so no run could ever be created (coreci-chat#1) #9

Merged
jonathan merged 22 commits from po/ci-fix-invalid-workflow-yaml into main 2026-10-27 22:38:41 +00:00
12 changed files with 491 additions and 78 deletions
+25 -10
View File
@@ -40,7 +40,12 @@ on:
env:
# Pin Node + pnpm versions for reproducibility.
NODE_VERSION: "20"
# Node 22 is the floor: pnpm 11.23.0 (the packageManager pin) needs >= v22.13
# and dies on Node 20 with ERR_UNKNOWN_BUILTIN_MODULE (node:sqlite).
NODE_VERSION: "22"
# Display label for the pnpm step only. The INSTALLED pnpm version is the
# packageManager pin in package.json - pnpm/action-setup rejects a second
# pin (workflow version: + package.json packageManager:) and exits 1.
PNPM_VERSION: "11"
jobs:
@@ -61,7 +66,6 @@ jobs:
- name: Setup pnpm ${{ env.PNPM_VERSION }}
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store dir
@@ -78,6 +82,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Consumers resolve @coreci/db and @coreci/secrets through their
# published dist/*.d.ts, so the packages must be built (pnpm runs
# them in dependency order) before typecheck and before the tests.
- name: Build (packages - dist for the type declarations)
run: pnpm --filter "./packages/*" -r build
- name: Typecheck
run: pnpm typecheck
@@ -87,7 +97,7 @@ jobs:
- name: Build (mcp package — dist for the smoke imports)
run: pnpm --filter @coreci/mcp build
- name: [G-018,R-008] Import guard (no @coreci/llm-mock in prod source)
- name: "[G-018,R-008] Import guard (no @coreci/llm-mock in prod source)"
run: pnpm check:llm-mock-guard
- name: Unit + integration tests (PGlite)
@@ -169,7 +179,6 @@ jobs:
- name: Setup pnpm ${{ env.PNPM_VERSION }}
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Cache pnpm store
@@ -182,6 +191,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Consumers resolve @coreci/db and @coreci/secrets through their
# published dist/*.d.ts, so the packages must be built (pnpm runs
# them in dependency order) before typecheck and before the tests.
- name: Build (packages - dist for the type declarations)
run: pnpm --filter "./packages/*" -r build
- name: Install the postgres client (the runner image carries no psql)
run: |
apt-get update -qq && apt-get install -y -qq postgresql-client
@@ -193,27 +208,27 @@ jobs:
echo "DATABASE_URL=postgres://coreci_app:coreci_app_ci@$HOST:5432/coreci_ci" >> "$GITHUB_ENV"
echo "CI_MIGRATOR_DATABASE_URL=postgres://migrator:migrator_ci@$HOST:5432/coreci_ci" >> "$GITHUB_ENV"
- name: [R-009] Setup CI roles (coreci_app NOBYPASSRLS, migrator BYPASSRLS)
- name: "[R-009] Setup CI roles (coreci_app NOBYPASSRLS, migrator BYPASSRLS)"
run: |
psql -h "$CI_DB_HOST" -U postgres -d postgres -f packages/db/scripts/setup-ci-roles.sql
- name: [G-022] Run migrations as migrator (BYPASSRLS)
- name: "[G-022] Run migrations as migrator (BYPASSRLS)"
run: |
DATABASE_URL="$CI_MIGRATOR_DATABASE_URL" pnpm --filter @coreci/db migrate
- name: [G-022] Build mcp (dist for smoke imports)
- name: "[G-022] Build mcp (dist for smoke imports)"
run: pnpm --filter @coreci/mcp build
- name: [G-022] Full M1 + M2 test suite against real Postgres 16
- name: "[G-022] Full M1 + M2 test suite against real Postgres 16"
# The tests read DB_MODE=pg + DATABASE_URL (coreci_app role, RLS
# enforced). The pen test's WITH CHECK assertion (R-009) is REAL here
# — a cross-tenant INSERT is rejected by the RLS policy.
run: pnpm test
- name: [G-022] MCP conformance + LLM smoke (Track A only — no PAT in pg job)
- name: "[G-022] MCP conformance + LLM smoke (Track A only — no PAT in pg job)"
run: pnpm test:conformance
- name: [G-022] DB pen test (real RLS WITH CHECK enforcement, R-009)
- name: "[G-022] DB pen test (real RLS WITH CHECK enforcement, R-009)"
run: pnpm --filter @coreci/db test:pen
- name: Upload coverage artifacts
+27
View File
@@ -0,0 +1,27 @@
/**
* / - App Router root layout (coreci-chat#10).
*
* Next.js 15 requires a root layout that renders <html> and <body> above
* every route. Without one, `next build` fails with "login/page.tsx does not
* have a root layout", so apps/control-plane has never been buildable - the
* pipeline never built the app, so nothing caught it.
*
* This is the build-enabling minimum only. The shell content (theme,
* providers, session wrapper) is an M3 scope call for the line head and is
* deliberately NOT decided here - it rides the M3 kickoff verdict on
* coreci-chat#1.
*/
import type { ReactNode } from "react";
export const metadata = {
title: "CoreCI Chat - control plane",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+13
View File
@@ -31,6 +31,19 @@ export async function getDb(): Promise<DbClient> {
}
async function runMigrations(db: DbClient): Promise<void> {
// The runtime role owns no tables. The migrations are applied by the
// migrator role (BYPASSRLS) - `pnpm migrate` in CI, the deploy step in prod -
// and the app role cannot DDL: the migration files CREATE tables, indexes and
// RLS policies, and those need ownership. The app role only gets DML. So the
// boot-time re-run is skipped when the schema is already there; it stays the
// dev convenience it was meant to be (a fresh PGlite database has no schema
// yet). Without this, the real-Postgres CI job red on "permission denied for
// schema public" as soon as the control-plane suites booted the app.
const { rows } = await db.query<{ applied: boolean }>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'tenants') AS applied",
);
if (rows[0]?.applied) return;
// Migrations live in packages/db/migrations. Resolve via the symlinked
// @coreci/db workspace package (apps/control-plane/node_modules/@coreci/db
// → packages/db). Falls back to a monorepo-relative path in dev.
+9 -4
View File
@@ -135,10 +135,15 @@ beforeAll(async () => {
});
beforeEach(async () => {
// Wipe mcp_adapters between tests (disable RLS to mutate, re-enable).
await db.query("ALTER TABLE mcp_adapters DISABLE ROW LEVEL SECURITY");
await db.query("DELETE FROM mcp_adapters");
await db.query("ALTER TABLE mcp_adapters ENABLE ROW LEVEL SECURITY");
// Wipe mcp_adapters between tests. The runtime role (coreci_app) owns no
// tables, so it cannot ALTER the table's RLS - that is why the old wipe
// died with "must be owner of table mcp_adapters" and took all 15 tests
// with it (coreci-chat#12). The wipe is a tenant-scoped DELETE inside
// withTenant: the app role holds the DELETE privilege and the RLS policy
// scopes the rows to the session's tenant.
await withTenant(admin!.tenantId, async (c: ScopedClient) => {
await c.query("DELETE FROM mcp_adapters");
});
// Reset the stream manager between tests so contexts don't leak.
const { streamManager } = await getMcpRuntime();
streamManager.reset();
+36 -27
View File
@@ -80,41 +80,51 @@ export async function provisionTenant(
createdUser = true;
}
// 3. Resolve-or-create the membership (tenant-scoped).
const membership = await resolveMembership(db, tenant.id, user.id);
if (!membership) {
const role: "admin" | "operator" | "viewer" = createdTenant ? "admin" : "viewer";
await withTenant(tenant.id, async (c: ScopedClient) => {
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
VALUES ($1, $2, $3)`,
[tenant.id, user.id, role],
);
// Audit: provision event (only when we actually created something).
await appendAudit(c, {
tenantId: tenant.id,
eventType: "provision",
payload: {
workosOrgId,
workosUserId,
email,
role,
createdTenant,
createdUser,
},
userId: user.id,
});
// 3. Resolve-or-create the membership (tenant-scoped). The lookup runs
// INSIDE the tenant scope on purpose: under RLS a statement outside a
// scope sees zero rows, so resolving outside always missed an existing
// membership and re-inserted it (the unique constraint on
// (tenant_id, user_id) then rejected the second call). It also keeps the
// statement off the empty-scope crash path: casting an unset scope to
// uuid raised `invalid input syntax for type uuid: ""` under real
// Postgres (coreci-chat#13).
const role: "admin" | "operator" | "viewer" = createdTenant ? "admin" : "viewer";
const membership = await withTenant(tenant.id, async (c: ScopedClient) => {
const found = await resolveMembership(c, tenant.id, user.id);
if (found) return { role: found.role, created: false };
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
VALUES ($1, $2, $3)`,
[tenant.id, user.id, role],
);
// Audit: provision event (only when we actually created something).
await appendAudit(c, {
tenantId: tenant.id,
eventType: "provision",
payload: {
workosOrgId,
workosUserId,
email,
role,
createdTenant,
createdUser,
},
userId: user.id,
});
return { role, created: true };
});
if (membership.created) {
return {
tenantId: tenant.id,
userId: user.id,
role,
role: membership.role,
createdTenant,
createdUser,
};
}
// Both exist + membership exists → idempotent. No audit (nothing changed).
// Both exist + membership exists -> idempotent. No audit (nothing changed).
return {
tenantId: tenant.id,
userId: user.id,
@@ -123,7 +133,6 @@ export async function provisionTenant(
createdUser: false,
};
}
async function resolveTenantByOrg(db: ProvisionDb, workosOrgId: string): Promise<TenantRow | null> {
const res = await db.query<TenantRow>(
"SELECT id, name FROM tenants WHERE workos_org_id = $1",
@@ -0,0 +1,65 @@
-- 0004_rls_scope_hardening.sql - RLS tenant-scope hardening (coreci-chat#13).
--
-- The tenant-scoped policies created by 0001_init.sql and 0003_mcp_adapters.sql
-- cast the session's tenant scope straight to uuid:
--
-- USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
--
-- `current_setting(name, missing_ok := true)` returns NULL when the setting was
-- never set in this session - but it returns an EMPTY STRING, not NULL, once
-- the session holds a placeholder for it. Postgres creates that placeholder the
-- first time a transaction-local `set_config('app.tenant_id', ..., true)` runs
-- (that is withTenant), and when that transaction ends the placeholder's value
-- is ''. So on any pooled connection that has already run a withTenant
-- transaction, the NEXT statement that touches a tenant-scoped table casts ''
-- to uuid and the database raises:
--
-- invalid input syntax for type uuid: ""
--
-- That is the crash the control-plane dashboard suite hit under real Postgres
-- (run 55595, job 57855: 16 assertions, provisioning.ts:177 via :84). PGlite
-- never showed it because PGlite 0.5.7 does not enforce RLS on SELECT, so the
-- policy expression is never evaluated there.
--
-- The fix keeps the documented semantics ("a query outside withTenant sees
-- zero tenant-scoped rows") and removes the crash: nullif(..., '') maps the
-- empty scope to NULL, so the comparison is NULL - not true - and the row is
-- filtered out instead of the statement dying.
--
-- DROP + CREATE keeps this file idempotent: re-running it replaces the
-- policies instead of failing on "policy already exists". The migrator role
-- owns the tables, so it can drop and recreate them.
--
-- Cross-tenant behaviour is unchanged: a row whose tenant_id differs from the
-- session's scope still fails the WITH CHECK clause - the R-009 pen test
-- asserts exactly that.
DROP POLICY IF EXISTS tenant_isolation_tenant_memberships ON tenant_memberships;
CREATE POLICY tenant_isolation_tenant_memberships ON tenant_memberships
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS tenant_isolation_invitations ON invitations;
CREATE POLICY tenant_isolation_invitations ON invitations
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS tenant_isolation_targets ON targets;
CREATE POLICY tenant_isolation_targets ON targets
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS tenant_isolation_byom_endpoints ON byom_endpoints;
CREATE POLICY tenant_isolation_byom_endpoints ON byom_endpoints
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS tenant_isolation_audit_log ON audit_log;
CREATE POLICY tenant_isolation_audit_log ON audit_log
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS tenant_isolation_mcp_adapters ON mcp_adapters;
CREATE POLICY tenant_isolation_mcp_adapters ON mcp_adapters
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
+14 -7
View File
@@ -37,6 +37,16 @@ BEGIN
END IF;
END $$;
-- Create the CI database FIRST (the service container's default is `postgres`;
-- the migrations and the test run happen in `coreci_ci`). The grants below are
-- per-database, so they have to be applied in the database the tests use.
SELECT 'CREATE DATABASE coreci_ci OWNER migrator'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'coreci_ci')\gexec
-- Switch to that database. The script connects to `postgres` first (the roles
-- are cluster-level and the database does not exist yet); schema and table
-- privileges granted in `postgres` do not reach the tables in `coreci_ci`.
\connect coreci_ci
-- Grant schema + table privileges. The migrator creates tables; the app
-- role gets DML (INSERT/SELECT/UPDATE/DELETE) on the tables it owns. RLS
-- policies enforce tenant scoping (the WITH CHECK clause blocks cross-tenant
@@ -47,9 +57,11 @@ GRANT CREATE ON SCHEMA public TO migrator;
-- The app role gets DML on all current + future tables in public. The
-- migrations CREATE TABLE with no explicit owner (migrator owns them); the
-- app role connects and queries/inserts under RLS.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
-- The default privileges are set FOR ROLE migrator: set as another
-- role they would not apply to the tables the migrations create.
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO coreci_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT USAGE ON SEQUENCES TO coreci_app;
-- For tables created by migrations BEFORE this grant took effect, apply
@@ -64,8 +76,3 @@ BEGIN
END LOOP;
END $$;
-- Create the CI database (the service container's default is `postgres`;
-- the CI workflow creates a separate `coreci_ci` database for the test run).
-- This is optional — the workflow may set DATABASE_URL to point at any DB.
SELECT 'CREATE DATABASE coreci_ci OWNER migrator'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'coreci_ci')\gexec
+52 -1
View File
@@ -49,13 +49,44 @@ export async function createDb(opts: CreateDbOptions = {}): Promise<DbClient> {
client.release();
}
},
// One connection for the whole transaction (coreci-chat#14): a pool
// hands out a different connection per statement, so a transaction-local
// setting (the tenant scope SET LOCAL app.tenant_id) would otherwise
// be set on one connection and read on another.
async transaction<T>(fn: (client: DbClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
const tx: DbClient = {
async query<R = Record<string, unknown>>(text: string, params?: unknown[]) {
const res = await client.query(text, params);
return { rows: res.rows as R[], rowCount: res.rowCount ?? 0 };
},
async exec(text: string) {
await client.query(text);
},
};
try {
await client.query("BEGIN");
const out = await fn(tx);
await client.query("COMMIT");
return out;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
/* ignore rollback failure; the original error is more important */
}
throw err;
} finally {
client.release();
}
},
};
}
// PGlite (dev/test). Real Postgres in WASM, supports RLS + triggers.
const { PGlite } = await import("@electric-sql/pglite");
const pg = await PGlite.create();
return {
const single: DbClient = {
async query<T = Record<string, unknown>>(text: string, params?: unknown[]) {
const res = await pg.query(text, params);
return { rows: res.rows as T[], rowCount: (res.affectedRows ?? res.rows.length) };
@@ -64,4 +95,24 @@ export async function createDb(opts: CreateDbOptions = {}): Promise<DbClient> {
await pg.exec(text);
},
};
return {
...single,
// PGlite is a single in-process connection: the transaction and the
// tenant scope always land on it.
async transaction<T>(fn: (client: DbClient) => Promise<T>): Promise<T> {
await pg.query("BEGIN");
try {
const out = await fn(single);
await pg.query("COMMIT");
return out;
} catch (err) {
try {
await pg.query("ROLLBACK");
} catch {
/* ignore rollback failure; the original error is more important */
}
throw err;
}
},
};
}
+10
View File
@@ -16,4 +16,14 @@ export interface DbClient {
query<T = Record<string, unknown>>(text: string, params?: unknown[]): Promise<QueryResult<T>>;
/** Run multi-statement SQL (DDL, migrations). No params. Use for migrations only. */
exec(text: string): Promise<void>;
/**
* Run `fn` inside one transaction on ONE connection.
*
* A pooled client can hand out a different connection per statement, so a
* transaction-local setting (the tenant scope `SET LOCAL app.tenant_id`)
* only holds if every statement of the transaction shares a connection.
* Optional: a client that cannot pin a connection omits it, and withTenant
* keeps its own BEGIN/COMMIT path as the fallback.
*/
transaction?<T>(fn: (client: DbClient) => Promise<T>): Promise<T>;
}
+39 -16
View File
@@ -6,7 +6,9 @@
* outside withTenant() returns zero rows (the RLS policy evaluates NULL = X → false).
*
* In PGlite (single-connection), BEGIN/COMMIT work on the single connection.
* In pg (pool), withTenant acquires a dedicated client for the transaction.
* In pg (pool), withTenant runs the transaction on a dedicated client that the
* client pins for the whole transaction (`DbClient.transaction`), so the tenant
* scope and every statement of the transaction share one connection.
*
* For simplicity in M1, this implementation holds a single client (set via
* `setDbClient`). The control plane's API gateway will call setDbClient at boot.
@@ -47,23 +49,44 @@ export async function withTenant<T>(
fn: (client: ScopedClient) => Promise<T>,
): Promise<T> {
const c = client();
const scoped: ScopedClient = {
async query(text, params) {
return c.query(text, params);
},
};
try {
await c.query("BEGIN");
await c.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await fn(scoped);
await c.query("COMMIT");
return result;
} catch (err) {
try {
await c.query("ROLLBACK");
} catch {
/* ignore rollback failure; original error is more important */
if (typeof c.transaction === "function") {
// The client can pin ONE connection for the whole transaction, so the
// tenant scope (SET LOCAL) and every statement in `fn` share it. A pool
// otherwise hands out a different connection per statement, and the
// scope is then set on one connection and read on another
// (coreci-chat#14).
return await c.transaction(async (tx) => {
await tx.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
return fn({
async query(text, params) {
return tx.query(text, params);
},
});
});
}
// Fallback for a client that cannot pin a connection: the scope and the
// statements still share the client's own connection.
const scoped: ScopedClient = {
async query(text, params) {
return c.query(text, params);
},
};
try {
await c.query("BEGIN");
await c.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await fn(scoped);
await c.query("COMMIT");
return result;
} catch (err) {
try {
await c.query("ROLLBACK");
} catch {
/* ignore rollback failure; original error is more important */
}
throw err;
}
} catch (err) {
throw new TenantContextError(
`withTenant(${tenantId}) failed: ${err instanceof Error ? err.message : String(err)}`,
err,
+73 -13
View File
@@ -38,6 +38,9 @@ const T1 = "00000000-0000-0000-0000-000000000001";
const T2 = "00000000-0000-0000-0000-000000000002";
const U1 = "00000000-0000-0000-0000-000000000011";
const U2 = "00000000-0000-0000-0000-000000000012";
/** Hostnames seeded by this file. No other suite uses them. */
const T1_HOST = "t1-host";
const T2_HOST = "t2-host";
/** DB_MODE env: 'pg' → real Postgres 16 (CI); unset → PGlite (dev). [G-022] */
const isPgMode = process.env.DB_MODE === "pg";
@@ -65,9 +68,10 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
// outside a tenant scope — RLS WITH CHECK rejects it).
}
// Seed two tenants + users + memberships + one target each.
// In PG mode (coreci_app role), the tenants/users/memberships tables are
// NOT tenant-scoped (they're the bootstrap tables), so direct inserts
// work. The targets table IS tenant-scoped — seed via withTenant so the
// In PG mode (coreci_app role) the tenants and users tables are the
// bootstrap tables and are not tenant-scoped, so direct inserts work.
// tenant_memberships IS tenant-scoped (the M1 migration enables AND forces
// RLS on it) and so is targets — both are seeded via withTenant so the
// RLS WITH CHECK passes.
await db.query(
`INSERT INTO tenants (id, name) VALUES ($1,'T1'), ($2,'T2') ON CONFLICT DO NOTHING`,
@@ -77,10 +81,38 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
`INSERT INTO users (id, email) VALUES ($1,'u1@t1.test'), ($2,'u2@t2.test') ON CONFLICT DO NOTHING`,
[U1, U2],
);
await db.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role) VALUES ($1,$2,'admin'), ($3,$4,'admin') ON CONFLICT DO NOTHING`,
[T1, U1, T2, U2],
);
if (isPgMode) {
await withTenant(T1, async (c) => {
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role) VALUES ($1,$2,'admin') ON CONFLICT DO NOTHING`,
[T1, U1],
);
});
await withTenant(T2, async (c) => {
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role) VALUES ($1,$2,'admin') ON CONFLICT DO NOTHING`,
[T2, U2],
);
});
} else {
await db.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role) VALUES ($1,$2,'admin'), ($3,$4,'admin') ON CONFLICT DO NOTHING`,
[T1, U1, T2, U2],
);
}
// The seed must be idempotent. This file runs TWICE in the CI postgres
// job — once inside `pnpm test`, once in the `test:pen` step — against
// ONE database, and `targets` has no unique constraint on (tenant_id,
// hostname), so `ON CONFLICT DO NOTHING` does NOT dedupe: the second run
// would add a second 't1-host' row and the row counts below would grow.
// Delete this file's own rows first (the app role holds DELETE; RLS
// scopes the delete to the tenant).
await withTenant(T1, async (c) => {
await c.query("DELETE FROM targets WHERE hostname = $1", [T1_HOST]);
});
await withTenant(T2, async (c) => {
await c.query("DELETE FROM targets WHERE hostname = $1", [T2_HOST]);
});
// Seed targets via withTenant (RLS WITH CHECK requires the row's
// tenant_id to match the current app.tenant_id).
await withTenant(T1, async (c) => {
@@ -107,9 +139,14 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t1-host");
// The row COUNT is not this test's to assert: the CI postgres job runs
// every workspace suite against ONE database and the relay-ws suites
// register targets for these same two tenant ids. The ISOLATION property
// is what is asserted here: this tenant's own seeded row is visible,
// every visible row belongs to T1, and T2's seeded row is not among them.
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
});
it("T2 sees only T2 targets, not T1", async () => {
@@ -120,8 +157,9 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t2-host");
expect(rows.filter((r) => r.hostname === T2_HOST)).toHaveLength(1);
expect(rows.every((r) => r.tenant_id === T2)).toBe(true);
expect(rows.some((r) => r.hostname === T1_HOST)).toBe(false);
});
it("a query scoped to T1 cannot read T2's targets by ID", async () => {
@@ -135,13 +173,35 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t1-host");
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
// In prod (real Postgres), RLS would block even a bare `SELECT * FROM targets`
// without the WHERE clause. PGlite 0.5.7 doesn't enforce RLS on SELECT,
// so the application-layer WHERE is the primary enforcement in dev/test.
});
it("RLS hides the other tenant's rows from a bare SELECT (real Postgres only)", async () => {
// PGlite 0.5.7 does not enforce RLS on SELECT, so this assertion only
// runs against real Postgres. This is the R-009 property in its purest
// form: inside withTenant(T1), with NO application-layer WHERE clause,
// the DATABASE returns only T1's rows. It also proves the tenant scope
// reached the connection that ran the statement (coreci-chat#14): a
// scope set on another connection would show no rows at all.
if (!isPgMode) {
expect(true).toBe(true); // PGlite: RLS on SELECT is not enforced
return;
}
const rows = await withTenant(T1, async (c) => {
const res = await c.query<{ tenant_id: string; hostname: string }>(
"SELECT tenant_id, hostname FROM targets",
);
return res.rows;
});
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
});
it("a query OUTSIDE withTenant() has no active tenant context", async () => {
const ctx = await getTenantContext();
expect(ctx).toBe(null); // no active tenant context → prod RLS returns nothing
+128
View File
@@ -0,0 +1,128 @@
/**
* withTenant — the tenant scope must live on ONE connection (coreci-chat#14).
*
* In pg mode the client is a POOL: a pool hands out a different connection
* per statement, so `SET LOCAL app.tenant_id` (transaction-local) only holds
* if the scope and the statements share a connection. A client that can pin
* one connection exposes `transaction()`; withTenant uses it when it is there
* and keeps its BEGIN/COMMIT path as the fallback for clients that cannot.
*/
import { describe, it, expect } from "vitest";
import { setDbClient, withTenant, TenantContextError } from "../src/withTenant.js";
import type { DbClient } from "../src/db-client.js";
type Recorder = {
client: DbClient;
/** statements that went through the pooled client (where a scope gets lost) */
pooled: string[];
/** statements that ran inside the pinned transaction */
pinned: string[];
};
function recorder(withTransaction: boolean): Recorder {
const pooled: string[] = [];
const pinned: string[] = [];
const pooledClient: DbClient = {
async query<T = Record<string, unknown>>(text: string) {
pooled.push(text);
return { rows: [] as T[], rowCount: 0 };
},
async exec() {
/* not used here */
},
};
const client: DbClient = withTransaction
? {
...pooledClient,
async transaction<T>(fn: (c: DbClient) => Promise<T>): Promise<T> {
pinned.push("BEGIN");
const tx: DbClient = {
async query<R = Record<string, unknown>>(text: string) {
pinned.push(text);
return { rows: [] as R[], rowCount: 0 };
},
async exec() {
/* not used here */
},
};
try {
const out = await fn(tx);
pinned.push("COMMIT");
return out;
} catch (err) {
pinned.push("ROLLBACK");
throw err;
}
},
}
: pooledClient;
return { client, pooled, pinned };
}
describe("withTenant — tenant scope pinning (coreci-chat#14)", () => {
it("runs the scope and the statements on the transaction's connection", async () => {
const r = recorder(true);
setDbClient(r.client);
const out = await withTenant("tenant-a", async (c) => {
await c.query("SELECT 1");
return "ok";
});
expect(out).toBe("ok");
expect(r.pinned).toEqual([
"BEGIN",
"SELECT set_config('app.tenant_id', $1, true)",
"SELECT 1",
"COMMIT",
]);
// Nothing touched the pooled client: a pool statement would have taken a
// different connection and lost the scope.
expect(r.pooled).toEqual([]);
});
it("falls back to BEGIN/COMMIT when the client cannot pin a connection", async () => {
const r = recorder(false);
setDbClient(r.client);
await withTenant("tenant-a", async (c) => {
await c.query("SELECT 1");
});
expect(r.pooled).toEqual([
"BEGIN",
"SELECT set_config('app.tenant_id', $1, true)",
"SELECT 1",
"COMMIT",
]);
expect(r.pinned).toEqual([]);
});
it("rolls the transaction back and wraps the failure in TenantContextError", async () => {
const r = recorder(true);
setDbClient(r.client);
await expect(
withTenant("tenant-a", async () => {
throw new Error("boom");
}),
).rejects.toBeInstanceOf(TenantContextError);
expect(r.pinned).toContain("ROLLBACK");
});
it("rolls the fallback path back too", async () => {
const r = recorder(false);
setDbClient(r.client);
await expect(
withTenant("tenant-a", async () => {
throw new Error("boom");
}),
).rejects.toBeInstanceOf(TenantContextError);
expect(r.pooled).toContain("ROLLBACK");
});
});