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}
48
49impl OrgMembership {
50    /// Build a membership from its primary role and any additional ones,
51    /// keeping `roles` a set with the primary first.
52    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    /// Whether this membership carries `role` — directly, or by being `admin`.
71    pub fn has_role(&self, role: &str) -> bool {
72        self.roles.iter().any(|held| held == role) || self.is_admin()
73    }
74
75    /// Whether this membership holds `admin` itself.
76    pub fn is_admin(&self) -> bool {
77        self.roles.iter().any(|held| held == ADMIN_ROLE)
78    }
79}
80
81/// The authenticated caller behind a request.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Principal {
84    pub user_id: Uuid,
85    /// Organisations the caller belongs to (loaded per request). Drives all
86    /// org-scoped access and `role:` checks.
87    pub organizations: Vec<OrgMembership>,
88}
89
90impl Principal {
91    /// Membership in a specific organisation, if any.
92    pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
93        self.organizations.iter().find(|m| m.org_id == org)
94    }
95
96    /// Whether the caller belongs to `org`.
97    pub fn is_member(&self, org: Uuid) -> bool {
98        self.membership(org).is_some()
99    }
100
101    /// The caller's *primary* role within `org`, if any.
102    ///
103    /// This is what a hook context's `role` reports. Authorization asks
104    /// [`has_role_in`](Self::has_role_in) instead, because holding a role is
105    /// not the same as it being your headline one.
106    pub fn role_in(&self, org: Uuid) -> Option<&str> {
107        self.membership(org).and_then(|m| m.role.as_deref())
108    }
109
110    /// Every role the caller holds in `org`.
111    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    /// Whether the caller may act as `role` in `org`. The question every
118    /// `role:` permission asks.
119    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    /// Whether the caller administers `org`.
124    pub fn is_admin_of(&self, org: Uuid) -> bool {
125        self.membership(org).is_some_and(OrgMembership::is_admin)
126    }
127
128    /// Every organisation id the caller belongs to.
129    pub fn org_ids(&self) -> Vec<Uuid> {
130        self.organizations.iter().map(|m| m.org_id).collect()
131    }
132
133    /// Organisations where the caller may act as `role` — including the ones
134    /// they merely administer.
135    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/// JWT claims for a session token. Org memberships are *not* baked in — they are
145/// resolved fresh from the database each request so changes take effect at once.
146#[derive(Debug, Serialize, Deserialize)]
147struct Claims {
148    /// Subject: the user id.
149    sub: String,
150    /// Expiry (unix seconds).
151    exp: i64,
152}
153
154/// Issues and verifies credentials for one running server.
155#[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    // --- Passwords --------------------------------------------------------
170
171    /// Hash a plaintext password with argon2id (random salt).
172    pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
173        Self::hash_password_with_argon2(plaintext)
174    }
175
176    /// The same hash, without an authenticator to hold.
177    ///
178    /// Passwords are salted per hash and never touch the JWT secret, so this
179    /// needs no instance — which is what lets a fixture loaded before the
180    /// server exists (`seed/user.toml`) write a password somebody can sign in
181    /// with.
182    pub fn hash_password_with_argon2(plaintext: &str) -> Result<String, Error> {
183        use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
184        use argon2::Argon2;
185        let salt = SaltString::generate(&mut OsRng);
186        Argon2::default()
187            .hash_password(plaintext.as_bytes(), &salt)
188            .map(|h| h.to_string())
189            .map_err(|e| Error::Hash(e.to_string()))
190    }
191
192    /// Verify a plaintext password against a stored argon2 hash.
193    pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
194        use argon2::password_hash::{PasswordHash, PasswordVerifier};
195        use argon2::Argon2;
196        match PasswordHash::new(hash) {
197            Ok(parsed) => Argon2::default()
198                .verify_password(plaintext.as_bytes(), &parsed)
199                .is_ok(),
200            Err(_) => false,
201        }
202    }
203
204    // --- Session tokens ---------------------------------------------------
205
206    /// Mint a signed session JWT for a user id.
207    pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
208        use jsonwebtoken::{encode, EncodingKey, Header};
209        let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
210        let claims = Claims {
211            sub: user_id.to_string(),
212            exp,
213        };
214        encode(
215            &Header::default(),
216            &claims,
217            &EncodingKey::from_secret(&self.secret),
218        )
219        .map_err(|_| Error::Token)
220    }
221
222    /// Verify a session JWT and recover the user id it was issued for.
223    pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
224        use jsonwebtoken::{decode, DecodingKey, Validation};
225        let data = decode::<Claims>(
226            token,
227            &DecodingKey::from_secret(&self.secret),
228            &Validation::default(),
229        )
230        .map_err(|_| Error::Token)?;
231        Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
232    }
233
234    // --- API keys ---------------------------------------------------------
235
236    /// Generate a new API key: `(plaintext, sha256_hex)`. The plaintext is shown
237    /// to the user once; only the hash is stored.
238    pub fn generate_api_key(&self) -> (String, String) {
239        use rand::RngCore;
240        let mut bytes = [0u8; 32];
241        rand::thread_rng().fill_bytes(&mut bytes);
242        let plaintext = format!("apik_{}", hex::encode(bytes));
243        let hash = Self::hash_api_key(&plaintext);
244        (plaintext, hash)
245    }
246
247    /// Deterministic hash used to look an API key up. SHA-256 (not argon2) so a
248    /// single indexed equality lookup resolves the key.
249    pub fn hash_api_key(plaintext: &str) -> String {
250        let mut hasher = Sha256::new();
251        hasher.update(plaintext.as_bytes());
252        hex::encode(hasher.finalize())
253    }
254
255    // --- Emailed links ----------------------------------------------------
256
257    /// Generate a single-use token for a link sent to somebody's mailbox —
258    /// an invitation, an address confirmation, a password reset — as
259    /// `(plaintext, sha256_hex)`.
260    ///
261    /// The plaintext goes in the message and is never stored; the hash is what
262    /// the row holds, so the database alone yields no working link. `prefix`
263    /// is cosmetic (`inv`, `verify`, `reset`) and only makes a token in a log
264    /// or a support ticket identifiable at a glance.
265    ///
266    /// The same 256 bits of entropy an API key gets: these are bearer
267    /// credentials that create accounts and change passwords, so a shorter
268    /// token guessable in bulk would be the weakest thing in the system.
269    pub fn generate_link_token(prefix: &str) -> (String, String) {
270        use rand::RngCore;
271        let mut bytes = [0u8; 32];
272        rand::thread_rng().fill_bytes(&mut bytes);
273        let plaintext = format!("{prefix}_{}", hex::encode(bytes));
274        let hash = Self::hash_link_token(&plaintext);
275        (plaintext, hash)
276    }
277
278    /// The hash a [`generate_link_token`](Self::generate_link_token) token is
279    /// stored and looked up by.
280    pub fn hash_link_token(plaintext: &str) -> String {
281        Self::hash_api_key(plaintext)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn password_roundtrip() {
291        let auth = Authenticator::new(b"secret".to_vec(), 3600);
292        let hash = auth.hash_password("hunter2").unwrap();
293        assert!(auth.verify_password("hunter2", &hash));
294        assert!(!auth.verify_password("wrong", &hash));
295    }
296
297    #[test]
298    fn token_roundtrip() {
299        let auth = Authenticator::new(b"secret".to_vec(), 3600);
300        let id = Uuid::new_v4();
301        let token = auth.issue_token(id).unwrap();
302        assert_eq!(auth.verify_token(&token).unwrap(), id);
303    }
304
305    #[test]
306    fn api_key_hash_is_deterministic() {
307        assert_eq!(
308            Authenticator::hash_api_key("apik_abc"),
309            Authenticator::hash_api_key("apik_abc")
310        );
311    }
312
313    #[test]
314    fn a_link_token_is_stored_only_as_its_hash() {
315        let (plaintext, hash) = Authenticator::generate_link_token("inv");
316        assert!(plaintext.starts_with("inv_"));
317        // What a row holds must not be what the link carries, or a leaked
318        // database is a pile of working invitations.
319        assert_ne!(plaintext, hash);
320        assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
321
322        let (other, _) = Authenticator::generate_link_token("inv");
323        assert_ne!(plaintext, other);
324    }
325
326    #[test]
327    fn membership_lookup() {
328        let org = Uuid::new_v4();
329        let p = Principal {
330            user_id: Uuid::new_v4(),
331            organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
332        };
333        assert!(p.is_member(org));
334        assert_eq!(p.role_in(org), Some("support"));
335        assert!(!p.is_member(Uuid::new_v4()));
336        assert_eq!(p.org_ids_with_role("support"), vec![org]);
337        assert!(p.org_ids_with_role("billing").is_empty());
338    }
339
340    #[test]
341    fn a_member_holds_every_role_granted_to_them() {
342        let org = Uuid::new_v4();
343        let p = Principal {
344            user_id: Uuid::new_v4(),
345            organizations: vec![OrgMembership::new(
346                org,
347                Some("support".into()),
348                ["billing".to_string()],
349            )],
350        };
351
352        // The primary role is one of the set, not a separate kind of thing.
353        assert_eq!(p.roles_in(org), ["support", "billing"]);
354        assert!(p.has_role_in(org, "support"));
355        assert!(p.has_role_in(org, "billing"));
356        assert!(!p.has_role_in(org, "admin"));
357
358        // Holding a role is not the same as it being your headline one, which
359        // is what a hook context reports.
360        assert_eq!(p.role_in(org), Some("support"));
361    }
362
363    #[test]
364    fn an_admin_holds_every_role_without_being_granted_them() {
365        let org = Uuid::new_v4();
366        let other = Uuid::new_v4();
367        let p = Principal {
368            user_id: Uuid::new_v4(),
369            organizations: vec![
370                OrgMembership::new(org, Some("admin".into()), []),
371                OrgMembership::new(other, Some("support".into()), []),
372            ],
373        };
374
375        assert!(p.is_admin_of(org));
376        assert!(p.has_role_in(org, "billing"));
377        assert!(p.has_role_in(org, "anything-at-all"));
378        assert_eq!(p.org_ids_with_role("billing"), vec![org]);
379
380        // …and only in the organisation where they are the admin.
381        assert!(!p.has_role_in(other, "billing"));
382        assert!(!p.is_admin_of(other));
383
384        // The rule is about checks, never about stored data: an admin's roles
385        // are still just the ones they were given, so taking one away is a
386        // change that means something.
387        assert_eq!(p.roles_in(org), ["admin"]);
388    }
389
390    #[test]
391    fn a_role_held_twice_is_still_one_role() {
392        let org = Uuid::new_v4();
393        // The primary role appearing again among the extras is a duplicate, not
394        // a second grant — otherwise removing one would appear to do nothing.
395        let membership = OrgMembership::new(
396            org,
397            Some("admin".into()),
398            ["admin".to_string(), "billing".to_string(), String::new()],
399        );
400        assert_eq!(membership.roles, ["admin", "billing"]);
401    }
402
403    #[test]
404    fn a_member_with_no_role_holds_none() {
405        let org = Uuid::new_v4();
406        let membership = OrgMembership::new(org, None, []);
407        assert!(membership.roles.is_empty());
408        assert!(!membership.is_admin());
409        assert!(!membership.has_role("member"));
410    }
411}