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        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    /// Verify a plaintext password against a stored argon2 hash.
183    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    // --- Session tokens ---------------------------------------------------
195
196    /// Mint a signed session JWT for a user id.
197    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    /// Verify a session JWT and recover the user id it was issued for.
213    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    // --- API keys ---------------------------------------------------------
225
226    /// Generate a new API key: `(plaintext, sha256_hex)`. The plaintext is shown
227    /// to the user once; only the hash is stored.
228    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    /// Deterministic hash used to look an API key up. SHA-256 (not argon2) so a
238    /// single indexed equality lookup resolves the key.
239    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    // --- Emailed links ----------------------------------------------------
246
247    /// Generate a single-use token for a link sent to somebody's mailbox —
248    /// an invitation, an address confirmation, a password reset — as
249    /// `(plaintext, sha256_hex)`.
250    ///
251    /// The plaintext goes in the message and is never stored; the hash is what
252    /// the row holds, so the database alone yields no working link. `prefix`
253    /// is cosmetic (`inv`, `verify`, `reset`) and only makes a token in a log
254    /// or a support ticket identifiable at a glance.
255    ///
256    /// The same 256 bits of entropy an API key gets: these are bearer
257    /// credentials that create accounts and change passwords, so a shorter
258    /// token guessable in bulk would be the weakest thing in the system.
259    pub fn generate_link_token(prefix: &str) -> (String, String) {
260        use rand::RngCore;
261        let mut bytes = [0u8; 32];
262        rand::thread_rng().fill_bytes(&mut bytes);
263        let plaintext = format!("{prefix}_{}", hex::encode(bytes));
264        let hash = Self::hash_link_token(&plaintext);
265        (plaintext, hash)
266    }
267
268    /// The hash a [`generate_link_token`](Self::generate_link_token) token is
269    /// stored and looked up by.
270    pub fn hash_link_token(plaintext: &str) -> String {
271        Self::hash_api_key(plaintext)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn password_roundtrip() {
281        let auth = Authenticator::new(b"secret".to_vec(), 3600);
282        let hash = auth.hash_password("hunter2").unwrap();
283        assert!(auth.verify_password("hunter2", &hash));
284        assert!(!auth.verify_password("wrong", &hash));
285    }
286
287    #[test]
288    fn token_roundtrip() {
289        let auth = Authenticator::new(b"secret".to_vec(), 3600);
290        let id = Uuid::new_v4();
291        let token = auth.issue_token(id).unwrap();
292        assert_eq!(auth.verify_token(&token).unwrap(), id);
293    }
294
295    #[test]
296    fn api_key_hash_is_deterministic() {
297        assert_eq!(
298            Authenticator::hash_api_key("apik_abc"),
299            Authenticator::hash_api_key("apik_abc")
300        );
301    }
302
303    #[test]
304    fn a_link_token_is_stored_only_as_its_hash() {
305        let (plaintext, hash) = Authenticator::generate_link_token("inv");
306        assert!(plaintext.starts_with("inv_"));
307        // What a row holds must not be what the link carries, or a leaked
308        // database is a pile of working invitations.
309        assert_ne!(plaintext, hash);
310        assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
311
312        let (other, _) = Authenticator::generate_link_token("inv");
313        assert_ne!(plaintext, other);
314    }
315
316    #[test]
317    fn membership_lookup() {
318        let org = Uuid::new_v4();
319        let p = Principal {
320            user_id: Uuid::new_v4(),
321            organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
322        };
323        assert!(p.is_member(org));
324        assert_eq!(p.role_in(org), Some("support"));
325        assert!(!p.is_member(Uuid::new_v4()));
326        assert_eq!(p.org_ids_with_role("support"), vec![org]);
327        assert!(p.org_ids_with_role("billing").is_empty());
328    }
329
330    #[test]
331    fn a_member_holds_every_role_granted_to_them() {
332        let org = Uuid::new_v4();
333        let p = Principal {
334            user_id: Uuid::new_v4(),
335            organizations: vec![OrgMembership::new(
336                org,
337                Some("support".into()),
338                ["billing".to_string()],
339            )],
340        };
341
342        // The primary role is one of the set, not a separate kind of thing.
343        assert_eq!(p.roles_in(org), ["support", "billing"]);
344        assert!(p.has_role_in(org, "support"));
345        assert!(p.has_role_in(org, "billing"));
346        assert!(!p.has_role_in(org, "admin"));
347
348        // Holding a role is not the same as it being your headline one, which
349        // is what a hook context reports.
350        assert_eq!(p.role_in(org), Some("support"));
351    }
352
353    #[test]
354    fn an_admin_holds_every_role_without_being_granted_them() {
355        let org = Uuid::new_v4();
356        let other = Uuid::new_v4();
357        let p = Principal {
358            user_id: Uuid::new_v4(),
359            organizations: vec![
360                OrgMembership::new(org, Some("admin".into()), []),
361                OrgMembership::new(other, Some("support".into()), []),
362            ],
363        };
364
365        assert!(p.is_admin_of(org));
366        assert!(p.has_role_in(org, "billing"));
367        assert!(p.has_role_in(org, "anything-at-all"));
368        assert_eq!(p.org_ids_with_role("billing"), vec![org]);
369
370        // …and only in the organisation where they are the admin.
371        assert!(!p.has_role_in(other, "billing"));
372        assert!(!p.is_admin_of(other));
373
374        // The rule is about checks, never about stored data: an admin's roles
375        // are still just the ones they were given, so taking one away is a
376        // change that means something.
377        assert_eq!(p.roles_in(org), ["admin"]);
378    }
379
380    #[test]
381    fn a_role_held_twice_is_still_one_role() {
382        let org = Uuid::new_v4();
383        // The primary role appearing again among the extras is a duplicate, not
384        // a second grant — otherwise removing one would appear to do nothing.
385        let membership = OrgMembership::new(
386            org,
387            Some("admin".into()),
388            ["admin".to_string(), "billing".to_string(), String::new()],
389        );
390        assert_eq!(membership.roles, ["admin", "billing"]);
391    }
392
393    #[test]
394    fn a_member_with_no_role_holds_none() {
395        let org = Uuid::new_v4();
396        let membership = OrgMembership::new(org, None, []);
397        assert!(membership.roles.is_empty());
398        assert!(!membership.is_admin());
399        assert!(!membership.has_role("member"));
400    }
401}