1use sqlx::{PgPool, Row};
20
21use crate::config::AdminConfig;
22use crate::contract_sql::{CONTRACT_SQL, CONTRACT_SQL_SHA256};
23use crate::error::{KernelError, Result};
24
25const 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TargetState {
42 Empty,
44 Initialized { contract_sha256: String },
46 Foreign { objects: Vec<String> },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum InitOutcome {
53 Initialized,
55 AlreadyInitialized,
57}
58
59#[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#[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 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 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
125pub 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 (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
154pub 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 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
191pub 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
214pub 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
246pub 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
287pub 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 sqlx::raw_sql(CONTRACT_SQL).execute(pool).await?;
330 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 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 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 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}