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
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        // The primary role is one of the set, not a separate kind of thing.
301        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        // Holding a role is not the same as it being your headline one, which
307        // is what a hook context reports.
308        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        // …and only in the organisation where they are the admin.
329        assert!(!p.has_role_in(other, "billing"));
330        assert!(!p.is_admin_of(other));
331
332        // The rule is about checks, never about stored data: an admin's roles
333        // are still just the ones they were given, so taking one away is a
334        // change that means something.
335        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        // The primary role appearing again among the extras is a duplicate, not
342        // a second grant — otherwise removing one would appear to do nothing.
343        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}