1use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use uuid::Uuid;
18
19#[derive(thiserror::Error, Debug)]
21pub enum Error {
22 #[error("password hashing failed: {0}")]
23 Hash(String),
24 #[error("invalid or expired token")]
25 Token,
26}
27
28pub const ADMIN_ROLE: &str = "admin";
36
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct OrgMembership {
40 pub org_id: Uuid,
41 pub role: Option<String>,
44 pub roles: Vec<String>,
47}
48
49impl OrgMembership {
50 pub fn new(
53 org_id: Uuid,
54 role: Option<String>,
55 extra: impl IntoIterator<Item = String>,
56 ) -> Self {
57 let mut roles: Vec<String> = Vec::new();
58 for candidate in role.clone().into_iter().chain(extra) {
59 if !candidate.is_empty() && !roles.contains(&candidate) {
60 roles.push(candidate);
61 }
62 }
63 OrgMembership {
64 org_id,
65 role,
66 roles,
67 }
68 }
69
70 pub fn has_role(&self, role: &str) -> bool {
72 self.roles.iter().any(|held| held == role) || self.is_admin()
73 }
74
75 pub fn is_admin(&self) -> bool {
77 self.roles.iter().any(|held| held == ADMIN_ROLE)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Principal {
84 pub user_id: Uuid,
85 pub organizations: Vec<OrgMembership>,
88}
89
90impl Principal {
91 pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
93 self.organizations.iter().find(|m| m.org_id == org)
94 }
95
96 pub fn is_member(&self, org: Uuid) -> bool {
98 self.membership(org).is_some()
99 }
100
101 pub fn role_in(&self, org: Uuid) -> Option<&str> {
107 self.membership(org).and_then(|m| m.role.as_deref())
108 }
109
110 pub fn roles_in(&self, org: Uuid) -> &[String] {
112 self.membership(org)
113 .map(|m| m.roles.as_slice())
114 .unwrap_or(&[])
115 }
116
117 pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
120 self.membership(org).is_some_and(|m| m.has_role(role))
121 }
122
123 pub fn is_admin_of(&self, org: Uuid) -> bool {
125 self.membership(org).is_some_and(OrgMembership::is_admin)
126 }
127
128 pub fn org_ids(&self) -> Vec<Uuid> {
130 self.organizations.iter().map(|m| m.org_id).collect()
131 }
132
133 pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
136 self.organizations
137 .iter()
138 .filter(|m| m.has_role(role))
139 .map(|m| m.org_id)
140 .collect()
141 }
142}
143
144#[derive(Debug, Serialize, Deserialize)]
147struct Claims {
148 sub: String,
150 exp: i64,
152}
153
154#[derive(Clone)]
156pub struct Authenticator {
157 secret: Vec<u8>,
158 session_ttl_secs: i64,
159}
160
161impl Authenticator {
162 pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
163 Authenticator {
164 secret: secret.into(),
165 session_ttl_secs: session_ttl_secs as i64,
166 }
167 }
168
169 pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
173 use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
174 use argon2::Argon2;
175 let salt = SaltString::generate(&mut OsRng);
176 Argon2::default()
177 .hash_password(plaintext.as_bytes(), &salt)
178 .map(|h| h.to_string())
179 .map_err(|e| Error::Hash(e.to_string()))
180 }
181
182 pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
184 use argon2::password_hash::{PasswordHash, PasswordVerifier};
185 use argon2::Argon2;
186 match PasswordHash::new(hash) {
187 Ok(parsed) => Argon2::default()
188 .verify_password(plaintext.as_bytes(), &parsed)
189 .is_ok(),
190 Err(_) => false,
191 }
192 }
193
194 pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
198 use jsonwebtoken::{encode, EncodingKey, Header};
199 let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
200 let claims = Claims {
201 sub: user_id.to_string(),
202 exp,
203 };
204 encode(
205 &Header::default(),
206 &claims,
207 &EncodingKey::from_secret(&self.secret),
208 )
209 .map_err(|_| Error::Token)
210 }
211
212 pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
214 use jsonwebtoken::{decode, DecodingKey, Validation};
215 let data = decode::<Claims>(
216 token,
217 &DecodingKey::from_secret(&self.secret),
218 &Validation::default(),
219 )
220 .map_err(|_| Error::Token)?;
221 Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
222 }
223
224 pub fn generate_api_key(&self) -> (String, String) {
229 use rand::RngCore;
230 let mut bytes = [0u8; 32];
231 rand::thread_rng().fill_bytes(&mut bytes);
232 let plaintext = format!("apik_{}", hex::encode(bytes));
233 let hash = Self::hash_api_key(&plaintext);
234 (plaintext, hash)
235 }
236
237 pub fn hash_api_key(plaintext: &str) -> String {
240 let mut hasher = Sha256::new();
241 hasher.update(plaintext.as_bytes());
242 hex::encode(hasher.finalize())
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn password_roundtrip() {
252 let auth = Authenticator::new(b"secret".to_vec(), 3600);
253 let hash = auth.hash_password("hunter2").unwrap();
254 assert!(auth.verify_password("hunter2", &hash));
255 assert!(!auth.verify_password("wrong", &hash));
256 }
257
258 #[test]
259 fn token_roundtrip() {
260 let auth = Authenticator::new(b"secret".to_vec(), 3600);
261 let id = Uuid::new_v4();
262 let token = auth.issue_token(id).unwrap();
263 assert_eq!(auth.verify_token(&token).unwrap(), id);
264 }
265
266 #[test]
267 fn api_key_hash_is_deterministic() {
268 assert_eq!(
269 Authenticator::hash_api_key("apik_abc"),
270 Authenticator::hash_api_key("apik_abc")
271 );
272 }
273
274 #[test]
275 fn membership_lookup() {
276 let org = Uuid::new_v4();
277 let p = Principal {
278 user_id: Uuid::new_v4(),
279 organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
280 };
281 assert!(p.is_member(org));
282 assert_eq!(p.role_in(org), Some("support"));
283 assert!(!p.is_member(Uuid::new_v4()));
284 assert_eq!(p.org_ids_with_role("support"), vec![org]);
285 assert!(p.org_ids_with_role("billing").is_empty());
286 }
287
288 #[test]
289 fn a_member_holds_every_role_granted_to_them() {
290 let org = Uuid::new_v4();
291 let p = Principal {
292 user_id: Uuid::new_v4(),
293 organizations: vec![OrgMembership::new(
294 org,
295 Some("support".into()),
296 ["billing".to_string()],
297 )],
298 };
299
300 assert_eq!(p.roles_in(org), ["support", "billing"]);
302 assert!(p.has_role_in(org, "support"));
303 assert!(p.has_role_in(org, "billing"));
304 assert!(!p.has_role_in(org, "admin"));
305
306 assert_eq!(p.role_in(org), Some("support"));
309 }
310
311 #[test]
312 fn an_admin_holds_every_role_without_being_granted_them() {
313 let org = Uuid::new_v4();
314 let other = Uuid::new_v4();
315 let p = Principal {
316 user_id: Uuid::new_v4(),
317 organizations: vec![
318 OrgMembership::new(org, Some("admin".into()), []),
319 OrgMembership::new(other, Some("support".into()), []),
320 ],
321 };
322
323 assert!(p.is_admin_of(org));
324 assert!(p.has_role_in(org, "billing"));
325 assert!(p.has_role_in(org, "anything-at-all"));
326 assert_eq!(p.org_ids_with_role("billing"), vec![org]);
327
328 assert!(!p.has_role_in(other, "billing"));
330 assert!(!p.is_admin_of(other));
331
332 assert_eq!(p.roles_in(org), ["admin"]);
336 }
337
338 #[test]
339 fn a_role_held_twice_is_still_one_role() {
340 let org = Uuid::new_v4();
341 let membership = OrgMembership::new(
344 org,
345 Some("admin".into()),
346 ["admin".to_string(), "billing".to_string(), String::new()],
347 );
348 assert_eq!(membership.roles, ["admin", "billing"]);
349 }
350
351 #[test]
352 fn a_member_with_no_role_holds_none() {
353 let org = Uuid::new_v4();
354 let membership = OrgMembership::new(org, None, []);
355 assert!(membership.roles.is_empty());
356 assert!(!membership.is_admin());
357 assert!(!membership.has_role("member"));
358 }
359}