Skip to main content

apiplant_auth/
lib.rs

1//! # apiplant-auth
2//!
3//! Authentication primitives, independent of HTTP so they can be unit-tested in
4//! isolation:
5//!
6//! * [`Authenticator`] — password hashing (argon2), JWT session tokens, and
7//!   API-key generation/hashing.
8//! * [`Principal`] — the resolved caller identity, including the organisations
9//!   they belong to and their **role within each** (roles are per-organisation).
10//!
11//! Authorization itself (mapping a resource's [`Access`](apiplant_core::Access)
12//! policy plus org context to a decision) lives in the server, where the active
13//! organisation and resource schema are known.
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use uuid::Uuid;
18
19/// Auth errors.
20#[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
28/// The role that satisfies every other.
29///
30/// An `admin` of an organisation holds, by definition, every role the app
31/// defines there — so a `role:billing` permission passes for them without
32/// anyone having to grant `billing` explicitly. This is a rule about *checks*,
33/// not about data: it is never written into anyone's roles, so removing a role
34/// from an admin cannot silently do nothing.
35pub const ADMIN_ROLE: &str = "admin";
36
37/// A user's membership in one organisation, and the roles they hold there.
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct OrgMembership {
40    pub org_id: Uuid,
41    /// The member's *primary* role, from `membership.role`. Kept as its own
42    /// field because that is the column apps and hook contexts already read.
43    pub role: Option<String>,
44    /// Every role held here, primary included — one entry per role, in the
45    /// order the database returned them.
46    pub roles: Vec<String>,
47    /// The organisation's `org_class`, carried along so a class-qualified
48    /// permission (`role:admin@org_class=school`) can be answered from the
49    /// principal alone, without a second query per request.
50    pub org_class: Option<String>,
51}
52
53impl OrgMembership {
54    /// Build a membership from its primary role and any additional ones,
55    /// keeping `roles` a set with the primary first.
56    pub fn new(
57        org_id: Uuid,
58        role: Option<String>,
59        extra: impl IntoIterator<Item = String>,
60    ) -> Self {
61        let mut roles: Vec<String> = Vec::new();
62        for candidate in role.clone().into_iter().chain(extra) {
63            if !candidate.is_empty() && !roles.contains(&candidate) {
64                roles.push(candidate);
65            }
66        }
67        OrgMembership {
68            org_id,
69            role,
70            roles,
71            org_class: None,
72        }
73    }
74
75    /// The same membership, in an organisation of `class`.
76    pub fn in_class(mut self, class: Option<String>) -> Self {
77        self.org_class = class.filter(|c| !c.is_empty());
78        self
79    }
80
81    /// Whether this organisation carries `class`.
82    pub fn is_class(&self, class: &str) -> bool {
83        self.org_class.as_deref() == Some(class)
84    }
85
86    /// Whether this membership carries `role` — directly, or by being `admin`.
87    pub fn has_role(&self, role: &str) -> bool {
88        self.roles.iter().any(|held| held == role) || self.is_admin()
89    }
90
91    /// Whether this membership holds `admin` itself.
92    pub fn is_admin(&self) -> bool {
93        self.roles.iter().any(|held| held == ADMIN_ROLE)
94    }
95}
96
97/// The authenticated caller behind a request.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Principal {
100    pub user_id: Uuid,
101    /// Organisations the caller belongs to (loaded per request). Drives all
102    /// org-scoped access and `role:` checks.
103    pub organizations: Vec<OrgMembership>,
104}
105
106impl Principal {
107    /// Membership in a specific organisation, if any.
108    pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
109        self.organizations.iter().find(|m| m.org_id == org)
110    }
111
112    /// Whether the caller belongs to `org`.
113    pub fn is_member(&self, org: Uuid) -> bool {
114        self.membership(org).is_some()
115    }
116
117    /// The caller's *primary* role within `org`, if any.
118    ///
119    /// This is what a hook context's `role` reports. Authorization asks
120    /// [`has_role_in`](Self::has_role_in) instead, because holding a role is
121    /// not the same as it being your headline one.
122    pub fn role_in(&self, org: Uuid) -> Option<&str> {
123        self.membership(org).and_then(|m| m.role.as_deref())
124    }
125
126    /// Every role the caller holds in `org`.
127    pub fn roles_in(&self, org: Uuid) -> &[String] {
128        self.membership(org)
129            .map(|m| m.roles.as_slice())
130            .unwrap_or(&[])
131    }
132
133    /// Whether the caller may act as `role` in `org`. The question every
134    /// `role:` permission asks.
135    pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
136        self.membership(org).is_some_and(|m| m.has_role(role))
137    }
138
139    /// The class of `org`, as seen through the caller's membership of it.
140    pub fn org_class_of(&self, org: Uuid) -> Option<&str> {
141        self.membership(org).and_then(|m| m.org_class.as_deref())
142    }
143
144    /// Whether the caller administers `org`.
145    pub fn is_admin_of(&self, org: Uuid) -> bool {
146        self.membership(org).is_some_and(OrgMembership::is_admin)
147    }
148
149    /// Every organisation id the caller belongs to.
150    pub fn org_ids(&self) -> Vec<Uuid> {
151        self.organizations.iter().map(|m| m.org_id).collect()
152    }
153
154    /// Organisations where the caller may act as `role` — including the ones
155    /// they merely administer.
156    pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
157        self.org_ids_with_role_in_class(role, None)
158    }
159
160    /// The same, narrowed to organisations of one class. `None` keeps every
161    /// class, which is what an unqualified permission means.
162    pub fn org_ids_with_role_in_class(&self, role: &str, class: Option<&str>) -> Vec<Uuid> {
163        self.organizations
164            .iter()
165            .filter(|m| m.has_role(role))
166            .filter(|m| class.is_none_or(|c| m.is_class(c)))
167            .map(|m| m.org_id)
168            .collect()
169    }
170
171    /// Every organisation the caller belongs to, optionally of one class.
172    pub fn org_ids_in_class(&self, class: Option<&str>) -> Vec<Uuid> {
173        self.organizations
174            .iter()
175            .filter(|m| class.is_none_or(|c| m.is_class(c)))
176            .map(|m| m.org_id)
177            .collect()
178    }
179}
180
181/// JWT claims for a session token. Org memberships are *not* baked in — they are
182/// resolved fresh from the database each request so changes take effect at once.
183#[derive(Debug, Serialize, Deserialize)]
184struct Claims {
185    /// Subject: the user id.
186    sub: String,
187    /// Expiry (unix seconds).
188    exp: i64,
189}
190
191/// Issues and verifies credentials for one running server.
192#[derive(Clone)]
193pub struct Authenticator {
194    secret: Vec<u8>,
195    session_ttl_secs: i64,
196}
197
198impl Authenticator {
199    pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
200        Authenticator {
201            secret: secret.into(),
202            session_ttl_secs: session_ttl_secs as i64,
203        }
204    }
205
206    // --- Passwords --------------------------------------------------------
207
208    /// Hash a plaintext password with argon2id (random salt).
209    pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
210        Self::hash_password_with_argon2(plaintext)
211    }
212
213    /// The same hash, without an authenticator to hold.
214    ///
215    /// Passwords are salted per hash and never touch the JWT secret, so this
216    /// needs no instance — which is what lets a fixture loaded before the
217    /// server exists (`seed/user.toml`) write a password somebody can sign in
218    /// with.
219    pub fn hash_password_with_argon2(plaintext: &str) -> Result<String, Error> {
220        use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
221        use argon2::Argon2;
222        let salt = SaltString::generate(&mut OsRng);
223        Argon2::default()
224            .hash_password(plaintext.as_bytes(), &salt)
225            .map(|h| h.to_string())
226            .map_err(|e| Error::Hash(e.to_string()))
227    }
228
229    /// Verify a plaintext password against a stored argon2 hash.
230    pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
231        use argon2::password_hash::{PasswordHash, PasswordVerifier};
232        use argon2::Argon2;
233        match PasswordHash::new(hash) {
234            Ok(parsed) => Argon2::default()
235                .verify_password(plaintext.as_bytes(), &parsed)
236                .is_ok(),
237            Err(_) => false,
238        }
239    }
240
241    // --- Session tokens ---------------------------------------------------
242
243    /// Mint a signed session JWT for a user id.
244    pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
245        use jsonwebtoken::{encode, EncodingKey, Header};
246        let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
247        let claims = Claims {
248            sub: user_id.to_string(),
249            exp,
250        };
251        encode(
252            &Header::default(),
253            &claims,
254            &EncodingKey::from_secret(&self.secret),
255        )
256        .map_err(|_| Error::Token)
257    }
258
259    /// Verify a session JWT and recover the user id it was issued for.
260    pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
261        use jsonwebtoken::{decode, DecodingKey, Validation};
262        let data = decode::<Claims>(
263            token,
264            &DecodingKey::from_secret(&self.secret),
265            &Validation::default(),
266        )
267        .map_err(|_| Error::Token)?;
268        Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
269    }
270
271    // --- API keys ---------------------------------------------------------
272
273    /// Generate a new API key: `(plaintext, sha256_hex)`. The plaintext is shown
274    /// to the user once; only the hash is stored.
275    pub fn generate_api_key(&self) -> (String, String) {
276        use rand::RngCore;
277        let mut bytes = [0u8; 32];
278        rand::thread_rng().fill_bytes(&mut bytes);
279        let plaintext = format!("apik_{}", hex::encode(bytes));
280        let hash = Self::hash_api_key(&plaintext);
281        (plaintext, hash)
282    }
283
284    /// Deterministic hash used to look an API key up. SHA-256 (not argon2) so a
285    /// single indexed equality lookup resolves the key.
286    pub fn hash_api_key(plaintext: &str) -> String {
287        let mut hasher = Sha256::new();
288        hasher.update(plaintext.as_bytes());
289        hex::encode(hasher.finalize())
290    }
291
292    // --- Emailed links ----------------------------------------------------
293
294    /// Generate a single-use token for a link sent to somebody's mailbox —
295    /// an invitation, an address confirmation, a password reset — as
296    /// `(plaintext, sha256_hex)`.
297    ///
298    /// The plaintext goes in the message and is never stored; the hash is what
299    /// the row holds, so the database alone yields no working link. `prefix`
300    /// is cosmetic (`inv`, `verify`, `reset`) and only makes a token in a log
301    /// or a support ticket identifiable at a glance.
302    ///
303    /// The same 256 bits of entropy an API key gets: these are bearer
304    /// credentials that create accounts and change passwords, so a shorter
305    /// token guessable in bulk would be the weakest thing in the system.
306    pub fn generate_link_token(prefix: &str) -> (String, String) {
307        use rand::RngCore;
308        let mut bytes = [0u8; 32];
309        rand::thread_rng().fill_bytes(&mut bytes);
310        let plaintext = format!("{prefix}_{}", hex::encode(bytes));
311        let hash = Self::hash_link_token(&plaintext);
312        (plaintext, hash)
313    }
314
315    /// The hash a [`generate_link_token`](Self::generate_link_token) token is
316    /// stored and looked up by.
317    pub fn hash_link_token(plaintext: &str) -> String {
318        Self::hash_api_key(plaintext)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn password_roundtrip() {
328        let auth = Authenticator::new(b"secret".to_vec(), 3600);
329        let hash = auth.hash_password("hunter2").unwrap();
330        assert!(auth.verify_password("hunter2", &hash));
331        assert!(!auth.verify_password("wrong", &hash));
332    }
333
334    #[test]
335    fn token_roundtrip() {
336        let auth = Authenticator::new(b"secret".to_vec(), 3600);
337        let id = Uuid::new_v4();
338        let token = auth.issue_token(id).unwrap();
339        assert_eq!(auth.verify_token(&token).unwrap(), id);
340    }
341
342    #[test]
343    fn api_key_hash_is_deterministic() {
344        assert_eq!(
345            Authenticator::hash_api_key("apik_abc"),
346            Authenticator::hash_api_key("apik_abc")
347        );
348    }
349
350    #[test]
351    fn a_link_token_is_stored_only_as_its_hash() {
352        let (plaintext, hash) = Authenticator::generate_link_token("inv");
353        assert!(plaintext.starts_with("inv_"));
354        // What a row holds must not be what the link carries, or a leaked
355        // database is a pile of working invitations.
356        assert_ne!(plaintext, hash);
357        assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
358
359        let (other, _) = Authenticator::generate_link_token("inv");
360        assert_ne!(plaintext, other);
361    }
362
363    #[test]
364    fn membership_lookup() {
365        let org = Uuid::new_v4();
366        let p = Principal {
367            user_id: Uuid::new_v4(),
368            organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
369        };
370        assert!(p.is_member(org));
371        assert_eq!(p.role_in(org), Some("support"));
372        assert!(!p.is_member(Uuid::new_v4()));
373        assert_eq!(p.org_ids_with_role("support"), vec![org]);
374        assert!(p.org_ids_with_role("billing").is_empty());
375    }
376
377    #[test]
378    fn a_member_holds_every_role_granted_to_them() {
379        let org = Uuid::new_v4();
380        let p = Principal {
381            user_id: Uuid::new_v4(),
382            organizations: vec![OrgMembership::new(
383                org,
384                Some("support".into()),
385                ["billing".to_string()],
386            )],
387        };
388
389        // The primary role is one of the set, not a separate kind of thing.
390        assert_eq!(p.roles_in(org), ["support", "billing"]);
391        assert!(p.has_role_in(org, "support"));
392        assert!(p.has_role_in(org, "billing"));
393        assert!(!p.has_role_in(org, "admin"));
394
395        // Holding a role is not the same as it being your headline one, which
396        // is what a hook context reports.
397        assert_eq!(p.role_in(org), Some("support"));
398    }
399
400    #[test]
401    fn an_admin_holds_every_role_without_being_granted_them() {
402        let org = Uuid::new_v4();
403        let other = Uuid::new_v4();
404        let p = Principal {
405            user_id: Uuid::new_v4(),
406            organizations: vec![
407                OrgMembership::new(org, Some("admin".into()), []),
408                OrgMembership::new(other, Some("support".into()), []),
409            ],
410        };
411
412        assert!(p.is_admin_of(org));
413        assert!(p.has_role_in(org, "billing"));
414        assert!(p.has_role_in(org, "anything-at-all"));
415        assert_eq!(p.org_ids_with_role("billing"), vec![org]);
416
417        // …and only in the organisation where they are the admin.
418        assert!(!p.has_role_in(other, "billing"));
419        assert!(!p.is_admin_of(other));
420
421        // The rule is about checks, never about stored data: an admin's roles
422        // are still just the ones they were given, so taking one away is a
423        // change that means something.
424        assert_eq!(p.roles_in(org), ["admin"]);
425    }
426
427    #[test]
428    fn a_role_held_twice_is_still_one_role() {
429        let org = Uuid::new_v4();
430        // The primary role appearing again among the extras is a duplicate, not
431        // a second grant — otherwise removing one would appear to do nothing.
432        let membership = OrgMembership::new(
433            org,
434            Some("admin".into()),
435            ["admin".to_string(), "billing".to_string(), String::new()],
436        );
437        assert_eq!(membership.roles, ["admin", "billing"]);
438    }
439
440    #[test]
441    fn memberships_carry_the_organisations_class() {
442        let school = Uuid::new_v4();
443        let staff = Uuid::new_v4();
444        let unclassed = Uuid::new_v4();
445        let p = Principal {
446            user_id: Uuid::new_v4(),
447            organizations: vec![
448                OrgMembership::new(school, Some("admin".into()), [])
449                    .in_class(Some("school".into())),
450                OrgMembership::new(staff, Some("admin".into()), []).in_class(Some("staff".into())),
451                OrgMembership::new(unclassed, Some("admin".into()), []),
452            ],
453        };
454
455        assert_eq!(p.org_class_of(school), Some("school"));
456        assert_eq!(p.org_class_of(unclassed), None);
457
458        // A class narrows; no class keeps everything, which is what an
459        // unqualified permission means.
460        assert_eq!(
461            p.org_ids_with_role_in_class("admin", Some("school")),
462            vec![school]
463        );
464        assert_eq!(p.org_ids_with_role("admin").len(), 3);
465        assert_eq!(p.org_ids_in_class(Some("staff")), vec![staff]);
466        assert_eq!(p.org_ids_in_class(None).len(), 3);
467
468        // An organisation with no class satisfies no qualifier at all.
469        assert!(p
470            .org_ids_with_role_in_class("admin", Some("customer"))
471            .is_empty());
472
473        // An empty class in the database is no class, not the empty one.
474        let blank = OrgMembership::new(school, None, []).in_class(Some(String::new()));
475        assert_eq!(blank.org_class, None);
476        assert!(!blank.is_class(""));
477    }
478
479    #[test]
480    fn a_member_with_no_role_holds_none() {
481        let org = Uuid::new_v4();
482        let membership = OrgMembership::new(org, None, []);
483        assert!(membership.roles.is_empty());
484        assert!(!membership.is_admin());
485        assert!(!membership.has_role("member"));
486    }
487}