objectiveai_cli/db/compartment.rs
1//! Per-plugin/tool database compartments.
2//!
3//! A compartment is a postgres ROLE + same-named SCHEMA scoped to one
4//! `(kind, owner, name, version)`. Inside its own schema the role has
5//! full DDL/DML freedom (it owns it); through the shared
6//! `objectiveai_read` group (provisioned by [`super::init`]) it has
7//! READONLY access to the base `objectiveai` schema's tables; and it
8//! has no privileges on any other compartment's schema. Postgres
9//! caveat, accepted by design: other compartments' object NAMES
10//! remain visible through `pg_catalog` — their data does not.
11//!
12//! [`ensure`] is idempotent and runs on every `tools run` /
13//! `plugins run` spawn, returning the role-specific connection URL
14//! the child receives as `OBJECTIVEAI_POSTGRES_URL`.
15//!
16//! The role's password is DERIVED — `xxh3_128(admin_password ":"
17//! role_name)` — rather than randomized per spawn: N children of the
18//! same compartment can spawn concurrently, and a rotate-on-spawn
19//! scheme would invalidate URLs already handed to in-flight
20//! siblings. Nothing is stored; if the admin password changes, the
21//! next provision's `ALTER ROLE` re-derives in stride.
22
23use super::{DbHandle, Error};
24
25/// Which spawn surface owns the compartment. Folded into the
26/// compartment name so a plugin and a tool with the same coordinates
27/// never share a namespace.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Kind {
30 Plugin,
31 Tool,
32}
33
34impl Kind {
35 fn as_str(self) -> &'static str {
36 match self {
37 Kind::Plugin => "plugin",
38 Kind::Tool => "tool",
39 }
40 }
41}
42
43/// Serializes compartment provisioning across concurrent spawns —
44/// the same one-lock-for-the-step pattern as the schema-apply lock
45/// in [`super::init`] and `tasks::claim_pending`.
46const COMPARTMENT_LOCK_KEY: i64 = 0x0C0_3B_A87_AE_57_i64;
47
48/// Ensure the compartment role + schema exist with the expected
49/// grants, and return the child's connection URL.
50pub async fn ensure(
51 handle: &DbHandle,
52 kind: Kind,
53 owner: &str,
54 name: &str,
55 version: &str,
56) -> Result<String, Error> {
57 let role = compartment_name(kind, owner, name, version);
58 let password = derived_password(&handle.admin_password, &role);
59 // The password rides as a SQL string literal (role DDL takes no
60 // bind parameters); it's hex from the derivation, but escape
61 // defensively anyway.
62 let password_literal = password.replace('\'', "''");
63 // `role` is structurally `[a-z0-9_]` from the sanitizer, so
64 // interpolating it as an identifier is injection-free. The
65 // database name is arbitrary config input — quote it.
66 let database_identifier = format!("\"{}\"", handle.database.replace('"', "\"\""));
67
68 let mut tx = handle.pool.begin().await?;
69 sqlx::query("SELECT pg_advisory_xact_lock($1)")
70 .bind(COMPARTMENT_LOCK_KEY)
71 .execute(&mut *tx)
72 .await?;
73 // 1. Role, created bare if absent (roles are cluster-wide; a
74 // sibling database may have provisioned it first)…
75 sqlx::query(&format!(
76 "DO $$ BEGIN CREATE ROLE {role}; EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
77 ))
78 .execute(&mut *tx)
79 .await?;
80 // 2. …then unconditionally normalized: login with the derived
81 // password, explicitly INHERIT (the read grants arrive via
82 // group membership), and nothing else.
83 sqlx::query(&format!(
84 "ALTER ROLE {role} LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE \
85 PASSWORD '{password_literal}'",
86 ))
87 .execute(&mut *tx)
88 .await?;
89 // 3. The compartment schema, owned by the role — ownership IS
90 // the write grant.
91 sqlx::query(&format!(
92 "CREATE SCHEMA IF NOT EXISTS {role} AUTHORIZATION {role}",
93 ))
94 .execute(&mut *tx)
95 .await?;
96 // 4. Readonly over the base tables via the shared group.
97 sqlx::query(&format!("GRANT objectiveai_read TO {role}"))
98 .execute(&mut *tx)
99 .await?;
100 // 5. Own schema first on the search path: unqualified CREATEs
101 // land in the compartment (the role owns no other schema),
102 // unqualified SELECTs fall through to the base objectiveai
103 // tables.
104 sqlx::query(&format!(
105 "ALTER ROLE {role} IN DATABASE {database_identifier} \
106 SET search_path = {role}, objectiveai",
107 ))
108 .execute(&mut *tx)
109 .await?;
110 tx.commit().await?;
111
112 Ok(format!(
113 "postgres://{role}:{password}@{address}/{database}",
114 address = handle.address,
115 database = percent_encoding::utf8_percent_encode(
116 &handle.database,
117 percent_encoding::NON_ALPHANUMERIC,
118 ),
119 ))
120}
121
122/// `{kind}_{owner}_{name}_{version}_{hash8}`, sanitized to
123/// `[a-z0-9_]` with the readable part capped at 40 chars. The hash —
124/// 8 hex chars of xxh3-64 over the RAW `kind/owner/name/version`
125/// string — disambiguates coordinates the lossy sanitizer would
126/// collide (`foo-bar` vs `foo_bar`) and must stay stable across
127/// releases (a std hasher would not be). Always fits postgres's
128/// 63-byte identifier cap and always starts with a letter (the
129/// kind).
130fn compartment_name(kind: Kind, owner: &str, name: &str, version: &str) -> String {
131 let raw = format!("{}/{owner}/{name}/{version}", kind.as_str());
132 let hash = twox_hash::XxHash3_64::oneshot(raw.as_bytes());
133 let mut readable = format!(
134 "{}_{}_{}_{}",
135 kind.as_str(),
136 sanitize(owner),
137 sanitize(name),
138 sanitize(version),
139 );
140 readable.truncate(40);
141 format!("{readable}_{:08x}", hash as u32)
142}
143
144fn sanitize(part: &str) -> String {
145 part.to_ascii_lowercase()
146 .chars()
147 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
148 .collect()
149}
150
151/// Deterministic compartment password: 32 hex chars of
152/// `xxh3_128(admin_password ":" role_name)`. Underivable without
153/// the admin password (which already owns the whole database), and
154/// stable so concurrent spawns of one compartment all mint the same
155/// working URL.
156fn derived_password(admin_password: &str, role: &str) -> String {
157 let input = format!("{admin_password}:{role}");
158 format!(
159 "{:032x}",
160 twox_hash::XxHash3_128::oneshot(input.as_bytes())
161 )
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn compartment_names_are_postgres_safe_and_distinct() {
170 let a = compartment_name(Kind::Plugin, "testorg", "test-mcp-plugin", "1.0.0");
171 let b = compartment_name(Kind::Plugin, "testorg", "test_mcp_plugin", "1.0.0");
172 let c = compartment_name(Kind::Tool, "testorg", "test-mcp-plugin", "1.0.0");
173 for n in [&a, &b, &c] {
174 assert!(n.len() <= 63, "{n} exceeds identifier cap");
175 assert!(
176 n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
177 "{n} carries non-identifier chars",
178 );
179 assert!(n.starts_with("plugin_") || n.starts_with("tool_"));
180 }
181 // Sanitization collides the readable part; the hash must not.
182 assert_ne!(a, b);
183 assert_ne!(a, c);
184 }
185
186 #[test]
187 fn long_coordinates_stay_under_the_identifier_cap() {
188 let n = compartment_name(
189 Kind::Plugin,
190 "an-extremely-long-github-organization-name",
191 "a-repository-name-that-goes-on-and-on-forever",
192 "10.20.30-beta.4",
193 );
194 assert!(n.len() <= 63, "{n} ({}) exceeds identifier cap", n.len());
195 }
196}