1use std::collections::HashMap;
10
11use serde::Deserialize;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Access {
16 Read,
17 Write,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Principal {
24 name: String,
25 read: Vec<String>,
26 write: Vec<String>,
27 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 pub fn admin(name: impl Into<String>) -> Self {
45 Self::new(name, vec!["*".into()], vec!["*".into()])
46 }
47
48 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 pub fn is_authenticated(&self) -> bool {
64 self.authenticated
65 }
66
67 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
77pub enum Auth {
80 Disabled,
81 Enabled(HashMap<String, Principal>),
82}
83
84impl Auth {
85 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 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 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 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 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 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 let disabled = Auth::from_env(|_| None, |_| Err("no file".into())).unwrap();
242 assert!(matches!(disabled, Auth::Disabled));
243 }
244}