agentmux_srv\backend\storage/
identities.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Identity subsystem — accounts, bundles, and the junctions that
5//! tie them to agents.
6//!
7//! Layered model:
8//! - **`IdentityAccount`** (`db_identity_accounts`): a single credential
9//!   pointer (provider + kind + secret_ref).
10//! - **`Identity`** (`db_identity_bundles`): a named bundle that
11//!   contains zero or more accounts via the `db_identity_bindings`
12//!   junction. The launch UI picks an Identity bundle, not raw accounts.
13//! - **`AgentIdentityLink`** (`db_agent_identity_links`): legacy
14//!   junction binding an agent to an account directly. Kept for the
15//!   identity migration path.
16//!
17//! Extracted from `store.rs` in Phase R.2 of the storage
18//! modularization plan
19//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
20//! method surface is unchanged — `Store::identity_*`,
21//! `bundle_identity_*`, and `agent_identity_*` still live on `Store`
22//! via this `impl` block; existing imports of the structs from
23//! `storage::store::*` keep working thanks to re-exports.
24
25use rusqlite::params;
26use serde::{Deserialize, Serialize};
27
28use super::error::StoreError;
29use super::store::Store;
30
31/// Provider-specific credential reference. Stored as JSON in
32/// `db_identity_accounts.secret_ref`. `backend` is the discriminator.
33/// The actual secret value is NEVER stored in the DB — only how to
34/// look it up at launch time (env var, secrets-manager path, etc.).
35/// `PlaintextDev` exists for local dev convenience and must never be
36/// the default path in production builds.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(tag = "backend", rename_all = "snake_case")]
39pub enum SecretRef {
40    Env {
41        env_var: String,
42    },
43    SecretsManager {
44        sm_path: String,
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        sm_json_path: Option<String>,
47    },
48    PlaintextDev {
49        plaintext_dev: String,
50    },
51    /// **OAuth credentials stored as a filesystem pointer.** The CLI
52    /// (Claude Code, codex, openclaw, …) reads its OAuth tokens from
53    /// this directory at spawn time — agentmux only holds the path,
54    /// never the tokens themselves. Token refresh is the CLI's job;
55    /// the path stays stable across refreshes. Used by oauth-class
56    /// providers; the resolver (PR B) dispatches to a config-dir
57    /// env-var injection mode rather than the api-key env-var path.
58    /// See `docs/specs/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md`.
59    OAuthConfigDir {
60        /// Absolute path to the per-bundle, per-provider config
61        /// directory — e.g. `~/.agentmux/shared/identities/<id>/claude/`,
62        /// or the legacy `~/.claude/` for the Default migration bundle
63        /// (PR E).
64        dir: String,
65    },
66    /// **API key / token stored in the OS-native secret store** (macOS
67    /// Keychain / Windows Credential Manager / Linux Secret Service),
68    /// addressed by `(service, account)`. The plaintext is NEVER held in
69    /// the DB — only this pointer. Written by the Trust Center key flow
70    /// after a successful live validation; resolved to the real value at
71    /// agent spawn time via `crate::identity::secret_store`. See
72    /// specs/SPEC_TRUST_CENTER_2026_06_15.md §7/§12.2.
73    Keychain {
74        /// Keychain service string — always `"agentmux"`.
75        service: String,
76        /// Keychain account string — `"acct:<account_id>"`.
77        account: String,
78    },
79}
80
81/// An identity account (reusable credential, linked to agents via the
82/// `db_agent_identity_links` junction). Replaces the browser
83/// localStorage identity store.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct IdentityAccount {
86    pub id: String,
87    pub name: String,
88    pub provider: String, // "github" | "aws" | "anthropic" | "custom"
89    pub kind: String,     // "pat" | "role" | "api_key" | "env_ref"
90    #[serde(default)]
91    pub display_name: String,
92    pub secret_ref: SecretRef,
93    /// Free-form JSON context (username, scopes, role ARN, etc.). Stored
94    /// verbatim; frontend types it by `provider`.
95    #[serde(default = "default_context_json")]
96    pub context: serde_json::Value,
97    #[serde(default = "default_identity_status")]
98    pub status: String, // "unknown" | "ok" | "expired" | "invalid"
99    pub created_at: i64,
100    pub updated_at: i64,
101}
102
103fn default_context_json() -> serde_json::Value {
104    serde_json::json!({})
105}
106
107fn default_identity_status() -> String {
108    "unknown".to_string()
109}
110
111/// Junction row: which identity an agent uses for a given provider.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct AgentIdentityLink {
114    pub agent_id: String,
115    pub account_id: String,
116    pub provider: String,
117}
118
119/// A named credential bundle. Contains zero or more accounts via the
120/// `db_identity_bindings` junction. `is_blank` tags the seeded singleton
121/// row that the launch UI uses as the default option.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Identity {
124    pub id: String,
125    pub name: String,
126    #[serde(default)]
127    pub description: String,
128    #[serde(default)]
129    pub is_blank: bool,
130    pub created_at: i64,
131    pub updated_at: i64,
132}
133
134/// Junction row binding an account to an identity for a given provider.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct IdentityBinding {
137    pub identity_id: String,
138    pub provider: String,
139    pub account_id: String,
140}
141
142impl Store {
143    // ---- Identity account CRUD ----
144
145    /// List identity accounts. If `provider` is `Some`, filter to that
146    /// provider; otherwise return every account, ordered by most recent
147    /// update first (so the identity panel shows live accounts on top).
148    pub fn identity_list(
149        &self,
150        provider: Option<&str>,
151    ) -> Result<Vec<IdentityAccount>, StoreError> {
152        let conn = self.conn.lock().unwrap();
153        let mut rows_vec = Vec::new();
154        let map_row = |row: &rusqlite::Row| -> rusqlite::Result<IdentityAccount> {
155            let secret_ref_json: String = row.get(5)?;
156            let context_json: String = row.get(6)?;
157            Ok(IdentityAccount {
158                id: row.get(0)?,
159                name: row.get(1)?,
160                provider: row.get(2)?,
161                kind: row.get(3)?,
162                display_name: row.get(4)?,
163                secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
164                    rusqlite::Error::FromSqlConversionFailure(
165                        5,
166                        rusqlite::types::Type::Text,
167                        Box::new(e),
168                    )
169                })?,
170                context: serde_json::from_str(&context_json)
171                    .unwrap_or_else(|_| serde_json::json!({})),
172                status: row.get(7)?,
173                created_at: row.get(8)?,
174                updated_at: row.get(9)?,
175            })
176        };
177        match provider {
178            Some(p) => {
179                let mut stmt = conn.prepare(
180                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
181                            status, created_at, updated_at
182                     FROM db_identity_accounts
183                     WHERE provider = ?1
184                     ORDER BY updated_at DESC",
185                )?;
186                let iter = stmt.query_map(params![p], map_row)?;
187                for r in iter {
188                    rows_vec.push(r?);
189                }
190            }
191            None => {
192                let mut stmt = conn.prepare(
193                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
194                            status, created_at, updated_at
195                     FROM db_identity_accounts
196                     ORDER BY updated_at DESC",
197                )?;
198                let iter = stmt.query_map([], map_row)?;
199                for r in iter {
200                    rows_vec.push(r?);
201                }
202            }
203        }
204        Ok(rows_vec)
205    }
206
207    pub fn identity_get(&self, id: &str) -> Result<Option<IdentityAccount>, StoreError> {
208        let conn = self.conn.lock().unwrap();
209        let mut stmt = conn.prepare(
210            "SELECT id, name, provider, kind, display_name, secret_ref, context,
211                    status, created_at, updated_at
212             FROM db_identity_accounts WHERE id = ?1",
213        )?;
214        let result = stmt.query_row(params![id], |row| {
215            let secret_ref_json: String = row.get(5)?;
216            let context_json: String = row.get(6)?;
217            Ok(IdentityAccount {
218                id: row.get(0)?,
219                name: row.get(1)?,
220                provider: row.get(2)?,
221                kind: row.get(3)?,
222                display_name: row.get(4)?,
223                secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
224                    rusqlite::Error::FromSqlConversionFailure(
225                        5,
226                        rusqlite::types::Type::Text,
227                        Box::new(e),
228                    )
229                })?,
230                context: serde_json::from_str(&context_json)
231                    .unwrap_or_else(|_| serde_json::json!({})),
232                status: row.get(7)?,
233                created_at: row.get(8)?,
234                updated_at: row.get(9)?,
235            })
236        });
237        match result {
238            Ok(a) => Ok(Some(a)),
239            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
240            Err(e) => Err(e.into()),
241        }
242    }
243
244    /// Upsert an identity account. If `account.id` is empty the caller
245    /// must generate one first (we don't silently mint ids here — callers
246    /// should know whether they're creating vs updating).
247    pub fn identity_upsert(&self, account: &IdentityAccount) -> Result<(), StoreError> {
248        let conn = self.conn.lock().unwrap();
249        let secret_ref_json = serde_json::to_string(&account.secret_ref)?;
250        let context_json = serde_json::to_string(&account.context)?;
251        conn.execute(
252            "INSERT INTO db_identity_accounts
253                (id, name, provider, kind, display_name, secret_ref, context,
254                 status, created_at, updated_at)
255             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
256             ON CONFLICT(id) DO UPDATE SET
257                name = excluded.name,
258                provider = excluded.provider,
259                kind = excluded.kind,
260                display_name = excluded.display_name,
261                secret_ref = excluded.secret_ref,
262                context = excluded.context,
263                status = excluded.status,
264                updated_at = excluded.updated_at",
265            params![
266                account.id,
267                account.name,
268                account.provider,
269                account.kind,
270                account.display_name,
271                secret_ref_json,
272                context_json,
273                account.status,
274                account.created_at,
275                account.updated_at,
276            ],
277        )?;
278        Ok(())
279    }
280
281    pub fn identity_delete(&self, id: &str) -> Result<bool, StoreError> {
282        let conn = self.conn.lock().unwrap();
283        let rows = conn.execute("DELETE FROM db_identity_accounts WHERE id = ?1", params![id])?;
284        Ok(rows > 0)
285    }
286
287    // ---- Agent ↔ Identity junction ----
288
289    /// Link an agent to an identity for a given provider. Overwrites any
290    /// existing link for the same (agent_id, provider) — each agent has
291    /// at most one account per provider.
292    pub fn agent_identity_link(
293        &self,
294        agent_id: &str,
295        account_id: &str,
296        provider: &str,
297    ) -> Result<(), StoreError> {
298        let conn = self.conn.lock().unwrap();
299        conn.execute(
300            "INSERT INTO db_agent_identity_links (agent_id, account_id, provider)
301             VALUES (?1, ?2, ?3)
302             ON CONFLICT(agent_id, provider) DO UPDATE SET account_id = excluded.account_id",
303            params![agent_id, account_id, provider],
304        )?;
305        Ok(())
306    }
307
308    /// Remove the identity link for a given (agent_id, provider).
309    /// Returns true iff a link existed.
310    pub fn agent_identity_unlink(
311        &self,
312        agent_id: &str,
313        provider: &str,
314    ) -> Result<bool, StoreError> {
315        let conn = self.conn.lock().unwrap();
316        let rows = conn.execute(
317            "DELETE FROM db_agent_identity_links WHERE agent_id = ?1 AND provider = ?2",
318            params![agent_id, provider],
319        )?;
320        Ok(rows > 0)
321    }
322
323    /// List all (agent_id, account_id, provider) triples for an agent.
324    pub fn agent_identity_list_for_agent(
325        &self,
326        agent_id: &str,
327    ) -> Result<Vec<AgentIdentityLink>, StoreError> {
328        let conn = self.conn.lock().unwrap();
329        let mut stmt = conn.prepare(
330            "SELECT agent_id, account_id, provider
331             FROM db_agent_identity_links
332             WHERE agent_id = ?1
333             ORDER BY provider",
334        )?;
335        let iter = stmt.query_map(params![agent_id], |row| {
336            Ok(AgentIdentityLink {
337                agent_id: row.get(0)?,
338                account_id: row.get(1)?,
339                provider: row.get(2)?,
340            })
341        })?;
342        let mut out = Vec::new();
343        for r in iter {
344            out.push(r?);
345        }
346        Ok(out)
347    }
348
349    // ---- Identity bundle CRUD ----
350
351    /// List all Identity bundles, blank singleton last so the picker shows
352    /// user-defined bundles first.
353    pub fn bundle_identity_list(&self) -> Result<Vec<Identity>, StoreError> {
354        let conn = self.conn.lock().unwrap();
355        let mut stmt = conn.prepare(
356            "SELECT id, name, description, is_blank, created_at, updated_at
357             FROM db_identity_bundles
358             ORDER BY is_blank ASC, updated_at DESC",
359        )?;
360        let iter = stmt.query_map([], |row| {
361            Ok(Identity {
362                id: row.get(0)?,
363                name: row.get(1)?,
364                description: row.get(2)?,
365                is_blank: row.get::<_, i64>(3)? != 0,
366                created_at: row.get(4)?,
367                updated_at: row.get(5)?,
368            })
369        })?;
370        let mut out = Vec::new();
371        for r in iter {
372            out.push(r?);
373        }
374        Ok(out)
375    }
376
377    pub fn bundle_identity_get(&self, id: &str) -> Result<Option<Identity>, StoreError> {
378        let conn = self.conn.lock().unwrap();
379        let mut stmt = conn.prepare(
380            "SELECT id, name, description, is_blank, created_at, updated_at
381             FROM db_identity_bundles WHERE id = ?1",
382        )?;
383        let result = stmt.query_row(params![id], |row| {
384            Ok(Identity {
385                id: row.get(0)?,
386                name: row.get(1)?,
387                description: row.get(2)?,
388                is_blank: row.get::<_, i64>(3)? != 0,
389                created_at: row.get(4)?,
390                updated_at: row.get(5)?,
391            })
392        });
393        match result {
394            Ok(i) => Ok(Some(i)),
395            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
396            Err(e) => Err(e.into()),
397        }
398    }
399
400    /// Upsert an Identity bundle. Caller mints the id (no silent generation).
401    /// The `is_blank` flag is reserved for the seeded singleton — callers
402    /// should pass `false` for user-created identities.
403    pub fn bundle_identity_upsert(&self, identity: &Identity) -> Result<(), StoreError> {
404        let conn = self.conn.lock().unwrap();
405        conn.execute(
406            "INSERT INTO db_identity_bundles
407                (id, name, description, is_blank, created_at, updated_at)
408             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
409             ON CONFLICT(id) DO UPDATE SET
410                name = excluded.name,
411                description = excluded.description,
412                updated_at = excluded.updated_at",
413            params![
414                identity.id,
415                identity.name,
416                identity.description,
417                identity.is_blank as i64,
418                identity.created_at,
419                identity.updated_at,
420            ],
421        )?;
422        Ok(())
423    }
424
425    /// Delete an Identity bundle. Refuses to delete the blank singleton —
426    /// the launch UI depends on it as the always-present default option.
427    pub fn bundle_identity_delete(&self, id: &str) -> Result<bool, StoreError> {
428        if id == "blank" {
429            return Err(StoreError::Other(
430                "cannot delete the blank Identity singleton".to_string(),
431            ));
432        }
433        let conn = self.conn.lock().unwrap();
434        let rows = conn.execute("DELETE FROM db_identity_bundles WHERE id = ?1", params![id])?;
435        Ok(rows > 0)
436    }
437
438    // ---- Identity bundle bindings (junction with accounts) ----
439
440    /// Set the account for `(identity_id, provider)`. Overwrites any
441    /// existing binding for the same (identity, provider).
442    pub fn bundle_identity_bind(
443        &self,
444        identity_id: &str,
445        provider: &str,
446        account_id: &str,
447    ) -> Result<(), StoreError> {
448        let conn = self.conn.lock().unwrap();
449        conn.execute(
450            "INSERT INTO db_identity_bindings (identity_id, provider, account_id)
451             VALUES (?1, ?2, ?3)
452             ON CONFLICT(identity_id, provider) DO UPDATE SET account_id = excluded.account_id",
453            params![identity_id, provider, account_id],
454        )?;
455        Ok(())
456    }
457
458    /// Remove the binding for `(identity_id, provider)`. Returns whether a
459    /// row was deleted.
460    pub fn bundle_identity_unbind(
461        &self,
462        identity_id: &str,
463        provider: &str,
464    ) -> Result<bool, StoreError> {
465        let conn = self.conn.lock().unwrap();
466        let rows = conn.execute(
467            "DELETE FROM db_identity_bindings WHERE identity_id = ?1 AND provider = ?2",
468            params![identity_id, provider],
469        )?;
470        Ok(rows > 0)
471    }
472
473    /// List bindings for an Identity bundle.
474    pub fn bundle_identity_bindings(
475        &self,
476        identity_id: &str,
477    ) -> Result<Vec<IdentityBinding>, StoreError> {
478        let conn = self.conn.lock().unwrap();
479        let mut stmt = conn.prepare(
480            "SELECT identity_id, provider, account_id
481             FROM db_identity_bindings
482             WHERE identity_id = ?1
483             ORDER BY provider ASC",
484        )?;
485        let iter = stmt.query_map(params![identity_id], |row| {
486            Ok(IdentityBinding {
487                identity_id: row.get(0)?,
488                provider: row.get(1)?,
489                account_id: row.get(2)?,
490            })
491        })?;
492        let mut out = Vec::new();
493        for r in iter {
494            out.push(r?);
495        }
496        Ok(out)
497    }
498}