Skip to main content

gonzalo_server/
auth.rs

1//! Namespace-scoped principal auth for the daemon (ADR 0015).
2//!
3//! A bearer token maps to a [`Principal`] carrying per-namespace `read`/`write`
4//! scopes (`"*"` = any namespace). [`Auth`] is the token registry; the
5//! transports authenticate (token → principal) at the edge and authorize
6//! ([`Principal::allows`]) in each handler where the target namespace is known.
7//! This module is pure and transport-agnostic.
8
9use std::collections::HashMap;
10
11use serde::Deserialize;
12
13/// The kind of access an operation needs on a namespace.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Access {
16    Read,
17    Write,
18}
19
20/// An authenticated caller and the namespaces it may read/write. `"*"` in a list
21/// grants that access on every namespace.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Principal {
24    name: String,
25    read: Vec<String>,
26    write: Vec<String>,
27    /// Whether this principal was established by real authentication (a
28    /// configured token) versus the implicit identity of open/disabled mode.
29    /// Only authenticated principals stamp authorship on writes.
30    authenticated: bool,
31}
32
33impl Principal {
34    pub fn new(name: impl Into<String>, read: Vec<String>, write: Vec<String>) -> Self {
35        Self {
36            name: name.into(),
37            read,
38            write,
39            authenticated: true,
40        }
41    }
42
43    /// A full-access principal (`read`/`write` on `"*"`).
44    pub fn admin(name: impl Into<String>) -> Self {
45        Self::new(name, vec!["*".into()], vec!["*".into()])
46    }
47
48    /// The implicit full-access identity of open (disabled-auth) mode. Unlike a
49    /// configured principal, it is not authenticated, so it does not stamp
50    /// authorship — open mode leaves the daemon a transparent store.
51    pub fn open() -> Self {
52        Self {
53            authenticated: false,
54            ..Self::admin("local")
55        }
56    }
57
58    pub fn name(&self) -> &str {
59        &self.name
60    }
61
62    /// Whether authorship should be stamped from this principal on writes.
63    pub fn is_authenticated(&self) -> bool {
64        self.authenticated
65    }
66
67    /// Whether this principal has `access` on `namespace` (exact match or `"*"`).
68    pub fn allows(&self, access: Access, namespace: &str) -> bool {
69        let scopes = match access {
70            Access::Read => &self.read,
71            Access::Write => &self.write,
72        };
73        scopes.iter().any(|s| s == "*" || s == namespace)
74    }
75}
76
77/// The daemon's token registry. `Disabled` means no auth was configured — every
78/// request is served as an implicit admin (local/library behavior).
79pub enum Auth {
80    Disabled,
81    Enabled(HashMap<String, Principal>),
82}
83
84impl Auth {
85    /// Resolve a bearer token to its principal. `Disabled` always yields an
86    /// implicit admin; `Enabled` yields the matching principal, or `None` when
87    /// the token is missing or unknown (the caller maps that to 401).
88    pub fn authenticate(&self, token: Option<&str>) -> Option<Principal> {
89        match self {
90            Auth::Disabled => Some(Principal::open()),
91            Auth::Enabled(by_token) => token.and_then(|t| by_token.get(t)).cloned(),
92        }
93    }
94
95    /// Parse a TOML principals file into an `Enabled` registry. Duplicate tokens
96    /// are an error (ambiguous identity).
97    pub fn parse_toml(s: &str) -> Result<Auth, String> {
98        #[derive(Deserialize)]
99        struct File {
100            #[serde(default)]
101            principal: Vec<Def>,
102        }
103        #[derive(Deserialize)]
104        struct Def {
105            name: String,
106            token: String,
107            #[serde(default)]
108            read: Vec<String>,
109            #[serde(default)]
110            write: Vec<String>,
111        }
112
113        let file: File = toml::from_str(s).map_err(|e| e.to_string())?;
114        let mut by_token = HashMap::new();
115        for def in file.principal {
116            let principal = Principal::new(def.name, def.read, def.write);
117            if by_token.insert(def.token, principal).is_some() {
118                return Err("duplicate token in auth file".to_string());
119            }
120        }
121        Ok(Auth::Enabled(by_token))
122    }
123
124    /// Resolve the daemon's auth from the environment (pure — `read_file` injects
125    /// the file IO so this is unit-testable):
126    ///
127    /// - `GONZALO_AUTH_FILE` → parse that TOML principals file;
128    /// - else `GONZALO_TOKEN` → a single admin principal (back-compat);
129    /// - else → `Disabled` (open).
130    pub fn from_env(
131        get: impl Fn(&str) -> Option<String>,
132        read_file: impl Fn(&str) -> Result<String, String>,
133    ) -> Result<Auth, String> {
134        if let Some(path) = get("GONZALO_AUTH_FILE").filter(|s| !s.is_empty()) {
135            Auth::parse_toml(&read_file(&path)?)
136        } else if let Some(token) = get("GONZALO_TOKEN").filter(|s| !s.is_empty()) {
137            Ok(Auth::Enabled(HashMap::from([(
138                token,
139                Principal::admin("root"),
140            )])))
141        } else {
142            Ok(Auth::Disabled)
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn allows_exact_wildcard_and_denies() {
153        let p = Principal::new("p", vec!["memory".into()], vec!["*".into()]);
154        assert!(p.allows(Access::Read, "memory"));
155        assert!(!p.allows(Access::Read, "sessions"));
156        // write is wildcard: any namespace.
157        assert!(p.allows(Access::Write, "sessions"));
158        assert!(p.allows(Access::Write, "anything"));
159    }
160
161    #[test]
162    fn parse_toml_builds_scoped_principals() {
163        let toml = r#"
164[[principal]]
165name  = "caliban"
166token = "s3cret"
167read  = ["memory", "sessions"]
168write = ["memory"]
169
170[[principal]]
171name  = "admin"
172token = "root"
173read  = ["*"]
174write = ["*"]
175"#;
176        let auth = Auth::parse_toml(toml).unwrap();
177        let caliban = auth.authenticate(Some("s3cret")).unwrap();
178        assert_eq!(caliban.name(), "caliban");
179        assert!(caliban.allows(Access::Read, "sessions"));
180        assert!(!caliban.allows(Access::Write, "sessions"));
181
182        let admin = auth.authenticate(Some("root")).unwrap();
183        assert!(admin.allows(Access::Write, "whatever"));
184
185        assert!(auth.authenticate(Some("nope")).is_none());
186        assert!(auth.authenticate(None).is_none());
187    }
188
189    #[test]
190    fn parse_toml_rejects_malformed_and_duplicate_tokens() {
191        assert!(Auth::parse_toml("not = valid = toml").is_err());
192        let dup = r#"
193[[principal]]
194name = "a"
195token = "same"
196[[principal]]
197name = "b"
198token = "same"
199"#;
200        assert!(Auth::parse_toml(dup).is_err());
201    }
202
203    #[test]
204    fn disabled_authenticates_everything_as_admin() {
205        let auth = Auth::Disabled;
206        let p = auth.authenticate(None).unwrap();
207        assert!(p.allows(Access::Write, "any"));
208    }
209
210    #[test]
211    fn from_env_precedence_file_then_token_then_disabled() {
212        let file = |_: &str| Ok("[[principal]]\nname='a'\ntoken='t'\nread=['ns']\nwrite=[]".into());
213
214        // File wins when set.
215        let by_file = Auth::from_env(
216            |k| (k == "GONZALO_AUTH_FILE").then(|| "auth.toml".into()),
217            file,
218        )
219        .unwrap();
220        assert!(
221            by_file
222                .authenticate(Some("t"))
223                .unwrap()
224                .allows(Access::Read, "ns")
225        );
226
227        // Token → single admin.
228        let by_token = Auth::from_env(
229            |k| (k == "GONZALO_TOKEN").then(|| "root".into()),
230            |_| Err("no file".into()),
231        )
232        .unwrap();
233        assert!(
234            by_token
235                .authenticate(Some("root"))
236                .unwrap()
237                .allows(Access::Write, "x")
238        );
239
240        // Neither → disabled (open).
241        let disabled = Auth::from_env(|_| None, |_| Err("no file".into())).unwrap();
242        assert!(matches!(disabled, Auth::Disabled));
243    }
244}