Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f280621b | ||
|
|
af559ca455 | ||
|
|
60f15a3576 |
No files matched your search
+47
-2
@@ -93,12 +93,15 @@ returns boolean
|
|||||||
language sql
|
language sql
|
||||||
stable
|
stable
|
||||||
security definer
|
security definer
|
||||||
set search_path = public
|
set search_path = pg_catalog
|
||||||
as $$
|
as $$
|
||||||
select exists (
|
select exists (
|
||||||
select 1 from public.spreadsheet_jobs j
|
select 1
|
||||||
|
from public.spreadsheet_jobs j
|
||||||
|
join public.spreadsheet_models m on m.id = j.model_id
|
||||||
where j.model_id = model
|
where j.model_id = model
|
||||||
and j.status in ('validating', 'ready', 'queued', 'processing')
|
and j.status in ('validating', 'ready', 'queued', 'processing')
|
||||||
|
and public.is_member(m.workspace_id)
|
||||||
)
|
)
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
@@ -208,14 +211,17 @@ create policy models_update on public.spreadsheet_models
|
|||||||
with check (public.can_manage_adapter(workspace_id));
|
with check (public.can_manage_adapter(workspace_id));
|
||||||
|
|
||||||
drop policy if exists jobs_all on public.spreadsheet_jobs;
|
drop policy if exists jobs_all on public.spreadsheet_jobs;
|
||||||
|
drop policy if exists jobs_select on public.spreadsheet_jobs;
|
||||||
create policy jobs_select on public.spreadsheet_jobs
|
create policy jobs_select on public.spreadsheet_jobs
|
||||||
for select to app_user
|
for select to app_user
|
||||||
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
||||||
|
|
||||||
|
drop policy if exists jobs_insert on public.spreadsheet_jobs;
|
||||||
create policy jobs_insert on public.spreadsheet_jobs
|
create policy jobs_insert on public.spreadsheet_jobs
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
|
||||||
|
|
||||||
|
drop policy if exists jobs_update on public.spreadsheet_jobs;
|
||||||
create policy jobs_update on public.spreadsheet_jobs
|
create policy jobs_update on public.spreadsheet_jobs
|
||||||
for update to app_user
|
for update to app_user
|
||||||
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
|
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
|
||||||
@@ -229,6 +235,7 @@ create policy sources_select on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_insert on public.spreadsheet_job_sources;
|
||||||
create policy sources_insert on public.spreadsheet_job_sources
|
create policy sources_insert on public.spreadsheet_job_sources
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (exists (
|
with check (exists (
|
||||||
@@ -236,6 +243,7 @@ create policy sources_insert on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_update on public.spreadsheet_job_sources;
|
||||||
create policy sources_update on public.spreadsheet_job_sources
|
create policy sources_update on public.spreadsheet_job_sources
|
||||||
for update to app_user
|
for update to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -247,6 +255,7 @@ create policy sources_update on public.spreadsheet_job_sources
|
|||||||
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists sources_delete on public.spreadsheet_job_sources;
|
||||||
create policy sources_delete on public.spreadsheet_job_sources
|
create policy sources_delete on public.spreadsheet_job_sources
|
||||||
for delete to app_user
|
for delete to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -263,6 +272,7 @@ create policy mappings_select on public.spreadsheet_job_mappings
|
|||||||
where s.id = job_source_id and j.user_id = public.auth_uid()
|
where s.id = job_source_id and j.user_id = public.auth_uid()
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists mappings_insert on public.spreadsheet_job_mappings;
|
||||||
create policy mappings_insert on public.spreadsheet_job_mappings
|
create policy mappings_insert on public.spreadsheet_job_mappings
|
||||||
for insert to app_user
|
for insert to app_user
|
||||||
with check (exists (
|
with check (exists (
|
||||||
@@ -271,6 +281,7 @@ create policy mappings_insert on public.spreadsheet_job_mappings
|
|||||||
where s.id = job_source_id and j.user_id = public.auth_uid()
|
where s.id = job_source_id and j.user_id = public.auth_uid()
|
||||||
));
|
));
|
||||||
|
|
||||||
|
drop policy if exists mappings_delete on public.spreadsheet_job_mappings;
|
||||||
create policy mappings_delete on public.spreadsheet_job_mappings
|
create policy mappings_delete on public.spreadsheet_job_mappings
|
||||||
for delete to app_user
|
for delete to app_user
|
||||||
using (exists (
|
using (exists (
|
||||||
@@ -282,3 +293,37 @@ create policy mappings_delete on public.spreadsheet_job_mappings
|
|||||||
grant usage on schema public to app_user;
|
grant usage on schema public to app_user;
|
||||||
grant select, insert, update, delete on all tables in schema public to app_user;
|
grant select, insert, update, delete on all tables in schema public to app_user;
|
||||||
grant usage, select on all sequences in schema public to app_user;
|
grant usage, select on all sequences in schema public to app_user;
|
||||||
|
|
||||||
|
-- schema_migrations não tem RLS. A escrita fica só com o dono que aplica as migrations.
|
||||||
|
revoke insert, update, delete, truncate, references, trigger on table public.schema_migrations from public, app_user;
|
||||||
|
|
||||||
|
do $priv$
|
||||||
|
declare
|
||||||
|
versao int := current_setting('server_version_num')::int;
|
||||||
|
escrita text := 'insert, update, delete, truncate, references, trigger';
|
||||||
|
papel text;
|
||||||
|
begin
|
||||||
|
if versao >= 170000 then
|
||||||
|
escrita := escrita || ', maintain';
|
||||||
|
execute 'revoke maintain on table public.schema_migrations from public, app_user';
|
||||||
|
end if;
|
||||||
|
foreach papel in array array['anon', 'authenticated', 'service_role', 'adaptador_web', 'adaptador_worker']
|
||||||
|
loop
|
||||||
|
if exists (select 1 from pg_catalog.pg_roles where rolname = papel) then
|
||||||
|
execute format(
|
||||||
|
'revoke %s on table public.profiles, public.workspaces, public.workspace_members, public.workspace_module_flags, public.audit_log, public.spreadsheet_adapter_settings, public.spreadsheet_models, public.spreadsheet_jobs, public.spreadsheet_job_sources, public.spreadsheet_job_mappings, public.schema_migrations from %I',
|
||||||
|
escrita,
|
||||||
|
papel
|
||||||
|
);
|
||||||
|
if papel in ('anon', 'authenticated', 'service_role') then
|
||||||
|
execute format('revoke select on table public.schema_migrations from %I', papel);
|
||||||
|
execute format(
|
||||||
|
'alter default privileges in schema public revoke %s on tables from %I',
|
||||||
|
escrita,
|
||||||
|
papel
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end
|
||||||
|
$priv$;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { EnvMap } from "@/config/env";
|
import type { EnvMap } from "@/config/env";
|
||||||
import { isDeployedEnv } from "@/config/env";
|
import { isDeployedEnv } from "@/config/env";
|
||||||
|
import { SUPABASE_ROOT_CA_2021 } from "./supabase-root-ca";
|
||||||
|
|
||||||
/** Projeto autorizado a receber migrations. Não é segredo. */
|
/** Projeto autorizado a receber migrations. Não é segredo. */
|
||||||
export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml";
|
export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml";
|
||||||
@@ -169,11 +170,22 @@ export type PostgresClientOptions = {
|
|||||||
connect_timeout: number;
|
connect_timeout: number;
|
||||||
idle_timeout: number;
|
idle_timeout: number;
|
||||||
max_lifetime: number;
|
max_lifetime: number;
|
||||||
ssl?: { rejectUnauthorized: true };
|
ssl?: { rejectUnauthorized: true; ca: string };
|
||||||
connection?: { family: 4 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Opções do driver. Em ambiente implantado o certificado é verificado sempre. */
|
/**
|
||||||
|
* Opções do driver. Em ambiente implantado o certificado é verificado sempre.
|
||||||
|
*
|
||||||
|
* `connection` no postgres.js é um parâmetro de startup, não uma opção do
|
||||||
|
* socket. Enviar `family=4` faz o PostgreSQL recusar a sessão com 42704
|
||||||
|
* antes da primeira consulta. O IPv4 continua no host do Session Pooler.
|
||||||
|
*
|
||||||
|
* O pooler apresenta uma cadeia emitida pela raiz pública Supabase Root 2021
|
||||||
|
* CA. Essa raiz não está na confiança padrão do Node, e por isso a verificação
|
||||||
|
* falha com SELF_SIGNED_CERT_IN_CHAIN. `ca` fixa somente essa raiz neste
|
||||||
|
* socket. `rejectUnauthorized` permanece verdadeiro, e o driver continua
|
||||||
|
* conferindo o hostname. A confiança padrão dos outros processos não muda.
|
||||||
|
*/
|
||||||
export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions {
|
export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions {
|
||||||
const deployed = isDeployedEnv(env);
|
const deployed = isDeployedEnv(env);
|
||||||
if (deployed) assertDeployedUrl(connectionString, purpose, env);
|
if (deployed) assertDeployedUrl(connectionString, purpose, env);
|
||||||
@@ -185,10 +197,7 @@ export function buildPostgresOptions(connectionString: string, purpose: DbPurpos
|
|||||||
idle_timeout: 20,
|
idle_timeout: 20,
|
||||||
max_lifetime: 60 * 30,
|
max_lifetime: 60 * 30,
|
||||||
};
|
};
|
||||||
if (deployed) {
|
if (deployed) options.ssl = { rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 };
|
||||||
options.ssl = { rejectUnauthorized: true };
|
|
||||||
options.connection = { family: 4 };
|
|
||||||
}
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-7
@@ -16,6 +16,8 @@ import type { Db } from "./types";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MIGRATIONS_FOLDER = "migrations";
|
const MIGRATIONS_FOLDER = "migrations";
|
||||||
|
/** Trava só durante a transação do migrate. A tabela de controle permanece id e applied_at. */
|
||||||
|
const MIGRATION_LOCK = 872341001;
|
||||||
|
|
||||||
export type MigrateOptions = {
|
export type MigrateOptions = {
|
||||||
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
|
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
|
||||||
@@ -51,27 +53,35 @@ export function assertManifestMatches(files: string[]): void {
|
|||||||
|
|
||||||
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
|
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
|
||||||
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
||||||
|
const files = await listMigrationFiles();
|
||||||
|
assertManifestMatches(files);
|
||||||
|
const sqlByFile = new Map<string, string>();
|
||||||
|
for (const file of files) {
|
||||||
|
sqlByFile.set(file, await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
await db.exec(`
|
return db.withTransaction(async (tx) => {
|
||||||
|
await tx.exec(`select pg_catalog.pg_advisory_xact_lock(${MIGRATION_LOCK})`);
|
||||||
|
await tx.exec(`
|
||||||
create table if not exists public.schema_migrations (
|
create table if not exists public.schema_migrations (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
applied_at timestamptz not null default now()
|
applied_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
const files = await listMigrationFiles();
|
const appliedRows = await tx.query<{ id: string }>("select id from public.schema_migrations");
|
||||||
assertManifestMatches(files);
|
|
||||||
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
|
|
||||||
const applied = new Set(appliedRows.map((row) => row.id));
|
const applied = new Set(appliedRows.map((row) => row.id));
|
||||||
const ran: string[] = [];
|
const ran: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (applied.has(file)) continue;
|
if (applied.has(file)) continue;
|
||||||
if (isShimMigration(file) && !applyShim) continue;
|
if (isShimMigration(file) && !applyShim) continue;
|
||||||
const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8");
|
const sql = sqlByFile.get(file);
|
||||||
await db.exec(sql);
|
if (!sql) throw new Error(`migration ausente: ${file}`);
|
||||||
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
|
await tx.exec(sql);
|
||||||
|
await tx.query("insert into public.schema_migrations (id) values ($1)", [file]);
|
||||||
ran.push(file);
|
ran.push(file);
|
||||||
}
|
}
|
||||||
return ran;
|
return ran;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
|
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
|
||||||
+23
-1
@@ -1,5 +1,5 @@
|
|||||||
import { PGlite } from "@electric-sql/pglite";
|
import { PGlite } from "@electric-sql/pglite";
|
||||||
import { createMutex, type Db, type Query, type Row, assertUuid } from "./types";
|
import { createMutex, type Db, type Query, type Row, type Tx, assertUuid } from "./types";
|
||||||
|
|
||||||
type PgClient = {
|
type PgClient = {
|
||||||
exec(sql: string): Promise<unknown>;
|
exec(sql: string): Promise<unknown>;
|
||||||
@@ -44,6 +44,28 @@ export function wrapPglite(client: PgClient): Db {
|
|||||||
close() {
|
close() {
|
||||||
return client.close();
|
return client.close();
|
||||||
},
|
},
|
||||||
|
withTransaction(fn) {
|
||||||
|
return exclusive(async () => {
|
||||||
|
await client.exec("begin");
|
||||||
|
const tx: Tx = {
|
||||||
|
exec(sql) {
|
||||||
|
return client.exec(sql).then(() => undefined);
|
||||||
|
},
|
||||||
|
async query<T = Row>(sql: string, params: unknown[] = []) {
|
||||||
|
const result = await client.query(sql, params);
|
||||||
|
return result.rows as T[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const value = await fn(tx);
|
||||||
|
await client.exec("commit");
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
await client.exec("rollback");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import type { PostgresClientOptions } from "./connection-policy";
|
import type { PostgresClientOptions } from "./connection-policy";
|
||||||
import { assertUuid, type Db, type Query, type Row } from "./types";
|
import { assertUuid, type Db, type Query, type Row, type Tx } from "./types";
|
||||||
|
|
||||||
export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
|
export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
|
||||||
const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
|
const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
|
||||||
@@ -33,5 +33,19 @@ export function createPostgresDb(connectionString: string, options?: PostgresCli
|
|||||||
async close() {
|
async close() {
|
||||||
await sql.end();
|
await sql.end();
|
||||||
},
|
},
|
||||||
|
async withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const adapter: Tx = {
|
||||||
|
async exec(text: string) {
|
||||||
|
await tx.unsafe(text);
|
||||||
|
},
|
||||||
|
async query<R = Row>(text: string, params: unknown[] = []) {
|
||||||
|
return tx.unsafe(text, params as never[]) as unknown as R[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(adapter);
|
||||||
|
});
|
||||||
|
return result as T;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* CA raiz pública do Supabase, publicada como prod-ca-2021.
|
||||||
|
* Não contém chave privada. A impressão SHA-256 é conferida nos testes.
|
||||||
|
*/
|
||||||
|
export const SUPABASE_ROOT_CA_2021 = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
|
||||||
|
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
|
||||||
|
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
|
||||||
|
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
|
||||||
|
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
|
||||||
|
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
|
||||||
|
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
|
||||||
|
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
|
||||||
|
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
|
||||||
|
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
|
||||||
|
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
|
||||||
|
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
|
||||||
|
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
|
||||||
|
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
|
||||||
|
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
|
||||||
|
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
|
||||||
|
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
|
||||||
|
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
|
||||||
|
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
|
||||||
|
o/bKiIz+Fq8=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
@@ -113,6 +113,13 @@ function postgresErrorCode(error: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Código do driver, sem a mensagem. A mensagem pode conter usuário, host ou senha. */
|
||||||
|
function unavailableIdentityCode(error: unknown): string {
|
||||||
|
const code = postgresErrorCode(error);
|
||||||
|
if (code && /^[A-Z0-9_]{2,48}$/.test(code)) return `identidade_indisponivel:${code}`;
|
||||||
|
return "identidade_indisponivel";
|
||||||
|
}
|
||||||
|
|
||||||
async function readRole(db: Db): Promise<FactRow> {
|
async function readRole(db: Db): Promise<FactRow> {
|
||||||
try {
|
try {
|
||||||
const rows = await db.withService((query) =>
|
const rows = await db.withService((query) =>
|
||||||
@@ -132,7 +139,7 @@ async function readRole(db: Db): Promise<FactRow> {
|
|||||||
return facts;
|
return facts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseGuardError) throw error;
|
if (error instanceof DatabaseGuardError) throw error;
|
||||||
throw new DatabaseGuardError("identidade_indisponivel");
|
throw new DatabaseGuardError(unavailableIdentityCode(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ export interface Query {
|
|||||||
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Tx {
|
||||||
|
exec(sql: string): Promise<void>;
|
||||||
|
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
exec(sql: string): Promise<void>;
|
exec(sql: string): Promise<void>;
|
||||||
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
|
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
|
||||||
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
|
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
|
||||||
|
/** Uma conexão, uma transação. O callback inteiro confirma ou reverte junto. */
|
||||||
|
withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { X509Certificate } from "node:crypto";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import tls from "node:tls";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
AUTHORIZED_HML_PROJECT,
|
AUTHORIZED_HML_PROJECT,
|
||||||
@@ -5,6 +10,7 @@ import {
|
|||||||
DatabaseGuardError,
|
DatabaseGuardError,
|
||||||
assertDeployedUrl,
|
assertDeployedUrl,
|
||||||
assertLocalDatabaseUrl,
|
assertLocalDatabaseUrl,
|
||||||
|
buildPostgresOptions,
|
||||||
expectedPoolerHost,
|
expectedPoolerHost,
|
||||||
resolvePoolMax,
|
resolvePoolMax,
|
||||||
scrubDbError,
|
scrubDbError,
|
||||||
@@ -12,7 +18,9 @@ import {
|
|||||||
import { readFileSync, readdirSync } from "node:fs";
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity";
|
import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity";
|
||||||
import { evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard";
|
import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
|
||||||
|
import { assertAdminTarget, evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard";
|
||||||
|
import type { Db } from "@/infra/db/types";
|
||||||
|
|
||||||
const REF = "abcdefghijabcdefghij";
|
const REF = "abcdefghijabcdefghij";
|
||||||
const REF_COM_DIGITO = "abcd1234abcd1234wxyz";
|
const REF_COM_DIGITO = "abcd1234abcd1234wxyz";
|
||||||
@@ -165,6 +173,73 @@ describe("barreira do banco de homologação", () => {
|
|||||||
expect(resolvePoolMax("web", env)).toBe(2);
|
expect(resolvePoolMax("web", env)).toBe(2);
|
||||||
expect(resolvePoolMax("worker", env)).toBe(2);
|
expect(resolvePoolMax("worker", env)).toBe(2);
|
||||||
expect(resolvePoolMax("admin", env)).toBe(1);
|
expect(resolvePoolMax("admin", env)).toBe(1);
|
||||||
|
const root = new X509Certificate(SUPABASE_ROOT_CA_2021);
|
||||||
|
expect(root.ca).toBe(true);
|
||||||
|
expect(root.fingerprint256).toBe("80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA");
|
||||||
|
expect(root.subject).toContain("CN=Supabase Root 2021 CA");
|
||||||
|
expect(SUPABASE_ROOT_CA_2021).not.toContain("PRIVATE KEY");
|
||||||
|
const deployed = buildPostgresOptions(adminUrl(), "admin", env);
|
||||||
|
const production = buildPostgresOptions(adminUrl(), "admin", { ...env, ADAPTER_ENV: "production" });
|
||||||
|
for (const options of [deployed, production]) {
|
||||||
|
expect(options.ssl).toEqual({ rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 });
|
||||||
|
expect(options).not.toHaveProperty("connection");
|
||||||
|
expect(JSON.stringify(options)).not.toContain("family");
|
||||||
|
expect(JSON.stringify(options)).not.toContain("rejectUnauthorized\":false");
|
||||||
|
}
|
||||||
|
const local = buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", {
|
||||||
|
ADAPTER_ENV: "development",
|
||||||
|
});
|
||||||
|
expect(local).not.toHaveProperty("connection");
|
||||||
|
expect(local.ssl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CA pública não libera outro destino nem aceita certificado divergente", async () => {
|
||||||
|
expect(() =>
|
||||||
|
buildPostgresOptions(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env),
|
||||||
|
).toThrow(/destino_incompativel/);
|
||||||
|
expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF })).toThrow(/init_nao_autorizada/);
|
||||||
|
const options = buildPostgresOptions(adminUrl(), "admin", env);
|
||||||
|
expect(options.ssl).not.toHaveProperty("checkServerIdentity");
|
||||||
|
expect(options.ssl).not.toHaveProperty("servername");
|
||||||
|
expect(options.ssl?.rejectUnauthorized).toBe(true);
|
||||||
|
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "ca-divergente-"));
|
||||||
|
const keyPath = path.join(dir, "key.pem");
|
||||||
|
const certPath = path.join(dir, "cert.pem");
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"openssl",
|
||||||
|
["req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", keyPath, "-out", certPath, "-days", "1", "-nodes", "-subj", "/CN=divergente.example"],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
const server = tls.createServer({ key: readFileSync(keyPath), cert: readFileSync(certPath) }, (socket) => socket.end());
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("porta_local_ausente");
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = tls.connect({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: address.port,
|
||||||
|
servername: "divergente.example",
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
ca: SUPABASE_ROOT_CA_2021,
|
||||||
|
});
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
socket.end();
|
||||||
|
reject(new Error("certificado_divergente_aceito"));
|
||||||
|
});
|
||||||
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
expect(error.code).toMatch(/SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
|
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
|
||||||
@@ -207,6 +282,41 @@ describe("barreira do banco de homologação", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifica a falha de conexão pelo código do driver e omite a mensagem", async () => {
|
||||||
|
const db = {
|
||||||
|
exec: async () => undefined,
|
||||||
|
close: async () => undefined,
|
||||||
|
withUser: async () => {
|
||||||
|
throw new Error("nao");
|
||||||
|
},
|
||||||
|
withService: async () => {
|
||||||
|
throw Object.assign(new Error("postgres://postgres.ref:segredo@host/db family"), { code: "42704" });
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(db, env)).rejects.toMatchObject({
|
||||||
|
code: "identidade_indisponivel:42704",
|
||||||
|
});
|
||||||
|
await expect(assertAdminTarget(db, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
|
||||||
|
const semCodigo = {
|
||||||
|
...db,
|
||||||
|
withService: async () => {
|
||||||
|
throw new Error("postgres://postgres.ref:segredo@host/db");
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(semCodigo, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
|
||||||
|
await expect(assertAdminTarget(semCodigo, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
|
||||||
|
const codigoInseguro = {
|
||||||
|
...db,
|
||||||
|
withService: async () => {
|
||||||
|
throw Object.assign(new Error("falha"), { code: "postgres://segredo" });
|
||||||
|
},
|
||||||
|
} as Db;
|
||||||
|
await expect(assertAdminTarget(codigoInseguro, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
|
||||||
|
await expect(assertAdminTarget(codigoInseguro, env)).rejects.not.toThrow(/segredo/);
|
||||||
|
});
|
||||||
|
|
||||||
it("não deixa senha nem URL no erro visível", () => {
|
it("não deixa senha nem URL no erro visível", () => {
|
||||||
const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres"));
|
const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres"));
|
||||||
expect(scrubbed).not.toContain("segredo");
|
expect(scrubbed).not.toContain("segredo");
|
||||||
|
|||||||
Reference in new issue
Block a user