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 gonzalo_core::Identity;
12use serde::Deserialize;
13
14/// The kind of access an operation needs on a namespace.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Access {
17    Read,
18    Write,
19}
20
21/// An authenticated caller and the namespaces it may read/write. `"*"` in a list
22/// grants that access on every namespace.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Principal {
25    name: String,
26    read: Vec<String>,
27    write: Vec<String>,
28    /// Whether this principal was established by real authentication (a
29    /// configured token) versus the implicit identity of open/disabled mode.
30    /// Only authenticated principals stamp authorship on writes.
31    authenticated: bool,
32}
33
34impl Principal {
35    pub fn new(name: impl Into<String>, read: Vec<String>, write: Vec<String>) -> Self {
36        Self {
37            name: name.into(),
38            read,
39            write,
40            authenticated: true,
41        }
42    }
43
44    /// A full-access principal (`read`/`write` on `"*"`).
45    pub fn admin(name: impl Into<String>) -> Self {
46        Self::new(name, vec!["*".into()], vec!["*".into()])
47    }
48
49    /// The implicit full-access identity of open (disabled-auth) mode. Unlike a
50    /// configured principal, it is not authenticated, so it does not stamp
51    /// authorship — open mode leaves the daemon a transparent store.
52    pub fn open() -> Self {
53        Self {
54            authenticated: false,
55            ..Self::admin("local")
56        }
57    }
58
59    pub fn name(&self) -> &str {
60        &self.name
61    }
62
63    /// Whether authorship should be stamped from this principal on writes.
64    pub fn is_authenticated(&self) -> bool {
65        self.authenticated
66    }
67
68    /// Whether this principal is an admin: `read` **and** `write` on every
69    /// namespace (`"*"` in both lists). Operations whose damage is not confined
70    /// to one namespace's readers and writers require it, such as `purge`
71    /// (gonzalo#203): purging a tombstone early resurrects the record later, on
72    /// another machine. Open mode's implicit principal is an admin.
73    pub fn is_admin(&self) -> bool {
74        self.read.iter().any(|s| s == "*") && self.write.iter().any(|s| s == "*")
75    }
76
77    /// The author a delete stamps on its tombstone (ADR 0015, gonzalo#203): the
78    /// same rule as `put_raw`. A non-admin is always stamped from its token; an
79    /// admin (the replication credential) or open mode may name the deleter,
80    /// and otherwise an authenticated admin is stamped and open mode stamps none.
81    pub fn delete_author(&self, claimed: Option<Identity>) -> Option<Identity> {
82        if !self.is_admin() {
83            return Some(Identity::new(self.name()));
84        }
85        claimed.or_else(|| self.is_authenticated().then(|| Identity::new(self.name())))
86    }
87
88    /// Whether this principal has `access` on `namespace` (exact match or `"*"`).
89    pub fn allows(&self, access: Access, namespace: &str) -> bool {
90        let scopes = match access {
91            Access::Read => &self.read,
92            Access::Write => &self.write,
93        };
94        scopes.iter().any(|s| s == "*" || s == namespace)
95    }
96}
97
98/// The daemon's token registry. `Disabled` means no auth was configured — every
99/// request is served as an implicit admin (local/library behavior).
100pub enum Auth {
101    Disabled,
102    Enabled(HashMap<String, Principal>),
103}
104
105impl Auth {
106    /// Resolve a bearer token to its principal. `Disabled` always yields an
107    /// implicit admin; `Enabled` yields the matching principal, or `None` when
108    /// the token is missing or unknown (the caller maps that to 401).
109    pub fn authenticate(&self, token: Option<&str>) -> Option<Principal> {
110        match self {
111            Auth::Disabled => Some(Principal::open()),
112            Auth::Enabled(by_token) => token.and_then(|t| by_token.get(t)).cloned(),
113        }
114    }
115
116    /// Parse a TOML principals file into an `Enabled` registry. Duplicate tokens
117    /// are an error (ambiguous identity).
118    pub fn parse_toml(s: &str) -> Result<Auth, String> {
119        #[derive(Deserialize)]
120        struct File {
121            #[serde(default)]
122            principal: Vec<Def>,
123        }
124        #[derive(Deserialize)]
125        struct Def {
126            name: String,
127            token: String,
128            #[serde(default)]
129            read: Vec<String>,
130            #[serde(default)]
131            write: Vec<String>,
132        }
133
134        let file: File = toml::from_str(s).map_err(|e| e.to_string())?;
135        let mut by_token = HashMap::new();
136        for def in file.principal {
137            let principal = Principal::new(def.name, def.read, def.write);
138            if by_token.insert(def.token, principal).is_some() {
139                return Err("duplicate token in auth file".to_string());
140            }
141        }
142        Ok(Auth::Enabled(by_token))
143    }
144
145    /// Resolve the daemon's auth from the environment (pure — `read_file` injects
146    /// the file IO so this is unit-testable):
147    ///
148    /// - `GONZALO_AUTH_FILE` → parse that TOML principals file;
149    /// - else `GONZALO_TOKEN` → a single admin principal (back-compat);
150    /// - else → `Disabled` (open).
151    pub fn from_env(
152        get: impl Fn(&str) -> Option<String>,
153        read_file: impl Fn(&str) -> Result<String, String>,
154    ) -> Result<Auth, String> {
155        if let Some(path) = get("GONZALO_AUTH_FILE").filter(|s| !s.is_empty()) {
156            Auth::parse_toml(&read_file(&path)?)
157        } else if let Some(token) = get("GONZALO_TOKEN").filter(|s| !s.is_empty()) {
158            Ok(Auth::Enabled(HashMap::from([(
159                token,
160                Principal::admin("root"),
161            )])))
162        } else {
163            Ok(Auth::Disabled)
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn allows_exact_wildcard_and_denies() {
174        let p = Principal::new("p", vec!["memory".into()], vec!["*".into()]);
175        assert!(p.allows(Access::Read, "memory"));
176        assert!(!p.allows(Access::Read, "sessions"));
177        // write is wildcard: any namespace.
178        assert!(p.allows(Access::Write, "sessions"));
179        assert!(p.allows(Access::Write, "anything"));
180    }
181
182    #[test]
183    fn parse_toml_builds_scoped_principals() {
184        let toml = r#"
185[[principal]]
186name  = "caliban"
187token = "s3cret"
188read  = ["memory", "sessions"]
189write = ["memory"]
190
191[[principal]]
192name  = "admin"
193token = "root"
194read  = ["*"]
195write = ["*"]
196"#;
197        let auth = Auth::parse_toml(toml).unwrap();
198        let caliban = auth.authenticate(Some("s3cret")).unwrap();
199        assert_eq!(caliban.name(), "caliban");
200        assert!(caliban.allows(Access::Read, "sessions"));
201        assert!(!caliban.allows(Access::Write, "sessions"));
202
203        let admin = auth.authenticate(Some("root")).unwrap();
204        assert!(admin.allows(Access::Write, "whatever"));
205
206        assert!(auth.authenticate(Some("nope")).is_none());
207        assert!(auth.authenticate(None).is_none());
208    }
209
210    #[test]
211    fn parse_toml_rejects_malformed_and_duplicate_tokens() {
212        assert!(Auth::parse_toml("not = valid = toml").is_err());
213        let dup = r#"
214[[principal]]
215name = "a"
216token = "same"
217[[principal]]
218name = "b"
219token = "same"
220"#;
221        assert!(Auth::parse_toml(dup).is_err());
222    }
223
224    #[test]
225    fn disabled_authenticates_everything_as_admin() {
226        let auth = Auth::Disabled;
227        let p = auth.authenticate(None).unwrap();
228        assert!(p.allows(Access::Write, "any"));
229    }
230
231    #[test]
232    fn is_admin_needs_wildcard_read_and_write() {
233        assert!(Principal::admin("root").is_admin());
234        // Open mode's implicit identity is an admin (it can purge).
235        assert!(Principal::open().is_admin());
236        // Wildcard on only one side is not an admin.
237        assert!(!Principal::new("r", vec!["*".into()], vec![]).is_admin());
238        assert!(!Principal::new("w", vec![], vec!["*".into()]).is_admin());
239        // Scoped principals are never admins.
240        assert!(!Principal::new("s", vec!["memory".into()], vec!["memory".into()]).is_admin());
241    }
242
243    #[test]
244    fn delete_author_follows_the_put_raw_rule() {
245        let writer = Principal::new("writer", vec!["memory".into()], vec!["memory".into()]);
246        assert_eq!(
247            writer.delete_author(Some(Identity::new("forged"))),
248            Some(Identity::new("writer"))
249        );
250        assert_eq!(writer.delete_author(None), Some(Identity::new("writer")));
251
252        let admin = Principal::admin("admin");
253        assert_eq!(
254            admin.delete_author(Some(Identity::new("origin"))),
255            Some(Identity::new("origin"))
256        );
257        assert_eq!(admin.delete_author(None), Some(Identity::new("admin")));
258
259        let open = Principal::open();
260        assert_eq!(
261            open.delete_author(Some(Identity::new("origin"))),
262            Some(Identity::new("origin"))
263        );
264        assert_eq!(open.delete_author(None), None);
265    }
266
267    #[test]
268    fn from_env_precedence_file_then_token_then_disabled() {
269        let file = |_: &str| Ok("[[principal]]\nname='a'\ntoken='t'\nread=['ns']\nwrite=[]".into());
270
271        // File wins when set.
272        let by_file = Auth::from_env(
273            |k| (k == "GONZALO_AUTH_FILE").then(|| "auth.toml".into()),
274            file,
275        )
276        .unwrap();
277        assert!(
278            by_file
279                .authenticate(Some("t"))
280                .unwrap()
281                .allows(Access::Read, "ns")
282        );
283
284        // Token → single admin.
285        let by_token = Auth::from_env(
286            |k| (k == "GONZALO_TOKEN").then(|| "root".into()),
287            |_| Err("no file".into()),
288        )
289        .unwrap();
290        assert!(
291            by_token
292                .authenticate(Some("root"))
293                .unwrap()
294                .allows(Access::Write, "x")
295        );
296
297        // Neither → disabled (open).
298        let disabled = Auth::from_env(|_| None, |_| Err("no file".into())).unwrap();
299        assert!(matches!(disabled, Auth::Disabled));
300    }
301}