fraiseql_cli/schema/pg_catalog.rs
1//! Live-`PostgreSQL` catalog access for the `--against-db` checks.
2//!
3//! A thin wrapper over a [`deadpool_postgres`] pool that answers the two
4//! questions the `validate --against-db` (#397) and `doctor --against-db`
5//! (#409) checks need:
6//!
7//! - **Function resolution / signature** — for a `sql_source` name, which overloads exist, what are
8//! their *input* argument types/names (in call order), and what columns does each return?
9//! (`resolve_functions`)
10//! - **PL/pgSQL body resolution** — does the `plpgsql_check` extension exist, and which managed
11//! functions call something the catalog can't resolve? (`plpgsql_check_available` /
12//! `plpgsql_check_unresolved_calls`)
13//!
14//! All catalog queries are `PostgreSQL`-specific (`pg_proc`, `pg_type`,
15//! `pg_attribute`, `plpgsql_check`); the surrounding logic in
16//! [`super::mutation_contract`] is database-agnostic and unit-tested without a
17//! connection.
18
19use anyhow::{Context, Result};
20use deadpool_postgres::Pool;
21use fraiseql_db::postgres::PostgresTlsConfig;
22
23/// A single output column of a `PostgreSQL` function's result row.
24///
25/// Populated from either the `OUT`/`TABLE` parameters of a `RETURNS TABLE(…)`
26/// function or the attributes of a composite `RETURNS <type>` — both are how
27/// FraiseQL mutation functions declare the row the server decodes into
28/// `MutationResponse`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct OutColumn {
31 /// Column name as it appears in the result row (matched against the
32 /// `MutationResponse` field names).
33 pub name: String,
34 /// `format_type` rendering of the column type (e.g. `"boolean"`,
35 /// `"jsonb"`, `"text[]"`, `"app.mutation_error_class"`).
36 pub type_name: String,
37 /// Whether the column type is a `PostgreSQL` `enum` (`pg_type.typtype = 'e'`).
38 /// Mutation functions legitimately type `error_class` as either `text` or a
39 /// project-specific enum, so the contract check accepts both.
40 pub is_enum: bool,
41}
42
43/// One overload of a `PostgreSQL` function, reduced to what the mutation-contract
44/// check needs: the *input* arguments (in positional call order, excluding
45/// `OUT`/`TABLE` columns) and the output columns.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PgFunction {
48 /// Schema the function lives in.
49 pub schema: String,
50 /// Function name (unqualified).
51 pub name: String,
52 /// Input argument types in call order (`format_type`), modes `IN`/`INOUT`/
53 /// `VARIADIC` only — these are the positions the server binds.
54 pub in_types: Vec<String>,
55 /// Input argument names in call order; `None` for unnamed positions.
56 pub in_names: Vec<Option<String>>,
57 /// Output columns of the result row (see [`OutColumn`]). Empty when the
58 /// function returns a scalar / `record` the catalog cannot expand.
59 pub out_columns: Vec<OutColumn>,
60 /// Whether the function returns a set (`RETURNS SETOF …` / `RETURNS
61 /// TABLE(…)`). Informational only.
62 pub returns_set: bool,
63 /// Implementation language (`pg_language.lanname`, e.g. `plpgsql`, `sql`,
64 /// `c`). The payload-key consumption scan (#384) only reads `plpgsql`/`sql`
65 /// bodies — for other languages `source` is a symbol, not scannable text.
66 pub language: String,
67 /// Function body (`pg_proc.prosrc`). For `plpgsql`/`sql` functions this is
68 /// the actual source text.
69 pub source: String,
70}
71
72/// A single unresolved internal call surfaced by `plpgsql_check` (#409).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BodyError {
75 /// Calling function, rendered as `schema.fn(argtypes)` (`regprocedure`).
76 pub caller: String,
77 /// Line number within the function body, when reported.
78 pub lineno: Option<i32>,
79 /// The `plpgsql_check` diagnostic message.
80 pub message: String,
81}
82
83/// A single live column of a table, as the change-log contract drift check
84/// (#380) reads it from `information_schema.columns`.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct LiveColumn {
87 /// Column name (`information_schema.columns.column_name`).
88 pub name: String,
89 /// `PostgreSQL` base type (`information_schema.columns.udt_name`): the
90 /// lower-case underlying type, e.g. `"uuid"`, `"int8"`, `"text"`, `"jsonb"`,
91 /// `"timestamptz"`, `"_text"` (the element form of `text[]`). Compared
92 /// against [`fraiseql_observers::migrations::ContractColumn::udt`].
93 pub udt_name: String,
94}
95
96/// Outcome of the PL/pgSQL body-resolution pass.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum PlpgsqlCheckOutcome {
99 /// `plpgsql_check` is not available on this server — the pass was skipped.
100 Unavailable,
101 /// The pass ran; `errors` is empty when every internal call resolved.
102 Ran {
103 /// Unresolved internal calls found.
104 errors: Vec<BodyError>,
105 },
106}
107
108/// Live Row-Level Security posture of `core.tb_entity_change_log` and whether the
109/// connecting role can read it under that posture (for the `fraiseql doctor`
110/// change-log RLS check, #437 F6 / #443).
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ChangeLogRlsStatus {
113 /// Whether `ENABLE ROW LEVEL SECURITY` is active on the table.
114 pub rls_enabled: bool,
115 /// Whether the connecting role bypasses RLS (`BYPASSRLS` or superuser).
116 pub can_bypass: bool,
117 /// Whether the connecting role owns the table (owners are exempt under `ENABLE`).
118 pub is_owner: bool,
119 /// The connecting role name (for the diagnostic message).
120 pub role_name: String,
121}
122
123/// PUBLIC-held privileges on one change-log relation, for the `fraiseql doctor`
124/// least-privilege check (#443).
125///
126/// Migration 12 `REVOKE ALL … FROM PUBLIC` keeps the change-log — every tenant's
127/// before/after payload — off the world-readable `PUBLIC` pseudo-role; a non-empty
128/// `privileges` means that REVOKE is not in force and the relation is
129/// world-accessible.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct PublicGrant {
132 /// Relation name (`tb_entity_change_log` / `v_entity_change_log` /
133 /// `v_entity_change_log_debezium`).
134 pub relname: String,
135 /// Privilege types currently granted to PUBLIC (e.g. `["SELECT"]`); empty when
136 /// the least-privilege baseline is intact.
137 pub privileges: Vec<String>,
138}
139
140/// Live actor-attribution posture of `core.tb_entity_change_log` for the
141/// `fraiseql doctor` actor check (#390).
142///
143/// The actor model stamps every mutation-runner write with a canonical
144/// [`fraiseql_core::security::ActorType`] token; migration 08's
145/// `chk_entity_change_log_actor_type` refuses anything else on new writes. This
146/// introspection reports what the *live* table actually holds, so the doctor can
147/// surface pre-constraint rogue values and unattributed rows.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ChangeLogActorStats {
150 /// Rows whose `actor_type` is NULL — pre-actor-model rows, or writes made
151 /// without an authenticated security context.
152 pub null_rows: i64,
153 /// Distinct non-NULL `actor_type` values outside the canonical token set —
154 /// evidence of a rogue writer (only possible before the CHECK constraint, or
155 /// while it is missing).
156 pub unknown_values: Vec<String>,
157 /// Whether `chk_entity_change_log_actor_type` is installed on the table.
158 pub constraint_installed: bool,
159}
160
161/// Security posture of `core.fn_entity_change_log_capture()` for the
162/// `fraiseql doctor` capture-function check (#443 / #437 F6).
163///
164/// The external-write capture function must be `SECURITY DEFINER` (so it runs as
165/// the table owner, exempt under change-log RLS) **with a pinned `search_path`** (a
166/// DEFINER function with a mutable `search_path` is a privilege-escalation vector).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CaptureFnSecurity {
169 /// Whether the function is `SECURITY DEFINER` (`pg_proc.prosecdef`).
170 pub security_definer: bool,
171 /// The pinned `search_path` value from `pg_proc.proconfig` (the text after
172 /// `search_path=`), or `None` when no `search_path` is pinned.
173 pub search_path: Option<String>,
174}
175
176/// Result of auditing `sql_source` views for the `security_invoker` requirement.
177#[derive(Debug)]
178pub struct SecurityInvokerAudit {
179 /// Whether any RLS policy exists in the database (RLS is in use at all).
180 pub rls_in_use: bool,
181 /// The audited views (by bare relname) that are NOT `security_invoker`.
182 pub views_without_invoker: Vec<String>,
183}
184
185/// A row-level security policy whose expression reads a session setting.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct PolicySettingRead {
188 /// The policy's table schema.
189 pub schema: String,
190 /// The policy's table.
191 pub table: String,
192 /// The policy's name.
193 pub policy: String,
194 /// The setting its `USING` or `WITH CHECK` expression reads.
195 pub setting: String,
196}
197
198/// A live `PostgreSQL` connection pool for catalog introspection.
199pub struct PgCatalog {
200 pool: Pool,
201}
202
203impl PgCatalog {
204 /// Read the catalogue through a pool the caller already connected.
205 #[must_use]
206 pub(crate) const fn from_pool(pool: Pool) -> Self {
207 Self { pool }
208 }
209
210 /// Connect to `db_url` (`PostgreSQL` only) for catalog introspection.
211 ///
212 /// # Errors
213 ///
214 /// Returns an error if `db_url` is not a PostgreSQL connection string (see
215 /// [`crate::connection::require_postgres`]), the server cannot be reached, or
216 /// it is older than PostgreSQL 18.
217 pub async fn connect(db_url: &str, tls: &PostgresTlsConfig) -> Result<Self> {
218 let pool = crate::connection::postgres_pool(db_url, "catalogue introspection", tls).await?;
219 Ok(Self { pool })
220 }
221
222 /// Whether the plan of `select` reads any index once sequential scans are priced out
223 /// (#1513): the question "is there an index that serves this", asked of the planner
224 /// rather than of index names, so an equivalent index under any name counts.
225 ///
226 /// Runs in a read-only transaction that is rolled back; `enable_seqscan = off` is
227 /// local to it.
228 ///
229 /// # Errors
230 ///
231 /// Returns an error if the connection fails or `select` does not plan (a missing
232 /// relation).
233 pub async fn plan_reads_an_index(&self, select: &str) -> Result<bool> {
234 let mut client = self.pool.get().await.context("failed to acquire DB connection")?;
235 let tx = client.build_transaction().read_only(true).start().await?;
236 tx.batch_execute("SET LOCAL enable_seqscan = off").await?;
237 let row = tx.query_one(&format!("EXPLAIN (FORMAT JSON) {select}"), &[]).await?;
238 let plan: serde_json::Value = row.try_get(0)?;
239 tx.rollback().await?;
240 Ok(plan_reads_index(&plan))
241 }
242
243 /// Resolve all overloads of a (possibly schema-qualified) function name.
244 ///
245 /// Unqualified names resolve against the connection's `search_path`
246 /// (`current_schemas(false)`), mirroring how the runtime resolves an
247 /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
248 /// each identifier component, so `proname` is compared verbatim.
249 ///
250 /// Returns an empty vec when no function of that name is visible.
251 ///
252 /// # Errors
253 ///
254 /// Returns an error if the connection or any catalog query fails.
255 pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
256 // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
257 // only — OUT/TABLE columns are excluded so the count matches what the
258 // server binds positionally) plus oid + proretset for follow-up.
259 const BASE: &str = "\
260 SELECT p.oid, n.nspname, p.proname, p.proretset, \
261 l.lanname, COALESCE(p.prosrc, '') AS prosrc, \
262 COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
263 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
264 WITH ORDINALITY AS a(t, ord) \
265 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
266 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
267 COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
268 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
269 WITH ORDINALITY AS a(t, ord) \
270 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
271 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
272 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
273 FROM pg_proc p \
274 JOIN pg_namespace n ON n.oid = p.pronamespace \
275 JOIN pg_language l ON l.oid = p.prolang ";
276
277 let client = self.pool.get().await.context("failed to acquire DB connection")?;
278 let (schema, name) = split_qualified(sql_source);
279
280 let rows = if let Some(schema) = schema {
281 client
282 .query(
283 &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
284 &[&schema, &name],
285 )
286 .await
287 } else {
288 client
289 .query(
290 &format!(
291 "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
292 ),
293 &[&name],
294 )
295 .await
296 }
297 .context("failed to query pg_proc for function resolution")?;
298
299 let mut functions = Vec::with_capacity(rows.len());
300 for row in rows {
301 let oid: u32 = row.get("oid");
302 let in_names: Vec<String> = row.get("in_names");
303 functions.push(PgFunction {
304 schema: row.get("nspname"),
305 name: row.get("proname"),
306 in_types: row.get("in_types"),
307 in_names: in_names
308 .into_iter()
309 .map(|n| if n.is_empty() { None } else { Some(n) })
310 .collect(),
311 out_columns: self.out_columns(oid).await?,
312 returns_set: row.get("proretset"),
313 language: row.get("lanname"),
314 source: row.get("prosrc"),
315 });
316 }
317 Ok(functions)
318 }
319
320 /// Output columns of one function (by oid).
321 ///
322 /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
323 /// function has none, falls back to the attributes of a composite return
324 /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
325 /// bare-`record` return yields an empty vec.
326 async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
327 let client = self.pool.get().await.context("failed to acquire DB connection")?;
328
329 // Path 1: OUT/TABLE output parameters.
330 let table_rows = client
331 .query(
332 "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
333 (tt.typtype = 'e') AS is_enum \
334 FROM pg_proc p, \
335 unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
336 JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
337 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
338 JOIN pg_type tt ON tt.oid = t \
339 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
340 &[&oid],
341 )
342 .await
343 .context("failed to query OUT/TABLE result columns")?;
344 if !table_rows.is_empty() {
345 return Ok(table_rows.iter().map(row_to_out_column).collect());
346 }
347
348 // Path 2: composite return type attributes.
349 let comp_rows = client
350 .query(
351 "SELECT att.attname AS name, \
352 format_type(att.atttypid, att.atttypmod) AS type_name, \
353 (at.typtype = 'e') AS is_enum \
354 FROM pg_proc p \
355 JOIN pg_type rt ON rt.oid = p.prorettype \
356 JOIN pg_attribute att ON att.attrelid = rt.typrelid \
357 AND att.attnum > 0 AND NOT att.attisdropped \
358 JOIN pg_type at ON at.oid = att.atttypid \
359 WHERE p.oid = $1 ORDER BY att.attnum",
360 &[&oid],
361 )
362 .await
363 .context("failed to query composite result columns")?;
364 Ok(comp_rows.iter().map(row_to_out_column).collect())
365 }
366
367 /// Whether the `plpgsql_check` extension is installable (present in
368 /// `pg_available_extensions`).
369 ///
370 /// # Errors
371 ///
372 /// Returns an error if the catalog query fails.
373 pub async fn plpgsql_check_available(&self) -> Result<bool> {
374 let client = self.pool.get().await.context("failed to acquire DB connection")?;
375 let row = client
376 .query_one(
377 "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
378 &[],
379 )
380 .await
381 .context("failed to probe for the plpgsql_check extension")?;
382 Ok(row.get(0))
383 }
384
385 /// The references between TVIEWs that `sql_sources` name (#1587), from `pg_tviews`'
386 /// registry: each child TVIEW's column holding a parent TVIEW's identity, proven by a
387 /// `FOREIGN KEY` and indexed (a reference whose index is gone is left out: it would not
388 /// be served by one). Both ends are matched by relation, so a plain view over a TVIEW, or
389 /// a source naming none, takes part in none. Empty when the registry has no `references`.
390 ///
391 /// # Errors
392 ///
393 /// Returns an error if the catalog query fails.
394 pub async fn tview_references(
395 &self,
396 sql_sources: &[String],
397 ) -> Result<Vec<crate::schema::native_joins::TviewReference>> {
398 let client = self.pool.get().await.context("failed to acquire DB connection")?;
399 let has_references: bool = client
400 .query_one(
401 "SELECT EXISTS (SELECT 1 FROM pg_attribute \
402 WHERE attrelid = to_regclass('tviews.registry') AND attname = 'references' \
403 AND NOT attisdropped)",
404 &[],
405 )
406 .await
407 .context("failed to probe for tviews.registry.references")?
408 .get(0);
409 let mut sql_sources = sql_sources.to_vec();
410 sql_sources.sort();
411 sql_sources.dedup();
412 if !has_references || sql_sources.is_empty() {
413 return Ok(Vec::new());
414 }
415 let rows = client
416 .query(
417 "WITH s AS (SELECT name, to_regclass(name)::oid AS rel \
418 FROM unnest($1::text[]) AS name), \
419 tv AS (SELECT to_regclass(quote_ident(g.schema) || '.' || \
420 quote_ident(g.name))::oid AS rel, \
421 g.entity, g.\"references\" AS refs \
422 FROM tviews.registry g) \
423 SELECT c.name AS child, p.name AS parent, ref.key AS foreign_key, \
424 ref.value ->> 'column' AS referenced_key \
425 FROM s c JOIN tv ct ON ct.rel = c.rel \
426 CROSS JOIN LATERAL jsonb_each(ct.refs) ref \
427 JOIN tv pt ON pt.entity = ref.value ->> 'entity' \
428 JOIN s p ON p.rel = pt.rel \
429 WHERE ref.value ->> 'index' IS NOT NULL",
430 &[&sql_sources],
431 )
432 .await
433 .context("failed to read tviews.registry.references")?;
434 Ok(rows
435 .iter()
436 .map(|row| crate::schema::native_joins::TviewReference {
437 child: row.get("child"),
438 parent: row.get("parent"),
439 foreign_key: row.get("foreign_key"),
440 referenced_key: row.get("referenced_key"),
441 })
442 .collect())
443 }
444
445 /// Whether `tviews.pg_tviews_flush_and_report(integer, boolean, boolean)`, the call a
446 /// `cascade_source = "pg_tviews"` mutation makes before its commit (#1391), resolves: a
447 /// function of that name whose first three parameters are those and whose others all
448 /// have defaults. `pg_tviews` added a defaulted fourth (`types`), so an exact signature
449 /// is not the question (#1600).
450 ///
451 /// # Errors
452 ///
453 /// Returns an error if the catalog query fails.
454 pub async fn pg_tviews_flush_available(&self) -> Result<bool> {
455 let client = self.pool.get().await.context("failed to acquire DB connection")?;
456 let row = client
457 .query_one(
458 "SELECT EXISTS (SELECT 1 FROM pg_proc p \
459 JOIN pg_namespace n ON n.oid = p.pronamespace \
460 WHERE n.nspname = 'tviews' AND p.proname = 'pg_tviews_flush_and_report' \
461 AND p.pronargs >= 3 AND p.pronargs - p.pronargdefaults <= 3 \
462 AND p.proargtypes[0] = 'integer'::regtype \
463 AND p.proargtypes[1] = 'boolean'::regtype \
464 AND p.proargtypes[2] = 'boolean'::regtype)",
465 &[],
466 )
467 .await
468 .context("failed to probe for pg_tviews_flush_and_report")?;
469 Ok(row.get(0))
470 }
471
472 /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
473 /// unresolved internal call (#409).
474 ///
475 /// Skips gracefully when `plpgsql_check` is unavailable
476 /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
477 /// (they need a relation OID to analyse), and only `error`-level
478 /// `function … does not exist` diagnostics are reported — warnings and
479 /// runtime-only temp-table references are not contract breakage.
480 ///
481 /// # Errors
482 ///
483 /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
484 /// insufficient privileges to create the extension).
485 pub async fn plpgsql_check_unresolved_calls(
486 &self,
487 schemas: &[String],
488 ) -> Result<PlpgsqlCheckOutcome> {
489 if !self.plpgsql_check_available().await? {
490 return Ok(PlpgsqlCheckOutcome::Unavailable);
491 }
492 let client = self.pool.get().await.context("failed to acquire DB connection")?;
493 client
494 .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
495 .await
496 .context("failed to CREATE EXTENSION plpgsql_check")?;
497
498 let rows = client
499 .query(
500 "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
501 FROM pg_proc p \
502 JOIN pg_namespace n ON n.oid = p.pronamespace \
503 JOIN pg_language l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
504 CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
505 WHERE n.nspname = ANY($1) \
506 AND p.prorettype <> 'trigger'::regtype \
507 AND t.level = 'error' \
508 AND t.message ~* 'function .* does not exist' \
509 ORDER BY 1, 2",
510 &[&schemas],
511 )
512 .await
513 .context("failed to run the plpgsql_check body-resolution pass")?;
514
515 let errors = rows
516 .iter()
517 .map(|row| BodyError {
518 caller: row.get("caller"),
519 lineno: row.get("lineno"),
520 message: row.get("message"),
521 })
522 .collect();
523 Ok(PlpgsqlCheckOutcome::Ran { errors })
524 }
525
526 /// Read the columns of `schema.table` from `information_schema.columns`
527 /// (name + `udt_name`), in declaration order.
528 ///
529 /// Returns an **empty** vec when the table does not exist (or is invisible to
530 /// the connecting role) — the change-log contract drift check (#380) reads
531 /// that as "table absent, the migration will install it" rather than an
532 /// error.
533 ///
534 /// # Errors
535 ///
536 /// Returns an error if the connection or the catalog query fails.
537 pub async fn table_columns(&self, schema: &str, table: &str) -> Result<Vec<LiveColumn>> {
538 let client = self.pool.get().await.context("failed to acquire DB connection")?;
539 let rows = client
540 .query(
541 "SELECT column_name, udt_name FROM information_schema.columns \
542 WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
543 &[&schema, &table],
544 )
545 .await
546 .context("failed to query information_schema.columns")?;
547 Ok(rows
548 .iter()
549 .map(|row| LiveColumn {
550 name: row.get("column_name"),
551 udt_name: row.get("udt_name"),
552 })
553 .collect())
554 }
555
556 /// Read the live RLS posture of `core.tb_entity_change_log` and whether the
557 /// connecting role can read it under that posture (#437 F6 / #443).
558 ///
559 /// Returns `None` when the table is absent (or invisible to the connecting
560 /// role) — the doctor check reads that as "not present, skipped".
561 ///
562 /// # Errors
563 ///
564 /// Returns an error if the connection or the catalog query fails.
565 pub async fn change_log_rls_status(&self) -> Result<Option<ChangeLogRlsStatus>> {
566 let client = self.pool.get().await.context("failed to acquire DB connection")?;
567 let rows = client
568 .query(
569 "SELECT c.relrowsecurity AS rls_enabled, \
570 (r.rolbypassrls OR r.rolsuper) AS can_bypass, \
571 (pg_get_userbyid(c.relowner) = current_user) AS is_owner, \
572 current_user::text AS role_name \
573 FROM pg_class c \
574 JOIN pg_namespace n ON n.oid = c.relnamespace \
575 JOIN pg_roles r ON r.rolname = current_user \
576 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log'",
577 &[],
578 )
579 .await
580 .context("failed to query change-log RLS status from pg_class/pg_roles")?;
581 Ok(rows.first().map(|row| ChangeLogRlsStatus {
582 rls_enabled: row.get("rls_enabled"),
583 can_bypass: row.get("can_bypass"),
584 is_owner: row.get("is_owner"),
585 role_name: row.get("role_name"),
586 }))
587 }
588
589 /// The row-level security policies whose `USING` or `WITH CHECK` expression calls
590 /// `current_setting` on one of `settings`, one entry per (policy, setting).
591 ///
592 /// Read from the expression text `pg_policies` deparses (`current_setting('app.x'::text,
593 /// true)`), case-insensitively. A policy that reads the setting through a function it
594 /// calls is not seen.
595 ///
596 /// # Errors
597 ///
598 /// Returns an error if the connection or the catalog query fails.
599 pub async fn policies_reading_settings(
600 &self,
601 settings: &[String],
602 ) -> Result<Vec<PolicySettingRead>> {
603 let client = self.pool.get().await.context("failed to acquire DB connection")?;
604 let rows = client
605 .query(
606 "SELECT p.schemaname::text AS schema_name, p.tablename::text AS table_name, \
607 p.policyname::text AS policy_name, s.name AS setting \
608 FROM pg_policies p CROSS JOIN unnest($1::text[]) AS s(name) \
609 WHERE strpos(lower(coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')), \
610 'current_setting(''' || lower(s.name) || '''') > 0 \
611 ORDER BY 1, 2, 3, 4",
612 &[&settings],
613 )
614 .await
615 .context("failed to read policy expressions from pg_policies")?;
616 Ok(rows
617 .iter()
618 .map(|r| PolicySettingRead {
619 schema: r.get("schema_name"),
620 table: r.get("table_name"),
621 policy: r.get("policy_name"),
622 setting: r.get("setting"),
623 })
624 .collect())
625 }
626
627 /// Audit the given `sql_source` views (bare relnames) for `security_invoker`.
628 ///
629 /// A *default* view runs with the view owner's privileges and bypasses the
630 /// caller's RLS; only a `security_invoker` view honours base-table RLS
631 /// — the requirement the cascade RLS boundary (and the query path) rely on.
632 /// Returns which audited views lack the option, plus whether any RLS policy
633 /// exists at all (the requirement only bites under RLS).
634 ///
635 /// # Errors
636 ///
637 /// Returns an error if the connection or a catalog query fails.
638 pub async fn security_invoker_audit(&self, views: &[String]) -> Result<SecurityInvokerAudit> {
639 let client = self.pool.get().await.context("failed to acquire DB connection")?;
640 let rls_in_use: bool = client
641 .query_one("SELECT EXISTS (SELECT 1 FROM pg_policies) AS in_use", &[])
642 .await
643 .context("failed to query pg_policies for RLS usage")?
644 .get("in_use");
645 let rows = client
646 .query(
647 "SELECT c.relname \
648 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
649 WHERE c.relkind = 'v' AND c.relname = ANY($1) \
650 AND NOT EXISTS ( \
651 SELECT 1 FROM pg_options_to_table(c.reloptions) o \
652 WHERE o.option_name = 'security_invoker' \
653 AND lower(o.option_value) IN ('true', 'on', '1'))",
654 &[&views],
655 )
656 .await
657 .context("failed to audit view security_invoker options from pg_class")?;
658 Ok(SecurityInvokerAudit {
659 rls_in_use,
660 views_without_invoker: rows.iter().map(|r| r.get::<_, String>("relname")).collect(),
661 })
662 }
663
664 /// Read the privileges granted to the `PUBLIC` pseudo-role on the three
665 /// change-log relations (the table + its two views) that exist in `core`
666 /// (#443).
667 ///
668 /// Returns one [`PublicGrant`] per *present* relation, listing the privileges
669 /// PUBLIC currently holds (empty when none). An empty vec means none of the
670 /// three relations exist — the doctor check reads that as "not present,
671 /// skipped". PUBLIC grants are read from `pg_class.relacl` via `aclexplode`
672 /// (the PUBLIC grantee has OID `0`); a `NULL` ACL (the default for a fresh
673 /// relation) explodes to no rows, i.e. PUBLIC holds nothing.
674 ///
675 /// # Errors
676 ///
677 /// Returns an error if the connection or the catalog query fails.
678 pub async fn change_log_public_grants(&self) -> Result<Vec<PublicGrant>> {
679 let client = self.pool.get().await.context("failed to acquire DB connection")?;
680 let rows = client
681 .query(
682 "SELECT c.relname, \
683 COALESCE( \
684 array_agg(DISTINCT a.privilege_type) FILTER (WHERE a.grantee = 0), \
685 ARRAY[]::text[]) AS public_privs \
686 FROM pg_class c \
687 JOIN pg_namespace n ON n.oid = c.relnamespace \
688 LEFT JOIN LATERAL aclexplode(c.relacl) a ON true \
689 WHERE n.nspname = 'core' \
690 AND c.relname IN ('tb_entity_change_log', 'v_entity_change_log', \
691 'v_entity_change_log_debezium') \
692 GROUP BY c.relname \
693 ORDER BY c.relname",
694 &[],
695 )
696 .await
697 .context("failed to query change-log PUBLIC grants from pg_class.relacl")?;
698 Ok(rows
699 .iter()
700 .map(|row| PublicGrant {
701 relname: row.get("relname"),
702 privileges: row.get("public_privs"),
703 })
704 .collect())
705 }
706
707 /// Read the live actor-attribution posture of `core.tb_entity_change_log`
708 /// (#390): how many rows carry no `actor_type`, which out-of-contract values
709 /// exist, and whether the domain CHECK constraint is installed.
710 ///
711 /// `valid_tokens` is the canonical roster (built from
712 /// [`fraiseql_core::security::ActorType::ALL`] by the caller). Returns `None`
713 /// when the table is absent — the doctor check reads that as "not present,
714 /// skipped".
715 ///
716 /// # Errors
717 ///
718 /// Returns an error if the connection or a catalog query fails.
719 pub async fn change_log_actor_stats(
720 &self,
721 valid_tokens: &[String],
722 ) -> Result<Option<ChangeLogActorStats>> {
723 let client = self.pool.get().await.context("failed to acquire DB connection")?;
724 let table_exists: bool = client
725 .query_one(
726 "SELECT EXISTS (SELECT 1 FROM pg_class c \
727 JOIN pg_namespace n ON n.oid = c.relnamespace \
728 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log') AS present",
729 &[],
730 )
731 .await
732 .context("failed to check for core.tb_entity_change_log in pg_class")?
733 .get("present");
734 if !table_exists {
735 return Ok(None);
736 }
737
738 let row = client
739 .query_one(
740 "SELECT \
741 (SELECT count(*) FROM core.tb_entity_change_log \
742 WHERE actor_type IS NULL) AS null_rows, \
743 (SELECT COALESCE(array_agg(DISTINCT actor_type ORDER BY actor_type), \
744 ARRAY[]::text[]) \
745 FROM core.tb_entity_change_log \
746 WHERE actor_type IS NOT NULL AND actor_type <> ALL($1)) AS unknown_values, \
747 EXISTS (SELECT 1 FROM pg_constraint con \
748 JOIN pg_class c ON c.oid = con.conrelid \
749 JOIN pg_namespace n ON n.oid = c.relnamespace \
750 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log' \
751 AND con.conname = 'chk_entity_change_log_actor_type') \
752 AS constraint_installed",
753 &[&valid_tokens],
754 )
755 .await
756 .context("failed to query change-log actor attribution stats")?;
757 Ok(Some(ChangeLogActorStats {
758 null_rows: row.get("null_rows"),
759 unknown_values: row.get("unknown_values"),
760 constraint_installed: row.get("constraint_installed"),
761 }))
762 }
763
764 /// Read the security posture of `core.fn_entity_change_log_capture()` (#443 /
765 /// #437 F6): whether it is `SECURITY DEFINER` (`pg_proc.prosecdef`) and the
766 /// pinned `search_path` from `pg_proc.proconfig`, if any.
767 ///
768 /// Returns `None` when the function is absent — the doctor check reads that as
769 /// "not present, skipped".
770 ///
771 /// # Errors
772 ///
773 /// Returns an error if the connection or the catalog query fails.
774 pub async fn capture_fn_security(&self) -> Result<Option<CaptureFnSecurity>> {
775 let client = self.pool.get().await.context("failed to acquire DB connection")?;
776 let rows = client
777 .query(
778 "SELECT p.prosecdef AS security_definer, \
779 (SELECT cfg FROM unnest(p.proconfig) AS cfg \
780 WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path_cfg \
781 FROM pg_proc p \
782 JOIN pg_namespace n ON n.oid = p.pronamespace \
783 WHERE n.nspname = 'core' AND p.proname = 'fn_entity_change_log_capture' \
784 LIMIT 1",
785 &[],
786 )
787 .await
788 .context("failed to query capture-function security from pg_proc")?;
789 Ok(rows.first().map(|row| {
790 let raw: Option<String> = row.get("search_path_cfg");
791 CaptureFnSecurity {
792 security_definer: row.get("security_definer"),
793 search_path: raw
794 .map(|s| s.strip_prefix("search_path=").unwrap_or(&s).to_string()),
795 }
796 }))
797 }
798}
799
800/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
801fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
802 OutColumn {
803 name: row.get("name"),
804 type_name: row.get("type_name"),
805 is_enum: row.get("is_enum"),
806 }
807}
808
809/// Split a possibly schema-qualified function name into `(schema, name)`.
810///
811/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
812/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
813/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
814fn split_qualified(sql_source: &str) -> (Option<String>, String) {
815 match sql_source.split_once('.') {
816 Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
817 None => (None, sql_source.to_string()),
818 }
819}
820
821/// Whether any node of an `EXPLAIN (FORMAT JSON)` plan reads an index.
822fn plan_reads_index(plan: &serde_json::Value) -> bool {
823 match plan {
824 serde_json::Value::Object(node) => {
825 node.contains_key("Index Name") || node.values().any(plan_reads_index)
826 },
827 serde_json::Value::Array(items) => items.iter().any(plan_reads_index),
828 _ => false,
829 }
830}