1use std::collections::HashMap;
10
11use gonzalo_core::Identity;
12use serde::Deserialize;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Access {
17 Read,
18 Write,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Principal {
25 name: String,
26 read: Vec<String>,
27 write: Vec<String>,
28 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 pub fn admin(name: impl Into<String>) -> Self {
46 Self::new(name, vec!["*".into()], vec!["*".into()])
47 }
48
49 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 pub fn is_authenticated(&self) -> bool {
65 self.authenticated
66 }
67
68 pub fn is_admin(&self) -> bool {
74 self.read.iter().any(|s| s == "*") && self.write.iter().any(|s| s == "*")
75 }
76
77 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 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
98pub enum Auth {
101 Disabled,
102 Enabled(HashMap<String, Principal>),
103}
104
105impl Auth {
106 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 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 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 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 assert!(Principal::open().is_admin());
236 assert!(!Principal::new("r", vec!["*".into()], vec![]).is_admin());
238 assert!(!Principal::new("w", vec![], vec!["*".into()]).is_admin());
239 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 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 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 let disabled = Auth::from_env(|_| None, |_| Err("no file".into())).unwrap();
299 assert!(matches!(disabled, Auth::Disabled));
300 }
301}