Skip to main content

gwk_kernel/
admin.rs

1//! One-shot initialization of a target database.
2//!
3//! `gw admin init` is the ONLY thing that runs DDL, and it runs against the
4//! schema-owner DSN. It applies the backend-neutral contract, the PostgreSQL
5//! mechanics beside it, records which contract the database now carries, and
6//! grants the already-created runtime role the narrow set of privileges the
7//! daemon needs.
8//!
9//! It refuses anything that is not an empty database (a fresh epoch starts on
10//! an empty target — no import, no backfill, no adoption), with one
11//! exception: re-running against a database this same contract already
12//! initialized is a no-op, so a retried operator command is safe.
13//!
14//! Recovery from a failed init is to drop the database and create a new one.
15//! That is deliberately the whole recovery story: a half-applied target is
16//! indistinguishable from a stranger's, and at cutover time an empty database
17//! costs one command.
18
19use sqlx::{PgPool, Row};
20
21use crate::config::AdminConfig;
22use crate::contract_sql::{CONTRACT_SQL, CONTRACT_SQL_SHA256};
23use crate::error::{KernelError, Result};
24
25/// The PostgreSQL mechanics applied beside the contract, in order.
26const BACKEND_MIGRATIONS: &[&str] = &[
27    include_str!("../migrations/0001_kernel_internal.sql"),
28    include_str!("../migrations/0002_writer.sql"),
29    include_str!("../migrations/0003_blob.sql"),
30    include_str!("../migrations/0004_checkpoint.sql"),
31];
32
33// ponytail: still no migration runner, and now for a better reason than "there
34// is only one file". `init` is all-or-nothing against an EMPTY database, so
35// there is no version ladder to walk — these are applied in order, once, in one
36// transaction, or not at all. A real migrator earns its keep the first time an
37// EXISTING database has to be upgraded in place; nothing before 1.0 does.
38
39/// What a candidate target database already contains.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TargetState {
42    /// No gwk objects and no other user objects — safe to initialize.
43    Empty,
44    /// Already carries a gwk contract. The digest says WHICH one.
45    Initialized { contract_sha256: String },
46    /// Nonempty and unrecognized. Never written to.
47    Foreign { objects: Vec<String> },
48}
49
50/// What [`init`] did.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum InitOutcome {
53    /// The contract, the backend mechanics, and the grants were applied.
54    Initialized,
55    /// This exact contract was already installed; nothing changed.
56    AlreadyInitialized,
57}
58
59/// Role-level attributes, which exist whether or not the schema does.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct RoleAttributes {
62    pub superuser: bool,
63    pub create_role: bool,
64    pub create_db: bool,
65    pub bypass_rls: bool,
66}
67
68/// Everything the daemon checks about the credential it was handed.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct RuntimePrivileges {
71    pub attributes: RoleAttributes,
72    pub can_update_event: bool,
73    pub can_delete_event: bool,
74    pub can_update_receipt: bool,
75    pub can_delete_receipt: bool,
76    pub can_create_in_gwk: bool,
77}
78
79impl RoleAttributes {
80    /// Attributes that outrank the kernel, named. Empty means safe.
81    pub fn violations(&self) -> Vec<&'static str> {
82        let mut out = Vec::new();
83        if self.superuser {
84            out.push("SUPERUSER");
85        }
86        if self.create_role {
87            out.push("CREATEROLE");
88        }
89        if self.create_db {
90            out.push("CREATEDB");
91        }
92        if self.bypass_rls {
93            out.push("BYPASSRLS");
94        }
95        out
96    }
97}
98
99impl RuntimePrivileges {
100    /// Every privilege the kernel refuses to hold, named. Empty means safe to
101    /// serve. History is append-only in the contract's own triggers too; this
102    /// is the grant-level half, so a dropped trigger is not the only thing
103    /// standing between a bug and a rewritten log.
104    pub fn violations(&self) -> Vec<&'static str> {
105        let mut out = self.attributes.violations();
106        if self.can_update_event {
107            out.push("UPDATE on gwk.event");
108        }
109        if self.can_delete_event {
110            out.push("DELETE on gwk.event");
111        }
112        if self.can_update_receipt {
113            out.push("UPDATE on gwk.receipt");
114        }
115        if self.can_delete_receipt {
116            out.push("DELETE on gwk.receipt");
117        }
118        if self.can_create_in_gwk {
119            out.push("CREATE on schema gwk");
120        }
121        out
122    }
123}
124
125/// Decide what a target database is, from facts already queried out of it.
126///
127/// Split from the queries so the decision table is testable without a server;
128/// [`inspect`] is the thin part that gathers the facts.
129pub fn classify(
130    gwk_present: bool,
131    contract_sha256: Option<String>,
132    foreign_objects: Vec<String>,
133) -> TargetState {
134    if !foreign_objects.is_empty() {
135        return TargetState::Foreign {
136            objects: foreign_objects,
137        };
138    }
139    match (gwk_present, contract_sha256) {
140        (false, None) => TargetState::Empty,
141        (true, Some(contract_sha256)) => TargetState::Initialized { contract_sha256 },
142        // Half a kernel. A crashed initialization and a stranger who happens
143        // to have named a schema `gwk` look identical from here, and either
144        // way initialization wants an empty target.
145        (true, None) => TargetState::Foreign {
146            objects: vec!["schema gwk (without a gwk_internal.schema_fingerprint row)".to_owned()],
147        },
148        (false, Some(_)) => TargetState::Foreign {
149            objects: vec!["gwk_internal.schema_fingerprint (without a gwk schema)".to_owned()],
150        },
151    }
152}
153
154/// Read what the target database already contains.
155pub async fn inspect(pool: &PgPool) -> Result<TargetState> {
156    let row = sqlx::query(
157        "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'gwk') AS gwk_present, \
158         to_regclass('gwk_internal.schema_fingerprint') IS NOT NULL AS fingerprint_table",
159    )
160    .fetch_one(pool)
161    .await?;
162    let gwk_present: bool = row.try_get("gwk_present")?;
163    let fingerprint_table: bool = row.try_get("fingerprint_table")?;
164
165    let contract_sha256: Option<String> = if fingerprint_table {
166        sqlx::query_scalar(
167            "SELECT contract_sha256 FROM gwk_internal.schema_fingerprint WHERE id = 1",
168        )
169        .fetch_optional(pool)
170        .await?
171    } else {
172        None
173    };
174
175    // Anything outside the two kernel schemas and PostgreSQL's own. Bounded:
176    // the message names a handful, it does not inventory a stranger's database.
177    let foreign_objects: Vec<String> = sqlx::query_scalar(
178        "SELECT n.nspname || '.' || c.relname \
179         FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
180         WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') \
181           AND n.nspname NOT IN ('information_schema', 'gwk', 'gwk_internal') \
182           AND n.nspname NOT LIKE 'pg\\_%' \
183         ORDER BY 1 LIMIT 20",
184    )
185    .fetch_all(pool)
186    .await?;
187
188    Ok(classify(gwk_present, contract_sha256, foreign_objects))
189}
190
191/// Read a named role's attributes. `None` when no such role exists.
192pub async fn role_attributes<'e>(
193    executor: impl sqlx::PgExecutor<'e>,
194    role: &str,
195) -> Result<Option<RoleAttributes>> {
196    let row = sqlx::query(
197        "SELECT rolsuper, rolcreaterole, rolcreatedb, rolbypassrls \
198         FROM pg_roles WHERE rolname = $1",
199    )
200    .bind(role)
201    .fetch_optional(executor)
202    .await?;
203    row.map(|row| {
204        Ok(RoleAttributes {
205            superuser: row.try_get("rolsuper")?,
206            create_role: row.try_get("rolcreaterole")?,
207            create_db: row.try_get("rolcreatedb")?,
208            bypass_rls: row.try_get("rolbypassrls")?,
209        })
210    })
211    .transpose()
212}
213
214/// Read what the CURRENT connection is allowed to do. The daemon calls this at
215/// startup and refuses to serve while [`RuntimePrivileges::violations`] is
216/// non-empty.
217pub async fn runtime_privileges<'e>(
218    executor: impl sqlx::PgExecutor<'e>,
219) -> Result<RuntimePrivileges> {
220    let row = sqlx::query(
221        "SELECT rolsuper, rolcreaterole, rolcreatedb, rolbypassrls, \
222           has_table_privilege('gwk.event', 'UPDATE')   AS upd_event, \
223           has_table_privilege('gwk.event', 'DELETE')   AS del_event, \
224           has_table_privilege('gwk.receipt', 'UPDATE') AS upd_receipt, \
225           has_table_privilege('gwk.receipt', 'DELETE') AS del_receipt, \
226           has_schema_privilege('gwk', 'CREATE')        AS create_gwk \
227         FROM pg_roles WHERE rolname = current_user",
228    )
229    .fetch_one(executor)
230    .await?;
231    Ok(RuntimePrivileges {
232        attributes: RoleAttributes {
233            superuser: row.try_get("rolsuper")?,
234            create_role: row.try_get("rolcreaterole")?,
235            create_db: row.try_get("rolcreatedb")?,
236            bypass_rls: row.try_get("rolbypassrls")?,
237        },
238        can_update_event: row.try_get("upd_event")?,
239        can_delete_event: row.try_get("del_event")?,
240        can_update_receipt: row.try_get("upd_receipt")?,
241        can_delete_receipt: row.try_get("del_receipt")?,
242        can_create_in_gwk: row.try_get("create_gwk")?,
243    })
244}
245
246/// The backend mechanics, the fingerprint row, and the runtime grants, as one
247/// script.
248///
249/// `role` is interpolated rather than bound because PostgreSQL cannot
250/// parameterize an identifier. [`crate::config::validate_role`] has already
251/// restricted it to `[a-z_][a-z0-9_]*`, which needs no quoting and cannot
252/// carry a separator, a quote, or a comment.
253///
254/// The privilege list grants exactly what the daemon does: read everything,
255/// append history, and update the projections it rebuilds. Nothing in the
256/// CONTRACT schema is deletable or truncatable, so the log never shrinks.
257/// `event`, `receipt`, and `ingested_record` are append-only and lose UPDATE —
258/// the last of those is a projection the kernel DOES rebuild, but only ever by
259/// inserting, so granting it UPDATE would widen the role for a write no code
260/// path makes. `transition` is the FSM seed the contract ships — the kernel
261/// only ever reads it, so it loses every write.
262///
263/// The blob tables are the one place DELETE is granted, and only inside
264/// `gwk_internal`: sweep reclaims unreferenced blobs, evidence pins are
265/// released, and uploads expire. None of that is history — the events that
266/// REFERENCE a blob stay in the log after its bytes are gone, which is what
267/// makes a swept or shredded blob auditable at all.
268pub fn backend_script(role: &str, contract_sha256: &str) -> String {
269    let migrations = BACKEND_MIGRATIONS.join("\n");
270    format!(
271        "{migrations}\n\
272         INSERT INTO gwk_internal.schema_fingerprint (id, contract_sha256) \
273         VALUES (1, '{contract_sha256}');\n\
274         GRANT USAGE ON SCHEMA gwk TO {role};\n\
275         GRANT USAGE ON SCHEMA gwk_internal TO {role};\n\
276         GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA gwk TO {role};\n\
277         REVOKE UPDATE ON gwk.event, gwk.receipt, gwk.ingested_record FROM {role};\n\
278         REVOKE INSERT, UPDATE ON gwk.transition FROM {role};\n\
279         GRANT SELECT ON gwk_internal.schema_fingerprint TO {role};\n\
280         GRANT SELECT, UPDATE ON gwk_internal.writer TO {role};\n\
281         GRANT SELECT, INSERT, UPDATE, DELETE ON \
282           gwk_internal.blob, gwk_internal.blob_pin, gwk_internal.blob_upload TO {role};\n\
283         GRANT SELECT, INSERT ON gwk_internal.checkpoint TO {role};\n"
284    )
285}
286
287/// Initialize `admin.admin_database_url()`'s database, or explain why not.
288pub async fn init(pool: &PgPool, admin: &AdminConfig) -> Result<InitOutcome> {
289    let role = admin.runtime_role();
290    let attributes = role_attributes(pool, role).await?.ok_or_else(|| {
291        KernelError::Privilege(format!(
292            "role {role:?} does not exist: initialization grants an already-created role and \
293             never creates one"
294        ))
295    })?;
296    let violations = attributes.violations();
297    if !violations.is_empty() {
298        return Err(KernelError::Privilege(format!(
299            "role {role:?} holds {}: the kernel refuses to run as a role that can re-grant or \
300             re-DDL its own store",
301            violations.join(", ")
302        )));
303    }
304
305    match inspect(pool).await? {
306        TargetState::Initialized { contract_sha256 } if contract_sha256 == CONTRACT_SQL_SHA256 => {
307            return Ok(InitOutcome::AlreadyInitialized);
308        }
309        TargetState::Initialized { contract_sha256 } => {
310            return Err(KernelError::Schema(format!(
311                "this database carries contract {contract_sha256}, and this binary carries \
312                 {CONTRACT_SQL_SHA256} — initialize a fresh database with the matching build"
313            )));
314        }
315        TargetState::Foreign { objects } => {
316            return Err(KernelError::Schema(format!(
317                "refusing to initialize a nonempty database this binary does not recognize; it \
318                 already contains: {}. Create a fresh empty database and point \
319                 GWK_ADMIN_DATABASE_URL at that",
320                objects.join(", ")
321            )));
322        }
323        TargetState::Empty => {}
324    }
325
326    // The contract script wraps itself in BEGIN/COMMIT, so it commits alone.
327    // Everything after it goes in a single simple-query batch, which
328    // PostgreSQL runs as one implicit transaction.
329    sqlx::raw_sql(CONTRACT_SQL).execute(pool).await?;
330    // The audit sqlx::AssertSqlSafe demands: the only runtime-substituted
331    // values in this script are `role`, restricted to `[a-z_][a-z0-9_]*` by
332    // config::validate_role, and a digest this binary computed over its own
333    // embedded DDL. Neither can carry a quote, a separator, or a comment.
334    sqlx::raw_sql(sqlx::AssertSqlSafe(backend_script(
335        role,
336        CONTRACT_SQL_SHA256,
337    )))
338    .execute(pool)
339    .await?;
340    Ok(InitOutcome::Initialized)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn an_empty_database_is_the_only_thing_init_will_write_to() {
349        assert_eq!(classify(false, None, vec![]), TargetState::Empty);
350        assert_eq!(
351            classify(true, Some("abc".to_owned()), vec![]),
352            TargetState::Initialized {
353                contract_sha256: "abc".to_owned()
354            }
355        );
356    }
357
358    #[test]
359    fn every_incoherent_or_occupied_target_is_refused() {
360        // A stranger's table, even beside a complete kernel.
361        let foreign = classify(
362            true,
363            Some("abc".to_owned()),
364            vec!["public.users".to_owned()],
365        );
366        assert_eq!(
367            foreign,
368            TargetState::Foreign {
369                objects: vec!["public.users".to_owned()]
370            }
371        );
372        // Half-applied in either direction is a refusal, not a resume.
373        for state in [
374            classify(true, None, vec![]),
375            classify(false, Some("abc".to_owned()), vec![]),
376        ] {
377            assert!(
378                matches!(state, TargetState::Foreign { .. }),
379                "expected a refusal, got {state:?}"
380            );
381        }
382    }
383
384    #[test]
385    fn a_role_that_outranks_the_kernel_names_every_reason() {
386        let clean = RoleAttributes {
387            superuser: false,
388            create_role: false,
389            create_db: false,
390            bypass_rls: false,
391        };
392        assert!(clean.violations().is_empty());
393        let all = RoleAttributes {
394            superuser: true,
395            create_role: true,
396            create_db: true,
397            bypass_rls: true,
398        };
399        assert_eq!(
400            all.violations(),
401            ["SUPERUSER", "CREATEROLE", "CREATEDB", "BYPASSRLS"]
402        );
403
404        let mut privileges = RuntimePrivileges {
405            attributes: clean,
406            can_update_event: false,
407            can_delete_event: false,
408            can_update_receipt: false,
409            can_delete_receipt: false,
410            can_create_in_gwk: false,
411        };
412        assert!(privileges.violations().is_empty());
413        privileges.can_delete_event = true;
414        privileges.can_create_in_gwk = true;
415        assert_eq!(
416            privileges.violations(),
417            ["DELETE on gwk.event", "CREATE on schema gwk"]
418        );
419    }
420
421    #[test]
422    fn the_grant_script_withholds_history_mutation_and_all_deletion() {
423        let script = backend_script("gwk_runtime", &"a".repeat(64));
424        assert!(script.contains("CREATE SCHEMA IF NOT EXISTS gwk_internal;"));
425        assert!(script.contains("VALUES (1, '"));
426        assert!(script.contains("GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA gwk"));
427        assert!(script.contains("REVOKE UPDATE ON gwk.event, gwk.receipt"));
428        assert!(script.contains("REVOKE INSERT, UPDATE ON gwk.transition"));
429        assert!(!script.contains("TRUNCATE"), "{script}");
430
431        // Deletion is granted on the blob tables and NOWHERE else. The check is
432        // spelled as "every granted object is one of these three" rather than
433        // "the blob grant is present", because the second passes just as
434        // happily while a fourth line hands out DELETE on the log.
435        let granted: Vec<&str> = script
436            .lines()
437            .filter(|line| line.starts_with("GRANT") && line.contains("DELETE"))
438            .collect();
439        assert_eq!(granted.len(), 1, "{script}");
440        for object in ["gwk_internal.blob", "gwk_internal.blob_pin"] {
441            assert!(granted[0].contains(object), "{}", granted[0]);
442        }
443        assert!(!granted[0].contains(" gwk."), "{}", granted[0]);
444    }
445}