@facilis.dev/db 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/client.d.ts +78 -0
- package/dist/client.js +208 -0
- package/dist/files.d.ts +268 -0
- package/dist/files.js +88 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/jobs.d.ts +255 -0
- package/dist/jobs.js +64 -0
- package/dist/live-schema.d.ts +39 -0
- package/dist/live-schema.js +97 -0
- package/dist/migrator.d.ts +20 -0
- package/dist/migrator.js +66 -0
- package/dist/policy.d.ts +50 -0
- package/dist/policy.js +111 -0
- package/dist/query-console.d.ts +44 -0
- package/dist/query-console.js +173 -0
- package/dist/read-only-sql.d.ts +13 -0
- package/dist/read-only-sql.js +145 -0
- package/dist/rls.d.ts +37 -0
- package/dist/rls.js +90 -0
- package/dist/schema-digest.d.ts +97 -0
- package/dist/schema-digest.js +178 -0
- package/dist/schema-drift.d.ts +43 -0
- package/dist/schema-drift.js +176 -0
- package/dist/snapshot-readers.d.ts +3 -0
- package/dist/snapshot-readers.js +182 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +21 -0
- package/dist/sudo-audit.d.ts +60 -0
- package/dist/sudo-audit.js +45 -0
- package/dist/sudo.d.ts +54 -0
- package/dist/sudo.js +137 -0
- package/dist/table-guard.d.ts +37 -0
- package/dist/table-guard.js +228 -0
- package/dist/tasks.d.ts +312 -0
- package/dist/tasks.js +52 -0
- package/dist/tenant-column.d.ts +21 -0
- package/dist/tenant-column.js +27 -0
- package/dist/views.d.ts +92 -0
- package/dist/views.js +186 -0
- package/dist/workflows.d.ts +289 -0
- package/dist/workflows.js +55 -0
- package/package.json +43 -4
- package/src/client.ts +291 -0
- package/src/files.ts +124 -0
- package/src/index.ts +174 -0
- package/src/jobs.ts +76 -0
- package/src/live-schema.ts +145 -0
- package/src/migrator.ts +92 -0
- package/src/policy.ts +174 -0
- package/src/query-console.ts +215 -0
- package/src/read-only-sql.ts +156 -0
- package/src/rls.ts +109 -0
- package/src/schema-digest.ts +240 -0
- package/src/schema-drift.ts +234 -0
- package/src/snapshot-readers.ts +212 -0
- package/src/snapshot.ts +122 -0
- package/src/sudo-audit.ts +91 -0
- package/src/sudo.ts +200 -0
- package/src/table-guard.ts +296 -0
- package/src/tasks.ts +79 -0
- package/src/tenant-column.ts +29 -0
- package/src/views.ts +291 -0
- package/src/workflows.ts +76 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { FacilisDb } from "./client.ts";
|
|
4
|
+
import { MIGRATION_TRACKING_TABLE } from "./migrator.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tables this package owns for its own bookkeeping. They exist in every migrated database
|
|
8
|
+
* but never in the project schema, so they would otherwise show up as permanent drift.
|
|
9
|
+
*/
|
|
10
|
+
const INTERNAL_TABLES: ReadonlySet<string> = new Set([MIGRATION_TRACKING_TABLE]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Live database introspection (SPEC §11.1): the catalog side of the code↔live diff.
|
|
14
|
+
*
|
|
15
|
+
* Everything here reads Postgres system catalogs, never the Drizzle schema, so the
|
|
16
|
+
* snapshot describes the database the running project is actually talking to (PGlite in
|
|
17
|
+
* dev, Postgres in production). Tables are restricted to the connection's current schema:
|
|
18
|
+
* Facilis projects keep their tables in `public`, and framework-internal schemas such as
|
|
19
|
+
* `drizzle` must not show up as drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface LiveColumnSnapshot {
|
|
23
|
+
name: string;
|
|
24
|
+
/** `format_type()` rendering, e.g. `character varying(120)` or `timestamp with time zone`. */
|
|
25
|
+
type: string;
|
|
26
|
+
notNull: boolean;
|
|
27
|
+
default: string | null;
|
|
28
|
+
isPrimaryKey: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LivePolicySnapshot {
|
|
32
|
+
name: string;
|
|
33
|
+
/** SQL command the policy applies to: ALL, SELECT, INSERT, UPDATE or DELETE. */
|
|
34
|
+
command: string;
|
|
35
|
+
/** RESTRICTIVE policies are AND-ed with the permissive ones (D-008). */
|
|
36
|
+
restrictive: boolean;
|
|
37
|
+
roles: string[];
|
|
38
|
+
using: string | null;
|
|
39
|
+
withCheck: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LiveTableSnapshot {
|
|
43
|
+
name: string;
|
|
44
|
+
rlsEnabled: boolean;
|
|
45
|
+
columns: LiveColumnSnapshot[];
|
|
46
|
+
policies: LivePolicySnapshot[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LiveSchemaSnapshot {
|
|
50
|
+
tables: LiveTableSnapshot[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One row per column; table-level attributes (`relrowsecurity`) repeat on every row and
|
|
55
|
+
* are lifted onto the table when the first column is seen.
|
|
56
|
+
*/
|
|
57
|
+
const COLUMNS_QUERY = sql`
|
|
58
|
+
select
|
|
59
|
+
c.relname as table_name,
|
|
60
|
+
c.relrowsecurity as rls_enabled,
|
|
61
|
+
a.attname as column_name,
|
|
62
|
+
format_type(a.atttypid, a.atttypmod) as column_type,
|
|
63
|
+
a.attnotnull as not_null,
|
|
64
|
+
pg_get_expr(d.adbin, d.adrelid) as column_default,
|
|
65
|
+
exists (
|
|
66
|
+
select 1
|
|
67
|
+
from pg_index i
|
|
68
|
+
where i.indrelid = c.oid and i.indisprimary and a.attnum = any (i.indkey)
|
|
69
|
+
) as is_primary_key
|
|
70
|
+
from pg_class c
|
|
71
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
72
|
+
join pg_attribute a on a.attrelid = c.oid
|
|
73
|
+
left join pg_attrdef d on d.adrelid = c.oid and d.adnum = a.attnum
|
|
74
|
+
where n.nspname = current_schema()
|
|
75
|
+
and c.relkind = 'r'
|
|
76
|
+
and a.attnum > 0
|
|
77
|
+
and not a.attisdropped
|
|
78
|
+
order by c.relname, a.attnum
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
/** `pg_policies` is the catalog view over `pg_policy`; it renders each qualifier as SQL. */
|
|
82
|
+
const POLICIES_QUERY = sql`
|
|
83
|
+
select tablename, policyname, permissive, roles, cmd, qual, with_check
|
|
84
|
+
from pg_policies
|
|
85
|
+
where schemaname = current_schema()
|
|
86
|
+
order by tablename, policyname
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/** Introspect the live database behind a Facilis project. */
|
|
90
|
+
export async function liveSchemaSnapshot(db: FacilisDb): Promise<LiveSchemaSnapshot> {
|
|
91
|
+
const columnResult = await db.db.execute(COLUMNS_QUERY);
|
|
92
|
+
const tables = new Map<string, LiveTableSnapshot>();
|
|
93
|
+
for (const row of columnResult.rows) {
|
|
94
|
+
const tableName = readText(row, "table_name");
|
|
95
|
+
const columnName = readText(row, "column_name");
|
|
96
|
+
if (tableName === null || columnName === null || INTERNAL_TABLES.has(tableName)) continue;
|
|
97
|
+
const table = tableFor(tables, tableName);
|
|
98
|
+
table.rlsEnabled = row["rls_enabled"] === true;
|
|
99
|
+
table.columns.push({
|
|
100
|
+
name: columnName,
|
|
101
|
+
type: readText(row, "column_type") ?? "",
|
|
102
|
+
notNull: row["not_null"] === true,
|
|
103
|
+
default: readText(row, "column_default"),
|
|
104
|
+
isPrimaryKey: row["is_primary_key"] === true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const policyResult = await db.db.execute(POLICIES_QUERY);
|
|
109
|
+
for (const row of policyResult.rows) {
|
|
110
|
+
const tableName = readText(row, "tablename");
|
|
111
|
+
const policyName = readText(row, "policyname");
|
|
112
|
+
if (tableName === null || policyName === null || INTERNAL_TABLES.has(tableName)) continue;
|
|
113
|
+
tableFor(tables, tableName).policies.push({
|
|
114
|
+
name: policyName,
|
|
115
|
+
command: (readText(row, "cmd") ?? "ALL").toUpperCase(),
|
|
116
|
+
restrictive: (readText(row, "permissive") ?? "").toUpperCase() === "RESTRICTIVE",
|
|
117
|
+
roles: readTextArray(row, "roles"),
|
|
118
|
+
using: readText(row, "qual"),
|
|
119
|
+
withCheck: readText(row, "with_check"),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
tables: [...tables.values()].toSorted((left, right) => left.name.localeCompare(right.name)),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function tableFor(tables: Map<string, LiveTableSnapshot>, name: string): LiveTableSnapshot {
|
|
129
|
+
const existing = tables.get(name);
|
|
130
|
+
if (existing !== undefined) return existing;
|
|
131
|
+
const table: LiveTableSnapshot = { name, rlsEnabled: false, columns: [], policies: [] };
|
|
132
|
+
tables.set(name, table);
|
|
133
|
+
return table;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readText(row: Record<string, unknown>, key: string): string | null {
|
|
137
|
+
const value = row[key];
|
|
138
|
+
return typeof value === "string" ? value : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readTextArray(row: Record<string, unknown>, key: string): string[] {
|
|
142
|
+
const value = row[key];
|
|
143
|
+
if (!Array.isArray(value)) return [];
|
|
144
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
145
|
+
}
|
package/src/migrator.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
import type { FacilisDb } from "./client.ts";
|
|
7
|
+
|
|
8
|
+
/** drizzle-kit separates statements in generated SQL files with this marker. */
|
|
9
|
+
const STATEMENT_BREAKPOINT = "--> statement-breakpoint";
|
|
10
|
+
|
|
11
|
+
/** Bookkeeping table owned by this package, not by the project schema: introspection skips it. */
|
|
12
|
+
export const MIGRATION_TRACKING_TABLE = "_facilis_applied_statements";
|
|
13
|
+
|
|
14
|
+
export type MigrationSource = string | readonly string[] | { readonly directory: string };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Applies statements in order inside one transaction. Statements are keyed by content
|
|
18
|
+
* hash and skipped when already applied, so bootstrapping a persistent dataDir twice is
|
|
19
|
+
* safe; a failure rolls back both the statements and the tracking rows.
|
|
20
|
+
*
|
|
21
|
+
* Array entries may either be one SQL statement or a whole file separated by drizzle-kit's
|
|
22
|
+
* "--> statement-breakpoint" marker (plain semicolons are never used as separators).
|
|
23
|
+
*/
|
|
24
|
+
export async function applyStatements(db: FacilisDb, statements: readonly string[]): Promise<void> {
|
|
25
|
+
await db.db.transaction(async (tx) => {
|
|
26
|
+
await tx.execute(
|
|
27
|
+
sql.raw(
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATION_TRACKING_TABLE} (id text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
for (const statement of statements.flatMap(splitStatementBatch)) {
|
|
33
|
+
const trimmed = statement.trim();
|
|
34
|
+
if (trimmed.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
// no-await-in-loop: statements must run one at a time on the transaction's connection.
|
|
37
|
+
// eslint-disable-next-line no-await-in-loop
|
|
38
|
+
const hashed = await tx.execute(sql`select md5(${trimmed}) as id`);
|
|
39
|
+
const row = hashed.rows[0];
|
|
40
|
+
if (row === undefined || typeof row.id !== "string") {
|
|
41
|
+
throw new Error("applyStatements: failed to hash a migration statement");
|
|
42
|
+
}
|
|
43
|
+
const id = row.id;
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line no-await-in-loop
|
|
46
|
+
const seen = await tx.execute(
|
|
47
|
+
sql`select 1 from ${sql.identifier(MIGRATION_TRACKING_TABLE)} where id = ${id}`,
|
|
48
|
+
);
|
|
49
|
+
if (seen.rows.length > 0) continue;
|
|
50
|
+
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop
|
|
52
|
+
await tx.execute(sql.raw(trimmed));
|
|
53
|
+
// eslint-disable-next-line no-await-in-loop
|
|
54
|
+
await tx.execute(
|
|
55
|
+
sql`insert into ${sql.identifier(MIGRATION_TRACKING_TABLE)} (id) values (${id})`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convenience runner for tests/examples: plain SQL text, a statement list, or a directory
|
|
63
|
+
* of drizzle-kit `.sql` files (applied in filename order).
|
|
64
|
+
*/
|
|
65
|
+
export async function applyMigrations(db: FacilisDb, source: MigrationSource): Promise<void> {
|
|
66
|
+
const statements =
|
|
67
|
+
typeof source === "string"
|
|
68
|
+
? [source]
|
|
69
|
+
: isMigrationDirectory(source)
|
|
70
|
+
? readMigrationDirectory(source.directory)
|
|
71
|
+
: source;
|
|
72
|
+
await applyStatements(db, statements);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitStatementBatch(batch: string): string[] {
|
|
76
|
+
return batch.split(STATEMENT_BREAKPOINT);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isMigrationDirectory(
|
|
80
|
+
source: string | readonly string[] | { readonly directory: string },
|
|
81
|
+
): source is { readonly directory: string } {
|
|
82
|
+
return typeof source === "object" && "directory" in source;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readMigrationDirectory(directory: string): string[] {
|
|
86
|
+
const files = readdirSync(directory, { withFileTypes: true })
|
|
87
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
|
88
|
+
.map((entry) => entry.name)
|
|
89
|
+
.toSorted();
|
|
90
|
+
|
|
91
|
+
return files.map((file) => readFileSync(join(directory, file), "utf8"));
|
|
92
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { FacilisDb, PgDatabaseLike } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
/** Non-superuser role used for policy-scoped work; RLS is bypassed by superusers (D-008). */
|
|
6
|
+
export const APP_ROLE = "facilis_app";
|
|
7
|
+
|
|
8
|
+
/** Values exported to Postgres GUCs for one policy-scoped operation (contract §4.2). */
|
|
9
|
+
export interface PolicyScope {
|
|
10
|
+
readonly tenantId?: string;
|
|
11
|
+
readonly userId?: string;
|
|
12
|
+
readonly roles?: readonly string[];
|
|
13
|
+
/** The session's expanded permissions, read by permission views (D-098). */
|
|
14
|
+
readonly permissions?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Transaction handed to `runWithPolicyScope` callbacks. */
|
|
18
|
+
export type PolicyScopedTransaction = Parameters<Parameters<PgDatabaseLike["transaction"]>[0]>[0];
|
|
19
|
+
|
|
20
|
+
/** Drizzle database or transaction accepted by every scoped query helper. */
|
|
21
|
+
export type PolicyScopedDatabase = PgDatabaseLike | PolicyScopedTransaction;
|
|
22
|
+
|
|
23
|
+
/** Thrown when the app role has not been bootstrapped yet (`ensureAppRole`). */
|
|
24
|
+
export class AppRoleUnavailableError extends Error {
|
|
25
|
+
readonly code = "app_role_unavailable";
|
|
26
|
+
|
|
27
|
+
constructor(cause: unknown) {
|
|
28
|
+
super(`App role "${APP_ROLE}" is unavailable; run ensureAppRole() after migrations.`, {
|
|
29
|
+
cause,
|
|
30
|
+
});
|
|
31
|
+
this.name = "AppRoleUnavailableError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The classification `ensureAppRole` derives from the live schema: every table in `public`
|
|
37
|
+
* is either RLS-scoped (the app role has DML and the policies decide the rows) or
|
|
38
|
+
* system-only (no table privilege at all; reachable only through the system identity).
|
|
39
|
+
* D-136 makes this the invariant: a table without RLS is never app-reachable.
|
|
40
|
+
*/
|
|
41
|
+
export interface AppRoleProvisioning {
|
|
42
|
+
readonly systemOnlyTables: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates the non-superuser role that policy-scoped queries run as and reconciles its
|
|
47
|
+
* privileges with the live schema: **RLS is the grant boundary** (D-136). A table with RLS
|
|
48
|
+
* enabled gets DML for the app role (its policies then decide the rows); a table without RLS
|
|
49
|
+
* gets no table privilege at all, so only the system identity can touch it. Sequences stay
|
|
50
|
+
* granted to both classes: USAGE can neither read nor write a table, and serial/identity
|
|
51
|
+
* columns need it for inserts. Idempotent and re-asserting: safe on every boot, and the
|
|
52
|
+
* blanket grants an older cluster carries are revoked the next time this runs.
|
|
53
|
+
*
|
|
54
|
+
* The role is `NOINHERIT` on purpose. It is a member of `facilis_system` so a deployment
|
|
55
|
+
* whose connection already is `facilis_app` can `SET ROLE` for the audited elevation
|
|
56
|
+
* (D-099); membership must not hand the request identity the system role's grants, or the
|
|
57
|
+
* system-only tables would be reachable from every handler. `SET ROLE` still works with
|
|
58
|
+
* `NOINHERIT`: only implicit inheritance of the member role's privileges is disabled.
|
|
59
|
+
*/
|
|
60
|
+
export async function ensureAppRole(db: FacilisDb): Promise<AppRoleProvisioning> {
|
|
61
|
+
const existing = await db.db.execute(sql`select 1 from pg_roles where rolname = ${APP_ROLE}`);
|
|
62
|
+
if (existing.rows.length === 0) {
|
|
63
|
+
await db.db.execute(
|
|
64
|
+
sql.raw(`CREATE ROLE ${APP_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
// Re-assert attributes in case an older dev dataDir created the role differently.
|
|
68
|
+
await db.db.execute(sql.raw(`ALTER ROLE ${APP_ROLE} NOSUPERUSER NOBYPASSRLS NOLOGIN NOINHERIT`));
|
|
69
|
+
await db.db.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${APP_ROLE}`));
|
|
70
|
+
// No table default privilege: a table a migration creates stays closed to the app role
|
|
71
|
+
// until its RLS is on and this function runs again after the migration. The REVOKE also
|
|
72
|
+
// clears the default an older version of this function installed on existing clusters.
|
|
73
|
+
await db.db.execute(
|
|
74
|
+
sql.raw(
|
|
75
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM ${APP_ROLE}`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
await db.db.execute(
|
|
79
|
+
sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${APP_ROLE}`),
|
|
80
|
+
);
|
|
81
|
+
await db.db.execute(
|
|
82
|
+
sql.raw(
|
|
83
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${APP_ROLE}`,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
// Clear every table-class privilege before re-granting: it removes the blanket grant an
|
|
87
|
+
// older cluster installed on objects the classification below does not cover (partition
|
|
88
|
+
// children, materialized and foreign tables) and on system-only tables. Views lose their
|
|
89
|
+
// grant here and get it back from applyViews, the only code that grants one (D-097).
|
|
90
|
+
await db.db.execute(
|
|
91
|
+
sql.raw(`REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM ${APP_ROLE}`),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const systemOnlyTables: string[] = [];
|
|
95
|
+
for (const table of await publicTables(db)) {
|
|
96
|
+
const identifier = sql.identifier(table.name);
|
|
97
|
+
if (table.rowSecurityEnabled) {
|
|
98
|
+
await db.db.execute(
|
|
99
|
+
sql`grant select, insert, update, delete on table ${identifier} to ${sql.identifier(APP_ROLE)}`,
|
|
100
|
+
);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Belt and braces: PUBLIC holds nothing by default, but a hand-written grant must not
|
|
104
|
+
// reopen a system-only table.
|
|
105
|
+
await db.db.execute(
|
|
106
|
+
sql`revoke all privileges on table ${identifier} from ${sql.identifier(APP_ROLE)}`,
|
|
107
|
+
);
|
|
108
|
+
await db.db.execute(sql`revoke all privileges on table ${identifier} from public`);
|
|
109
|
+
systemOnlyTables.push(table.name);
|
|
110
|
+
}
|
|
111
|
+
return { systemOnlyTables };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs `fn` in one transaction whose Postgres identity is facilis_app and whose GUCs carry
|
|
116
|
+
* the scope. GUC values travel through set_config parameters, never string concatenation.
|
|
117
|
+
*/
|
|
118
|
+
export async function runWithPolicyScope<T>(
|
|
119
|
+
db: FacilisDb,
|
|
120
|
+
scope: PolicyScope,
|
|
121
|
+
fn: (tx: PolicyScopedDatabase) => Promise<T>,
|
|
122
|
+
): Promise<T> {
|
|
123
|
+
return db.db.transaction(async (tx) => {
|
|
124
|
+
try {
|
|
125
|
+
// SET LOCAL ROLE drops back to the connecting (superuser) role at commit.
|
|
126
|
+
await tx.execute(sql.raw(`SET LOCAL ROLE ${APP_ROLE}`));
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
throw new AppRoleUnavailableError(cause);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const roles = JSON.stringify(scope.roles ?? []);
|
|
132
|
+
const permissions = JSON.stringify(scope.permissions ?? []);
|
|
133
|
+
await tx.execute(sql`
|
|
134
|
+
select
|
|
135
|
+
set_config('app.tenant_id', ${scope.tenantId ?? ""}, true),
|
|
136
|
+
set_config('app.user_id', ${scope.userId ?? ""}, true),
|
|
137
|
+
set_config('app.roles', ${roles}, true),
|
|
138
|
+
set_config('app.permissions', ${permissions}, true)
|
|
139
|
+
`);
|
|
140
|
+
|
|
141
|
+
return fn(tx);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One `public` table, as the catalog reports it (the live schema is the classification). */
|
|
146
|
+
interface PublicTable {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly rowSecurityEnabled: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The grant loop's input. The catalog, not the code schema, decides: migrations are what
|
|
153
|
+
* actually enabled RLS. Partition children are skipped — routing through a partitioned
|
|
154
|
+
* parent checks the parent's privilege, and the framework has no partitioned tables today.
|
|
155
|
+
*/
|
|
156
|
+
async function publicTables(db: FacilisDb): Promise<PublicTable[]> {
|
|
157
|
+
const result = await db.db.execute(sql`
|
|
158
|
+
select c.relname as name, c.relrowsecurity as row_security
|
|
159
|
+
from pg_class c
|
|
160
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
161
|
+
where n.nspname = 'public' and c.relkind in ('r', 'p') and not c.relispartition
|
|
162
|
+
order by c.relname
|
|
163
|
+
`);
|
|
164
|
+
const tables: PublicTable[] = [];
|
|
165
|
+
for (const row of result.rows) {
|
|
166
|
+
const name = row["name"];
|
|
167
|
+
const rowSecurityEnabled = row["row_security"];
|
|
168
|
+
if (typeof name !== "string" || typeof rowSecurityEnabled !== "boolean") {
|
|
169
|
+
throw new Error("ensureAppRole: unexpected pg_class row shape");
|
|
170
|
+
}
|
|
171
|
+
tables.push({ name, rowSecurityEnabled });
|
|
172
|
+
}
|
|
173
|
+
return tables;
|
|
174
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Read-only SQL execution and live view creation for the Studio's dev console
|
|
2
|
+
// (SPEC §11.2, D-097).
|
|
3
|
+
//
|
|
4
|
+
// This lives in @facilis.dev/db because it is SQL: AGENTS.md security rule 5 keeps raw Drizzle
|
|
5
|
+
// imports inside this package. The Nitro route owns transport and validation; this module
|
|
6
|
+
// owns the read-only transaction and the JSON-safe result shape.
|
|
7
|
+
|
|
8
|
+
import { sql } from "drizzle-orm";
|
|
9
|
+
|
|
10
|
+
import type { FacilisDb } from "./client.ts";
|
|
11
|
+
import { APP_ROLE } from "./policy.ts";
|
|
12
|
+
import { isReadOnlySql, stripTrailingSemicolon } from "./read-only-sql.ts";
|
|
13
|
+
import { grantViewSelect, ViewDefinitionError, viewDefinitionStatement } from "./views.ts";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_QUERY_LIMIT = 100;
|
|
16
|
+
export const MAX_QUERY_LIMIT = 500;
|
|
17
|
+
/** A console query must not hang the dev server; 5s is generous for a preview. */
|
|
18
|
+
export const QUERY_STATEMENT_TIMEOUT_MS = 5000;
|
|
19
|
+
|
|
20
|
+
export type QueryExecutionErrorCode =
|
|
21
|
+
"invalid_name" | "invalid_sql" | "invalid_permission" | "query_failed" | "create_failed";
|
|
22
|
+
|
|
23
|
+
/** A console operation the route reports as a 400 `{ code }` body. */
|
|
24
|
+
export class QueryExecutionError extends Error {
|
|
25
|
+
readonly code: QueryExecutionErrorCode;
|
|
26
|
+
|
|
27
|
+
constructor(code: QueryExecutionErrorCode, message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "QueryExecutionError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QueryColumn {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/** Postgres type name from pg_type (e.g. "uuid", "timestamptz"). */
|
|
37
|
+
readonly pgType: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface QueryExecution {
|
|
41
|
+
readonly columns: readonly QueryColumn[];
|
|
42
|
+
readonly rows: readonly (readonly unknown[])[];
|
|
43
|
+
readonly rowCount: number;
|
|
44
|
+
readonly elapsedMs: number;
|
|
45
|
+
readonly truncated: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PgliteField {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly dataTypeID: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run one read-only statement for the console.
|
|
55
|
+
*
|
|
56
|
+
* The prefix check is the friendly error; the read-only transaction is the boundary: a
|
|
57
|
+
* data-modifying CTE (WITH ... DELETE) passes the prefix check and is rejected by
|
|
58
|
+
* Postgres. The statement is wrapped in an outer LIMIT so a preview cannot stream the
|
|
59
|
+
* whole table into the browser, and a statement timeout keeps a runaway query from
|
|
60
|
+
* pinning the dev server.
|
|
61
|
+
*/
|
|
62
|
+
export async function runReadOnlyQuery(
|
|
63
|
+
db: FacilisDb,
|
|
64
|
+
sqlText: string,
|
|
65
|
+
limit = DEFAULT_QUERY_LIMIT,
|
|
66
|
+
): Promise<QueryExecution> {
|
|
67
|
+
if (!isReadOnlySql(sqlText)) {
|
|
68
|
+
throw new QueryExecutionError(
|
|
69
|
+
"invalid_sql",
|
|
70
|
+
"The console runs one read-only statement (SELECT, WITH, VALUES or TABLE).",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const statement = stripTrailingSemicolon(sqlText);
|
|
74
|
+
const capped = Math.min(Math.max(Math.trunc(limit), 1), MAX_QUERY_LIMIT);
|
|
75
|
+
const startedAt = performance.now();
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return await db.db.transaction(async (tx) => {
|
|
79
|
+
await tx.execute(sql.raw("set transaction read only"));
|
|
80
|
+
await tx.execute(
|
|
81
|
+
sql.raw("set local statement_timeout = '" + QUERY_STATEMENT_TIMEOUT_MS + "ms'"),
|
|
82
|
+
);
|
|
83
|
+
const result = await tx.execute(
|
|
84
|
+
sql.raw("select * from (\n" + statement + "\n) as __facilis_query limit " + (capped + 1)),
|
|
85
|
+
);
|
|
86
|
+
const fields = readFields(result);
|
|
87
|
+
if (fields.length === 0) {
|
|
88
|
+
throw new QueryExecutionError("query_failed", "The statement returned no result columns.");
|
|
89
|
+
}
|
|
90
|
+
const types = await readTypeNames(tx, fields);
|
|
91
|
+
const rows = result.rows
|
|
92
|
+
.slice(0, capped)
|
|
93
|
+
.map((row) => fields.map((field) => toJsonValue(row[field.name])));
|
|
94
|
+
return {
|
|
95
|
+
columns: fields.map((field) => ({
|
|
96
|
+
name: field.name,
|
|
97
|
+
pgType: types.get(field.dataTypeID) ?? "unknown",
|
|
98
|
+
})),
|
|
99
|
+
rows,
|
|
100
|
+
rowCount: rows.length,
|
|
101
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
102
|
+
truncated: result.rows.length > capped,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Postgres errors are plain driver objects; the console reports the reason as a code,
|
|
107
|
+
// never a 500 with a stack.
|
|
108
|
+
if (error instanceof QueryExecutionError) throw error;
|
|
109
|
+
throw new QueryExecutionError("query_failed", messageOf(error));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create (or replace) a live view from the console's SQL and grant the policy role.
|
|
115
|
+
* The Studio writes db/views/<name>.ts afterwards; this call makes the view usable
|
|
116
|
+
* immediately, before the next boot re-applies the code.
|
|
117
|
+
*/
|
|
118
|
+
export async function createLiveView(
|
|
119
|
+
db: FacilisDb,
|
|
120
|
+
name: string,
|
|
121
|
+
queryText: string,
|
|
122
|
+
permission?: string,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
let statement;
|
|
125
|
+
try {
|
|
126
|
+
statement = viewDefinitionStatement(name, queryText, permission);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof ViewDefinitionError) {
|
|
129
|
+
throw new QueryExecutionError(error.code, error.message);
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await db.db.execute(statement);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new QueryExecutionError("create_failed", messageOf(error));
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await grantViewSelect(db, name);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new QueryExecutionError(
|
|
142
|
+
"create_failed",
|
|
143
|
+
"View created, but the grant to " + APP_ROLE + " failed: " + messageOf(error),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readTypeNames(
|
|
149
|
+
tx: { execute(query: ReturnType<typeof sql.raw>): Promise<{ rows: Record<string, unknown>[] }> },
|
|
150
|
+
fields: readonly PgliteField[],
|
|
151
|
+
): Promise<Map<number, string>> {
|
|
152
|
+
const types = new Map<number, string>();
|
|
153
|
+
const oids = [...new Set(fields.map((field) => field.dataTypeID))].filter((oid) =>
|
|
154
|
+
Number.isInteger(oid),
|
|
155
|
+
);
|
|
156
|
+
if (oids.length === 0) return types;
|
|
157
|
+
const result = await tx.execute(
|
|
158
|
+
sql.raw("select oid, typname from pg_type where oid in (" + oids.join(", ") + ")"),
|
|
159
|
+
);
|
|
160
|
+
for (const row of result.rows) {
|
|
161
|
+
const oid = Number(row["oid"]);
|
|
162
|
+
const name = row["typname"];
|
|
163
|
+
if (Number.isInteger(oid) && typeof name === "string") types.set(oid, name);
|
|
164
|
+
}
|
|
165
|
+
return types;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readFields(result: unknown): PgliteField[] {
|
|
169
|
+
if (typeof result !== "object" || result === null) return [];
|
|
170
|
+
const fields = Reflect.get(result, "fields");
|
|
171
|
+
if (!Array.isArray(fields)) return [];
|
|
172
|
+
const out: PgliteField[] = [];
|
|
173
|
+
for (const entry of fields) {
|
|
174
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
175
|
+
const name = Reflect.get(entry, "name");
|
|
176
|
+
const oid = Reflect.get(entry, "dataTypeID");
|
|
177
|
+
if (typeof name === "string" && typeof oid === "number") out.push({ name, dataTypeID: oid });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* JSON-safe cell value. Postgres drivers hand back bigint, Date, Uint8Array and nested
|
|
184
|
+
* jsonb; JSON.stringify would throw on bigint and silently reshape the rest.
|
|
185
|
+
*/
|
|
186
|
+
export function toJsonValue(value: unknown): unknown {
|
|
187
|
+
if (value === null || value === undefined) return null;
|
|
188
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
189
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
190
|
+
if (typeof value === "bigint") return value.toString();
|
|
191
|
+
if (value instanceof Date) return value.toISOString();
|
|
192
|
+
if (value instanceof Uint8Array) return "\\x" + [...value].map(hexByte).join("");
|
|
193
|
+
if (Array.isArray(value)) return value.map(toJsonValue);
|
|
194
|
+
if (typeof value === "object") {
|
|
195
|
+
const out: Record<string, unknown> = {};
|
|
196
|
+
for (const [key, entry] of Object.entries(value)) out[key] = toJsonValue(entry);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
return String(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hexByte(byte: number): string {
|
|
203
|
+
return byte.toString(16).padStart(2, "0");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function messageOf(error: unknown): string {
|
|
207
|
+
if (!(error instanceof Error)) return String(error);
|
|
208
|
+
// Drizzle wraps a driver error in "Failed query: …"; the reason (e.g. "cannot execute
|
|
209
|
+
// DELETE in a read-only transaction") is in the cause and is what the console shows.
|
|
210
|
+
const cause = error.cause;
|
|
211
|
+
if (cause instanceof Error && cause.message.length > 0) {
|
|
212
|
+
return error.message + ": " + cause.message;
|
|
213
|
+
}
|
|
214
|
+
return error.message;
|
|
215
|
+
}
|