Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/adapters/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,10 +413,16 @@ export class PostgresAdapter implements DatabaseAdapter {

async query<T = Record<string, unknown>>(
sqlStr: string,
_params?: unknown[],
params?: unknown[],
): Promise<T[]> {
const sql = this.db();
const rows = await sql.unsafe(sqlStr);
// postgres@3 `sql.unsafe(query, parameters)` binds values via real
// placeholders ($1, $2, ...) when `parameters` is provided. Passing
// undefined runs the query literally. This is the only safe path for
// SQL built with dynamic structure (see sampler/referential.ts).
const rows = params && params.length > 0
? await sql.unsafe(sqlStr, params as unknown as Parameters<typeof sql.unsafe>[1])
: await sql.unsafe(sqlStr);
return rows as unknown as T[];
}
}
16 changes: 14 additions & 2 deletions packages/core/src/branching/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./storage.js";
import { diffBranch } from "./diff.js";
import { loadProjectState } from "../config/loader.js";
import { quoteIdent } from "../sql/identifiers.js";
import type { Branch, BranchOptions, CheckpointInfo, DiffResult } from "./types.js";

function resolveConnector(connectorName?: string): string {
Expand Down Expand Up @@ -470,10 +471,21 @@ export async function getBranchSample(
});

try {
// `table` comes from user/agent input via `sow branch sample <branch> <table>`
// or the `sow_branch_sample` MCP tool — we cannot trust it. Identifiers
// cannot be parameterized so we quote via the SQL-standard escape. The
// limit is numeric-clamped to [0, 100] then passed as $1. A non-finite
// input (undefined, NaN) falls back to the documented default of 5.
// LIMIT 0 is legal SQL and a valid request (empty result set).
const rawLimit = typeof limit === "number" && Number.isFinite(limit)
? Math.floor(limit)
: 5;
const safeLimit = Math.min(Math.max(0, rawLimit), 100);
const rows = await sql.unsafe(
`SELECT * FROM "${table}" LIMIT ${Math.min(limit, 100)}`,
`SELECT * FROM ${quoteIdent(table)} LIMIT $1`,
[safeLimit] as unknown as Parameters<typeof sql.unsafe>[1],
);
return rows.map((r: any) => ({ ...r }));
return rows.map((r: Record<string, unknown>) => ({ ...r }));
} finally {
await sql.end();
}
Expand Down
51 changes: 38 additions & 13 deletions packages/core/src/branching/providers/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,49 @@ function extractUserIds(tables: { rows: Record<string, unknown>[] }[]): string[]
return Array.from(ids);
}

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Batch size keeps us well under Postgres's 65,535 bind-parameter limit
// and gives the query planner a reasonable prepared-statement shape.
const AUTH_FETCH_BATCH = 1000;

async function fetchAuthUserMappings(
adapter: DatabaseAdapter,
userIds: string[],
): Promise<AuthUserMapping[]> {
if (userIds.length === 0) return [];
try {
const idList = userIds.map((id) => `'${id}'`).join(",");
const rows = await adapter.query<{ id: string; email: string }>(
`SELECT id::text, email FROM auth.users WHERE id IN (${idList})`,
);
return rows.map((row) => ({
id: row.id,
email: row.email,
sanitizedEmail: transformValue(row.email, "email") as string,
}));
} catch {
return [];
// Only query for strictly UUID-shaped ids. Non-UUID values in the
// upstream heuristic (`length > 10`) would otherwise cause the whole
// batch to fail on a Postgres cast error and silently return empty.
const validIds = userIds.filter((id) => UUID_RE.test(id));
if (validIds.length === 0) return [];

const mappings: AuthUserMapping[] = [];

for (let offset = 0; offset < validIds.length; offset += AUTH_FETCH_BATCH) {
const batch = validIds.slice(offset, offset + AUTH_FETCH_BATCH);
// Build $1,$2,... placeholders and bind ids as parameters. The pre-fix
// code interpolated ids inside single-quoted literals, which a source
// row with a quote or crafted payload could escape.
const placeholders = batch.map((_, i) => `$${i + 1}`).join(",");
try {
const rows = await adapter.query<{ id: string; email: string }>(
`SELECT id::text, email FROM auth.users WHERE id IN (${placeholders})`,
batch,
);
for (const row of rows) {
mappings.push({
id: row.id,
email: row.email,
sanitizedEmail: transformValue(row.email, "email") as string,
});
}
} catch {
// A single batch failure shouldn't nuke the others — continue.
}
}

return mappings;
}

export class SupabaseBranchProvider implements BranchProvider {
Expand Down
161 changes: 118 additions & 43 deletions packages/core/src/branching/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createConnection } from "node:net";
import postgres from "postgres";
import { quoteIdent } from "../sql/identifiers.js";

const SUPABASE_DB_PORT = 54322;
const SUPABASE_API_PORT = 54321;
Expand Down Expand Up @@ -113,30 +114,66 @@ export async function loadIntoSupabase(

for (const t of tables) {
const tableName = t.tablename as string;
// tableName comes from the sandbox DB's pg_catalog. Even though we
// control the sandbox, a catalog row containing an unusual character
// (e.g. a double quote from a quoted DDL identifier in the source
// schema) must not break out of our identifier quoting.
let quotedTable: string;
let cols: { column_name: string }[];
let hasProjId: unknown[] = [];

// Per-table introspection. A transient failure here (connection blip,
// catalog contention, quoteIdent rejecting a degenerate name) skips
// this ONE table without affecting the others. Critically, this does
// NOT disable RLS on failure — unconfigured tables remain locked
// (fail-safe) rather than falling open. Callers that need write
// access to a skipped table can retry via `sow branch reset`.
try {
const cols = await sql.unsafe(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}' AND column_name IN ('user_id', 'owner_id', 'created_by')`,
);
quotedTable = quoteIdent(tableName);
cols = (await sql.unsafe(
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name IN ('user_id', 'owner_id', 'created_by')",
[tableName] as unknown as Parameters<typeof sql.unsafe>[1],
)) as { column_name: string }[];

if (cols.length === 0) {
hasProjId = await sql.unsafe(
"SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'project_id' LIMIT 1",
[tableName] as unknown as Parameters<typeof sql.unsafe>[1],
);
}
} catch {
// Introspection failed for this table — skip without touching RLS.
// This is the fail-safe branch: if we can't read the shape, we
// don't trust ourselves to configure access, and leaving RLS in
// whatever state the restore put it in is safer than disabling.
continue;
}

await sql.unsafe(`ALTER TABLE public."${tableName}" ENABLE ROW LEVEL SECURITY`);
try {
await sql.unsafe(`ALTER TABLE public.${quotedTable} ENABLE ROW LEVEL SECURITY`);

if (cols.length > 0) {
const userCol = cols[0].column_name;
await sql.unsafe(`CREATE POLICY "sow_owner" ON public."${tableName}" FOR ALL USING ("${userCol}" = auth.uid())`);
const quotedUserCol = quoteIdent(userCol);
await sql.unsafe(
`CREATE POLICY "sow_owner" ON public.${quotedTable} FOR ALL USING (${quotedUserCol} = auth.uid())`,
);
} else if (hasProjId.length > 0) {
await sql.unsafe(
`CREATE POLICY "sow_project" ON public.${quotedTable} FOR ALL USING (project_id IN (SELECT id FROM public.projects WHERE user_id = auth.uid()))`,
);
} else {
const hasProjId = await sql.unsafe(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}' AND column_name = 'project_id' LIMIT 1`,
await sql.unsafe(
`CREATE POLICY "sow_open" ON public.${quotedTable} FOR ALL USING (auth.role() = 'authenticated')`,
);

if (hasProjId.length > 0) {
await sql.unsafe(`CREATE POLICY "sow_project" ON public."${tableName}" FOR ALL USING (project_id IN (SELECT id FROM public.projects WHERE user_id = auth.uid()))`);
} else {
await sql.unsafe(`CREATE POLICY "sow_open" ON public."${tableName}" FOR ALL USING (auth.role() = 'authenticated')`);
}
}
} catch {
// Policy creation failed (e.g. conflicting existing policy). Disable
// RLS so the dev can still use the sandbox table. This is a
// dev-sandbox fallback, not a production concession — branches are
// sanitized and ephemeral.
try {
await sql.unsafe(`ALTER TABLE public."${tableName}" DISABLE ROW LEVEL SECURITY`);
await sql.unsafe(`ALTER TABLE public.${quotedTable} DISABLE ROW LEVEL SECURITY`);
} catch { /* ignore */ }
}
}
Expand Down Expand Up @@ -228,43 +265,81 @@ export async function createTestAuthUsers(

try {
for (const user of users) {
const useId = user.id
? `'${user.id}'::uuid`
: "gen_random_uuid()";

try {
// Remove existing entries for this ID or email to avoid conflicts
// Remove existing entries for this ID or email to avoid conflicts.
// user.id flows through $1 (cast server-side to uuid), not into
// the SQL text. Empty/malformed ids raise a cast error which is
// caught below, which is the intended behavior.
if (user.id) {
await sql.unsafe(`DELETE FROM auth.identities WHERE user_id = '${user.id}'`);
await sql.unsafe(`DELETE FROM auth.users WHERE id = '${user.id}'`);
await sql.unsafe(
"DELETE FROM auth.identities WHERE user_id = $1::uuid",
[user.id] as unknown as Parameters<typeof sql.unsafe>[1],
);
await sql.unsafe(
"DELETE FROM auth.users WHERE id = $1::uuid",
[user.id] as unknown as Parameters<typeof sql.unsafe>[1],
);
}

await sql.unsafe(`
INSERT INTO auth.users (
instance_id, id, aud, role, email, encrypted_password,
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at, confirmation_token, email_change,
email_change_token_new, recovery_token
) VALUES (
'00000000-0000-0000-0000-000000000000',
${useId}, 'authenticated', 'authenticated',
'${user.email}',
crypt('password123', gen_salt('bf')),
now(),
'{"provider":"email","providers":["email"]}',
'{}',
now(), now(), '', '', '', ''
)
`);

// Create matching identity for login
await sql.unsafe(`
// When user.id is empty, we want `gen_random_uuid()` as the id.
// That's SQL, not a value, so it cannot be parameterized directly.
// Branch on whether we have an id: either bind $1::uuid, or use
// the SQL function literal.
if (user.id) {
await sql.unsafe(
`
INSERT INTO auth.users (
instance_id, id, aud, role, email, encrypted_password,
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at, confirmation_token, email_change,
email_change_token_new, recovery_token
) VALUES (
'00000000-0000-0000-0000-000000000000',
$1::uuid, 'authenticated', 'authenticated',
$2,
crypt('password123', gen_salt('bf')),
now(),
'{"provider":"email","providers":["email"]}',
'{}',
now(), now(), '', '', '', ''
)
`,
[user.id, user.email] as unknown as Parameters<typeof sql.unsafe>[1],
);
} else {
await sql.unsafe(
`
INSERT INTO auth.users (
instance_id, id, aud, role, email, encrypted_password,
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at, confirmation_token, email_change,
email_change_token_new, recovery_token
) VALUES (
'00000000-0000-0000-0000-000000000000',
gen_random_uuid(), 'authenticated', 'authenticated',
$1,
crypt('password123', gen_salt('bf')),
now(),
'{"provider":"email","providers":["email"]}',
'{}',
now(), now(), '', '', '', ''
)
`,
[user.email] as unknown as Parameters<typeof sql.unsafe>[1],
);
}

// Create matching identity for login — bind the email value.
await sql.unsafe(
`
INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
SELECT gen_random_uuid(), id, id,
format('{"sub":"%s","email":"%s"}', id::text, email)::jsonb,
'email', now(), now(), now()
FROM auth.users WHERE email = '${user.email}'
`);
FROM auth.users WHERE email = $1
`,
[user.email] as unknown as Parameters<typeof sql.unsafe>[1],
);
} catch {
// Skip users that can't be created
}
Expand Down
Loading
Loading