Skip to main content

nexo_auth/
resolver.rs

1//! Agent → credential resolver. Produced by [`AgentCredentialResolver::build`]
2//! after the boot-time gauntlet validates every invariant listed in
3//! `proyecto/docs/credentials.md`.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8
9use arc_swap::ArcSwap;
10
11use crate::email::EmailCredentialStore;
12use crate::error::{BuildError, ResolveError};
13use crate::google::GoogleCredentialStore;
14use crate::handle::{AgentId, Channel, CredentialHandle, EMAIL, GOOGLE, TELEGRAM, WHATSAPP};
15use crate::store::CredentialStore;
16use crate::telegram::TelegramCredentialStore;
17use crate::whatsapp::WhatsappCredentialStore;
18
19#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
20pub enum StrictLevel {
21    /// Warn-only for soft violations (asymmetric binding, unused
22    /// account). Default during migration so back-compat configs
23    /// keep booting.
24    #[default]
25    Lenient,
26    /// Promote every warning to a hard error. Intended for CI lane
27    /// (`--check-config`) and eventually as the default in V2.
28    Strict,
29}
30
31/// View of the per-agent config that the resolver needs — keeps this
32/// crate independent of `nexo-config`. The wiring crate maps from
33/// [`AgentConfig`](nexo_config::types::agents::AgentConfig) into this
34/// shape just before calling [`AgentCredentialResolver::build`].
35#[derive(Debug, Clone)]
36pub struct AgentCredentialsInput {
37    pub agent_id: String,
38    /// Outbound binding per channel (`channel -> account_id`).
39    pub outbound: HashMap<Channel, String>,
40    /// Inbound instance subscriptions per channel (parsed from
41    /// `inbound_bindings`). Used for the asymmetric-binding check.
42    pub inbound: HashMap<Channel, Vec<String>>,
43    /// Per-channel opt-out for the symmetric-binding warning.
44    pub asymmetric_allowed: HashMap<Channel, bool>,
45}
46
47#[derive(Clone)]
48pub struct CredentialStores {
49    pub whatsapp: Arc<WhatsappCredentialStore>,
50    pub telegram: Arc<TelegramCredentialStore>,
51    pub google: Arc<GoogleCredentialStore>,
52    pub email: Arc<EmailCredentialStore>,
53}
54
55impl CredentialStores {
56    pub fn empty() -> Self {
57        Self {
58            whatsapp: Arc::new(WhatsappCredentialStore::empty()),
59            telegram: Arc::new(TelegramCredentialStore::empty()),
60            google: Arc::new(GoogleCredentialStore::empty()),
61            email: Arc::new(EmailCredentialStore::empty()),
62        }
63    }
64}
65
66/// Hot-reloadable resolver. Bindings + warnings + strict level live in
67/// `ArcSwap`/`ArcSwap`/atomic so the runtime can swap them without
68/// re-creating the `Arc<AgentCredentialResolver>` every plugin/tool
69/// holds.
70#[derive(Debug)]
71pub struct AgentCredentialResolver {
72    bindings: ArcSwap<HashMap<AgentId, HashMap<Channel, CredentialHandle>>>,
73    warnings: ArcSwap<Vec<String>>,
74    strict: ArcSwap<StrictLevel>,
75    version: AtomicU64,
76}
77
78impl AgentCredentialResolver {
79    pub fn empty() -> Self {
80        Self {
81            bindings: ArcSwap::from_pointee(HashMap::new()),
82            warnings: ArcSwap::from_pointee(Vec::new()),
83            strict: ArcSwap::from_pointee(StrictLevel::default()),
84            version: AtomicU64::new(0),
85        }
86    }
87
88    /// Lookup the outbound handle for `(agent, channel)`. Never panics
89    /// on missing bindings — returns [`ResolveError::Unbound`] so the
90    /// calling tool can surface a clean error to the LLM.
91    pub fn resolve(
92        &self,
93        agent_id: &str,
94        channel: Channel,
95    ) -> Result<CredentialHandle, ResolveError> {
96        self.bindings
97            .load()
98            .get(agent_id)
99            .and_then(|m| m.get(channel))
100            .cloned()
101            .ok_or(ResolveError::Unbound {
102                agent: agent_id.to_string(),
103                channel,
104            })
105    }
106
107    pub fn version(&self) -> u64 {
108        self.version.load(Ordering::Relaxed)
109    }
110
111    pub fn warnings(&self) -> Vec<String> {
112        self.warnings.load().as_ref().clone()
113    }
114
115    pub fn strict(&self) -> StrictLevel {
116        **self.strict.load()
117    }
118
119    /// Atomic hot-reload — replaces bindings, warnings, and strict
120    /// level in one swap. Existing `CredentialHandle`s already issued
121    /// to in-flight tool calls keep working (handles are by-value
122    /// clones, the resolver only mediates lookup of *future* calls).
123    pub fn replace_state(
124        &self,
125        new_bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>>,
126        new_warnings: Vec<String>,
127        new_strict: StrictLevel,
128    ) {
129        self.bindings.store(Arc::new(new_bindings));
130        self.warnings.store(Arc::new(new_warnings));
131        self.strict.store(Arc::new(new_strict));
132        self.version.fetch_add(1, Ordering::Relaxed);
133    }
134
135    /// Validate every agent binding against the given stores. Returns
136    /// [`Ok`] with the resolver plus soft warnings, or [`Err`] with
137    /// every accumulated invariant violation so the operator can fix
138    /// them in one edit.
139    pub fn build(
140        agents: &[AgentCredentialsInput],
141        stores: &CredentialStores,
142        strict: StrictLevel,
143    ) -> Result<Self, Vec<BuildError>> {
144        let mut errors: Vec<BuildError> = Vec::new();
145        let mut warnings: Vec<String> = Vec::new();
146        let mut bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>> = HashMap::new();
147
148        for agent in agents {
149            let mut per_channel: HashMap<Channel, CredentialHandle> = HashMap::new();
150            for channel in [WHATSAPP, TELEGRAM, GOOGLE, EMAIL] {
151                let outbound = agent.outbound.get(channel).cloned();
152                let inbound = agent.inbound.get(channel).cloned().unwrap_or_default();
153                let asymmetric_ok = *agent.asymmetric_allowed.get(channel).unwrap_or(&false);
154
155                // Back-compat inference: no explicit outbound + single
156                // inbound instance → use that one.
157                let account_id = match outbound {
158                    Some(a) => Some(a),
159                    None => match inbound.len() {
160                        0 => None,
161                        1 => Some(inbound[0].clone()),
162                        _ => {
163                            errors.push(BuildError::AmbiguousOutbound {
164                                channel,
165                                agent: agent.agent_id.clone(),
166                                instances: inbound.clone(),
167                            });
168                            continue;
169                        }
170                    },
171                };
172
173                let Some(account_id) = account_id else {
174                    continue;
175                };
176
177                // Account must exist in the store.
178                let available = store_list(stores, channel);
179                if !available.iter().any(|a| a == &account_id) {
180                    errors.push(BuildError::MissingInstance {
181                        channel,
182                        agent: agent.agent_id.clone(),
183                        account: account_id.clone(),
184                        available,
185                    });
186                    continue;
187                }
188
189                // Account's allow_agents must accept this agent.
190                let allow = store_allow_agents(stores, channel, &account_id);
191                if !allow.is_empty() && !allow.iter().any(|a| a == &agent.agent_id) {
192                    errors.push(BuildError::AllowAgentsExcludes {
193                        channel,
194                        instance: account_id.clone(),
195                        agent: agent.agent_id.clone(),
196                    });
197                    continue;
198                }
199
200                // Asymmetric-binding warning (outbound ≠ inbound).
201                if !inbound.is_empty()
202                    && !inbound.iter().any(|i| i == &account_id)
203                    && !asymmetric_ok
204                {
205                    let msg = BuildError::AsymmetricBinding {
206                        channel,
207                        agent: agent.agent_id.clone(),
208                        outbound: account_id.clone(),
209                        inbound: inbound.join(","),
210                    };
211                    match strict {
212                        StrictLevel::Strict => errors.push(msg),
213                        StrictLevel::Lenient => warnings.push(msg.to_string()),
214                    }
215                }
216
217                // Issue the handle through the store — re-runs the
218                // allow_agents check and enforces Google's 1:1 rule.
219                match store_issue(stores, channel, &account_id, &agent.agent_id) {
220                    Ok(handle) => {
221                        per_channel.insert(channel, handle);
222                    }
223                    Err(source) => {
224                        errors.push(BuildError::Credential {
225                            channel,
226                            instance: account_id.clone(),
227                            source,
228                        });
229                    }
230                }
231            }
232            if !per_channel.is_empty() {
233                bindings.insert(Arc::from(agent.agent_id.as_str()), per_channel);
234            }
235        }
236
237        if !errors.is_empty() {
238            return Err(errors);
239        }
240
241        Ok(Self {
242            bindings: ArcSwap::from_pointee(bindings),
243            warnings: ArcSwap::from_pointee(warnings),
244            strict: ArcSwap::from_pointee(strict),
245            version: AtomicU64::new(1),
246        })
247    }
248
249    /// Reload entry point — runs `build` against fresh inputs and
250    /// atomically swaps the new state into `self`. Used by the
251    /// admin endpoint and integration tests.
252    pub fn rebuild(
253        &self,
254        agents: &[AgentCredentialsInput],
255        stores: &CredentialStores,
256        strict: StrictLevel,
257    ) -> Result<(), Vec<BuildError>> {
258        let fresh = Self::build(agents, stores, strict)?;
259        // Move state out of the freshly-built resolver into self.
260        let new_bindings = fresh.bindings.load_full();
261        let new_warnings = fresh.warnings.load_full();
262        let new_strict = **fresh.strict.load();
263        self.replace_state((*new_bindings).clone(), (*new_warnings).clone(), new_strict);
264        Ok(())
265    }
266
267    /// Test-only constructor that takes raw bindings. Not intended for
268    /// production code — [`Self::build`] is the only validated path.
269    #[doc(hidden)]
270    pub fn from_raw(bindings: HashMap<AgentId, HashMap<Channel, CredentialHandle>>) -> Self {
271        Self {
272            bindings: ArcSwap::from_pointee(bindings),
273            warnings: ArcSwap::from_pointee(Vec::new()),
274            strict: ArcSwap::from_pointee(StrictLevel::default()),
275            version: AtomicU64::new(1),
276        }
277    }
278}
279
280fn store_list(stores: &CredentialStores, channel: Channel) -> Vec<String> {
281    match channel {
282        WHATSAPP => stores.whatsapp.list(),
283        TELEGRAM => stores.telegram.list(),
284        GOOGLE => stores.google.list(),
285        EMAIL => stores.email.list(),
286        _ => Vec::new(),
287    }
288}
289
290fn store_allow_agents(
291    stores: &CredentialStores,
292    channel: Channel,
293    account_id: &str,
294) -> Vec<String> {
295    match channel {
296        WHATSAPP => stores.whatsapp.allow_agents(account_id),
297        TELEGRAM => stores.telegram.allow_agents(account_id),
298        GOOGLE => stores.google.allow_agents(account_id),
299        EMAIL => stores.email.allow_agents(account_id),
300        _ => Vec::new(),
301    }
302}
303
304fn store_issue(
305    stores: &CredentialStores,
306    channel: Channel,
307    account_id: &str,
308    agent_id: &str,
309) -> Result<CredentialHandle, crate::error::CredentialError> {
310    match channel {
311        WHATSAPP => stores.whatsapp.issue(account_id, agent_id),
312        TELEGRAM => stores.telegram.issue(account_id, agent_id),
313        GOOGLE => stores.google.issue(account_id, agent_id),
314        EMAIL => stores.email.issue(account_id, agent_id),
315        _ => Err(crate::error::CredentialError::NotFound {
316            channel,
317            account: account_id.to_string(),
318        }),
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::google::GoogleAccount;
326    use crate::telegram::TelegramAccount;
327    use crate::whatsapp::WhatsappAccount;
328    use std::path::PathBuf;
329
330    fn wa(instance: &str, allow: &[&str]) -> WhatsappAccount {
331        WhatsappAccount {
332            instance: instance.into(),
333            session_dir: PathBuf::from(format!("/tmp/wa-{instance}")),
334            media_dir: PathBuf::from(format!("/tmp/wa-{instance}/media")),
335            allow_agents: allow.iter().map(|s| s.to_string()).collect(),
336        }
337    }
338
339    fn tg(instance: &str, allow: &[&str]) -> TelegramAccount {
340        TelegramAccount {
341            instance: instance.into(),
342            token: "t".into(),
343            allow_agents: allow.iter().map(|s| s.to_string()).collect(),
344            allowed_chat_ids: vec![],
345        }
346    }
347
348    fn ga(id: &str, agent: &str) -> GoogleAccount {
349        GoogleAccount {
350            id: id.into(),
351            agent_id: agent.into(),
352            client_id_path: PathBuf::from("/tmp/cid"),
353            client_secret_path: PathBuf::from("/tmp/csec"),
354            token_path: PathBuf::from("/tmp/tok"),
355            scopes: vec![],
356        }
357    }
358
359    fn stores(
360        wa_list: Vec<WhatsappAccount>,
361        tg_list: Vec<TelegramAccount>,
362        g_list: Vec<GoogleAccount>,
363    ) -> CredentialStores {
364        CredentialStores {
365            whatsapp: Arc::new(WhatsappCredentialStore::new(wa_list)),
366            telegram: Arc::new(TelegramCredentialStore::new(tg_list)),
367            google: Arc::new(GoogleCredentialStore::new(g_list)),
368            email: Arc::new(EmailCredentialStore::empty()),
369        }
370    }
371
372    fn input(
373        id: &str,
374        out: &[(Channel, &str)],
375        inb: &[(Channel, &[&str])],
376    ) -> AgentCredentialsInput {
377        let mut outbound = HashMap::new();
378        for (c, a) in out {
379            outbound.insert(*c, a.to_string());
380        }
381        let mut inbound = HashMap::new();
382        for (c, ins) in inb {
383            inbound.insert(*c, ins.iter().map(|s| s.to_string()).collect());
384        }
385        AgentCredentialsInput {
386            agent_id: id.into(),
387            outbound,
388            inbound,
389            asymmetric_allowed: HashMap::new(),
390        }
391    }
392
393    #[test]
394    fn happy_path_binds_all_three_channels() {
395        let s = stores(
396            vec![wa("personal", &["ana"])],
397            vec![tg("ana_bot", &["ana"])],
398            vec![ga("ana@x", "ana")],
399        );
400        let inp = input(
401            "ana",
402            &[
403                (WHATSAPP, "personal"),
404                (TELEGRAM, "ana_bot"),
405                (GOOGLE, "ana@x"),
406            ],
407            &[],
408        );
409        let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap();
410        assert!(r.resolve("ana", WHATSAPP).is_ok());
411        assert!(r.resolve("ana", TELEGRAM).is_ok());
412        assert!(r.resolve("ana", GOOGLE).is_ok());
413    }
414
415    #[test]
416    fn missing_instance_rejected_with_available_list() {
417        let s = stores(vec![wa("work", &[])], vec![], vec![]);
418        let inp = input("ana", &[(WHATSAPP, "personal")], &[]);
419        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
420        assert_eq!(err.len(), 1);
421        match &err[0] {
422            BuildError::MissingInstance {
423                agent,
424                account,
425                available,
426                ..
427            } => {
428                assert_eq!(agent, "ana");
429                assert_eq!(account, "personal");
430                assert_eq!(available, &vec!["work".to_string()]);
431            }
432            other => panic!("unexpected: {other:?}"),
433        }
434    }
435
436    #[test]
437    fn ambiguous_inbound_rejected() {
438        let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
439        let inp = input("ana", &[], &[(WHATSAPP, &["a", "b"])]);
440        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
441        assert!(matches!(err[0], BuildError::AmbiguousOutbound { .. }));
442    }
443
444    #[test]
445    fn single_inbound_infers_outbound() {
446        let s = stores(vec![wa("personal", &[])], vec![], vec![]);
447        let inp = input("ana", &[], &[(WHATSAPP, &["personal"])]);
448        let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap();
449        assert_eq!(
450            r.resolve("ana", WHATSAPP).unwrap().account_id_raw(),
451            "personal"
452        );
453    }
454
455    #[test]
456    fn allow_agents_excludes_agent() {
457        let s = stores(vec![wa("work", &["kate"])], vec![], vec![]);
458        let inp = input("ana", &[(WHATSAPP, "work")], &[]);
459        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
460        assert!(matches!(err[0], BuildError::AllowAgentsExcludes { .. }));
461    }
462
463    #[test]
464    fn asymmetric_binding_warns_in_lenient() {
465        let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
466        let inp = input("ana", &[(WHATSAPP, "a")], &[(WHATSAPP, &["b"])]);
467        let r = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap();
468        assert_eq!(r.warnings().len(), 1);
469    }
470
471    #[test]
472    fn asymmetric_binding_errors_in_strict() {
473        let s = stores(vec![wa("a", &[]), wa("b", &[])], vec![], vec![]);
474        let inp = input("ana", &[(WHATSAPP, "a")], &[(WHATSAPP, &["b"])]);
475        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Strict).unwrap_err();
476        assert!(matches!(err[0], BuildError::AsymmetricBinding { .. }));
477    }
478
479    #[test]
480    fn boot_reports_all_errors_in_one_pass() {
481        let s = stores(
482            vec![wa("work", &["kate"])],
483            vec![], // telegram empty — missing instance
484            vec![],
485        );
486        let inp = input("ana", &[(WHATSAPP, "work"), (TELEGRAM, "nope")], &[]);
487        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
488        assert_eq!(err.len(), 2, "both errors should surface: {err:#?}");
489    }
490
491    #[test]
492    fn google_1to1_rule_enforced_via_store_issue() {
493        let s = stores(vec![], vec![], vec![ga("ana@x", "ana")]);
494        let inp = input("kate", &[(GOOGLE, "ana@x")], &[]);
495        let err = AgentCredentialResolver::build(&[inp], &s, StrictLevel::Lenient).unwrap_err();
496        // allow_agents for google returns the bound agent, so the
497        // mismatch is caught as AllowAgentsExcludes.
498        assert!(matches!(err[0], BuildError::AllowAgentsExcludes { .. }));
499    }
500
501    #[test]
502    fn no_bindings_when_config_empty() {
503        let s = stores(vec![], vec![], vec![]);
504        let r = AgentCredentialResolver::build(&[], &s, StrictLevel::Strict).unwrap();
505        assert!(r.resolve("ana", WHATSAPP).is_err());
506    }
507}