Skip to main content

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}