apiplant-auth 0.5.0

apiplant authentication: passwords, JWT sessions, API keys and permission checks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! # apiplant-auth
//!
//! Authentication primitives, independent of HTTP so they can be unit-tested in
//! isolation:
//!
//! * [`Authenticator`] — password hashing (argon2), JWT session tokens, and
//!   API-key generation/hashing.
//! * [`Principal`] — the resolved caller identity, including the organisations
//!   they belong to and their **role within each** (roles are per-organisation).
//!
//! Authorization itself (mapping a resource's [`Access`](apiplant_core::Access)
//! policy plus org context to a decision) lives in the server, where the active
//! organisation and resource schema are known.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;

/// Auth errors.
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("password hashing failed: {0}")]
    Hash(String),
    #[error("invalid or expired token")]
    Token,
}

/// The role that satisfies every other.
///
/// An `admin` of an organisation holds, by definition, every role the app
/// defines there — so a `role:billing` permission passes for them without
/// anyone having to grant `billing` explicitly. This is a rule about *checks*,
/// not about data: it is never written into anyone's roles, so removing a role
/// from an admin cannot silently do nothing.
pub const ADMIN_ROLE: &str = "admin";

/// A user's membership in one organisation, and the roles they hold there.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct OrgMembership {
    pub org_id: Uuid,
    /// The member's *primary* role, from `membership.role`. Kept as its own
    /// field because that is the column apps and hook contexts already read.
    pub role: Option<String>,
    /// Every role held here, primary included — one entry per role, in the
    /// order the database returned them.
    pub roles: Vec<String>,
}

impl OrgMembership {
    /// Build a membership from its primary role and any additional ones,
    /// keeping `roles` a set with the primary first.
    pub fn new(
        org_id: Uuid,
        role: Option<String>,
        extra: impl IntoIterator<Item = String>,
    ) -> Self {
        let mut roles: Vec<String> = Vec::new();
        for candidate in role.clone().into_iter().chain(extra) {
            if !candidate.is_empty() && !roles.contains(&candidate) {
                roles.push(candidate);
            }
        }
        OrgMembership {
            org_id,
            role,
            roles,
        }
    }

    /// Whether this membership carries `role` — directly, or by being `admin`.
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|held| held == role) || self.is_admin()
    }

    /// Whether this membership holds `admin` itself.
    pub fn is_admin(&self) -> bool {
        self.roles.iter().any(|held| held == ADMIN_ROLE)
    }
}

/// The authenticated caller behind a request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
    pub user_id: Uuid,
    /// Organisations the caller belongs to (loaded per request). Drives all
    /// org-scoped access and `role:` checks.
    pub organizations: Vec<OrgMembership>,
}

impl Principal {
    /// Membership in a specific organisation, if any.
    pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
        self.organizations.iter().find(|m| m.org_id == org)
    }

    /// Whether the caller belongs to `org`.
    pub fn is_member(&self, org: Uuid) -> bool {
        self.membership(org).is_some()
    }

    /// The caller's *primary* role within `org`, if any.
    ///
    /// This is what a hook context's `role` reports. Authorization asks
    /// [`has_role_in`](Self::has_role_in) instead, because holding a role is
    /// not the same as it being your headline one.
    pub fn role_in(&self, org: Uuid) -> Option<&str> {
        self.membership(org).and_then(|m| m.role.as_deref())
    }

    /// Every role the caller holds in `org`.
    pub fn roles_in(&self, org: Uuid) -> &[String] {
        self.membership(org)
            .map(|m| m.roles.as_slice())
            .unwrap_or(&[])
    }

    /// Whether the caller may act as `role` in `org`. The question every
    /// `role:` permission asks.
    pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
        self.membership(org).is_some_and(|m| m.has_role(role))
    }

    /// Whether the caller administers `org`.
    pub fn is_admin_of(&self, org: Uuid) -> bool {
        self.membership(org).is_some_and(OrgMembership::is_admin)
    }

    /// Every organisation id the caller belongs to.
    pub fn org_ids(&self) -> Vec<Uuid> {
        self.organizations.iter().map(|m| m.org_id).collect()
    }

    /// Organisations where the caller may act as `role` — including the ones
    /// they merely administer.
    pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
        self.organizations
            .iter()
            .filter(|m| m.has_role(role))
            .map(|m| m.org_id)
            .collect()
    }
}

/// JWT claims for a session token. Org memberships are *not* baked in — they are
/// resolved fresh from the database each request so changes take effect at once.
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    /// Subject: the user id.
    sub: String,
    /// Expiry (unix seconds).
    exp: i64,
}

/// Issues and verifies credentials for one running server.
#[derive(Clone)]
pub struct Authenticator {
    secret: Vec<u8>,
    session_ttl_secs: i64,
}

impl Authenticator {
    pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
        Authenticator {
            secret: secret.into(),
            session_ttl_secs: session_ttl_secs as i64,
        }
    }

    // --- Passwords --------------------------------------------------------

    /// Hash a plaintext password with argon2id (random salt).
    pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
        Self::hash_password_with_argon2(plaintext)
    }

    /// The same hash, without an authenticator to hold.
    ///
    /// Passwords are salted per hash and never touch the JWT secret, so this
    /// needs no instance — which is what lets a fixture loaded before the
    /// server exists (`seed/user.toml`) write a password somebody can sign in
    /// with.
    pub fn hash_password_with_argon2(plaintext: &str) -> Result<String, Error> {
        use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
        use argon2::Argon2;
        let salt = SaltString::generate(&mut OsRng);
        Argon2::default()
            .hash_password(plaintext.as_bytes(), &salt)
            .map(|h| h.to_string())
            .map_err(|e| Error::Hash(e.to_string()))
    }

    /// Verify a plaintext password against a stored argon2 hash.
    pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
        use argon2::password_hash::{PasswordHash, PasswordVerifier};
        use argon2::Argon2;
        match PasswordHash::new(hash) {
            Ok(parsed) => Argon2::default()
                .verify_password(plaintext.as_bytes(), &parsed)
                .is_ok(),
            Err(_) => false,
        }
    }

    // --- Session tokens ---------------------------------------------------

    /// Mint a signed session JWT for a user id.
    pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
        use jsonwebtoken::{encode, EncodingKey, Header};
        let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
        let claims = Claims {
            sub: user_id.to_string(),
            exp,
        };
        encode(
            &Header::default(),
            &claims,
            &EncodingKey::from_secret(&self.secret),
        )
        .map_err(|_| Error::Token)
    }

    /// Verify a session JWT and recover the user id it was issued for.
    pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
        use jsonwebtoken::{decode, DecodingKey, Validation};
        let data = decode::<Claims>(
            token,
            &DecodingKey::from_secret(&self.secret),
            &Validation::default(),
        )
        .map_err(|_| Error::Token)?;
        Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
    }

    // --- API keys ---------------------------------------------------------

    /// Generate a new API key: `(plaintext, sha256_hex)`. The plaintext is shown
    /// to the user once; only the hash is stored.
    pub fn generate_api_key(&self) -> (String, String) {
        use rand::RngCore;
        let mut bytes = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut bytes);
        let plaintext = format!("apik_{}", hex::encode(bytes));
        let hash = Self::hash_api_key(&plaintext);
        (plaintext, hash)
    }

    /// Deterministic hash used to look an API key up. SHA-256 (not argon2) so a
    /// single indexed equality lookup resolves the key.
    pub fn hash_api_key(plaintext: &str) -> String {
        let mut hasher = Sha256::new();
        hasher.update(plaintext.as_bytes());
        hex::encode(hasher.finalize())
    }

    // --- Emailed links ----------------------------------------------------

    /// Generate a single-use token for a link sent to somebody's mailbox —
    /// an invitation, an address confirmation, a password reset — as
    /// `(plaintext, sha256_hex)`.
    ///
    /// The plaintext goes in the message and is never stored; the hash is what
    /// the row holds, so the database alone yields no working link. `prefix`
    /// is cosmetic (`inv`, `verify`, `reset`) and only makes a token in a log
    /// or a support ticket identifiable at a glance.
    ///
    /// The same 256 bits of entropy an API key gets: these are bearer
    /// credentials that create accounts and change passwords, so a shorter
    /// token guessable in bulk would be the weakest thing in the system.
    pub fn generate_link_token(prefix: &str) -> (String, String) {
        use rand::RngCore;
        let mut bytes = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut bytes);
        let plaintext = format!("{prefix}_{}", hex::encode(bytes));
        let hash = Self::hash_link_token(&plaintext);
        (plaintext, hash)
    }

    /// The hash a [`generate_link_token`](Self::generate_link_token) token is
    /// stored and looked up by.
    pub fn hash_link_token(plaintext: &str) -> String {
        Self::hash_api_key(plaintext)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn password_roundtrip() {
        let auth = Authenticator::new(b"secret".to_vec(), 3600);
        let hash = auth.hash_password("hunter2").unwrap();
        assert!(auth.verify_password("hunter2", &hash));
        assert!(!auth.verify_password("wrong", &hash));
    }

    #[test]
    fn token_roundtrip() {
        let auth = Authenticator::new(b"secret".to_vec(), 3600);
        let id = Uuid::new_v4();
        let token = auth.issue_token(id).unwrap();
        assert_eq!(auth.verify_token(&token).unwrap(), id);
    }

    #[test]
    fn api_key_hash_is_deterministic() {
        assert_eq!(
            Authenticator::hash_api_key("apik_abc"),
            Authenticator::hash_api_key("apik_abc")
        );
    }

    #[test]
    fn a_link_token_is_stored_only_as_its_hash() {
        let (plaintext, hash) = Authenticator::generate_link_token("inv");
        assert!(plaintext.starts_with("inv_"));
        // What a row holds must not be what the link carries, or a leaked
        // database is a pile of working invitations.
        assert_ne!(plaintext, hash);
        assert_eq!(Authenticator::hash_link_token(&plaintext), hash);

        let (other, _) = Authenticator::generate_link_token("inv");
        assert_ne!(plaintext, other);
    }

    #[test]
    fn membership_lookup() {
        let org = Uuid::new_v4();
        let p = Principal {
            user_id: Uuid::new_v4(),
            organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
        };
        assert!(p.is_member(org));
        assert_eq!(p.role_in(org), Some("support"));
        assert!(!p.is_member(Uuid::new_v4()));
        assert_eq!(p.org_ids_with_role("support"), vec![org]);
        assert!(p.org_ids_with_role("billing").is_empty());
    }

    #[test]
    fn a_member_holds_every_role_granted_to_them() {
        let org = Uuid::new_v4();
        let p = Principal {
            user_id: Uuid::new_v4(),
            organizations: vec![OrgMembership::new(
                org,
                Some("support".into()),
                ["billing".to_string()],
            )],
        };

        // The primary role is one of the set, not a separate kind of thing.
        assert_eq!(p.roles_in(org), ["support", "billing"]);
        assert!(p.has_role_in(org, "support"));
        assert!(p.has_role_in(org, "billing"));
        assert!(!p.has_role_in(org, "admin"));

        // Holding a role is not the same as it being your headline one, which
        // is what a hook context reports.
        assert_eq!(p.role_in(org), Some("support"));
    }

    #[test]
    fn an_admin_holds_every_role_without_being_granted_them() {
        let org = Uuid::new_v4();
        let other = Uuid::new_v4();
        let p = Principal {
            user_id: Uuid::new_v4(),
            organizations: vec![
                OrgMembership::new(org, Some("admin".into()), []),
                OrgMembership::new(other, Some("support".into()), []),
            ],
        };

        assert!(p.is_admin_of(org));
        assert!(p.has_role_in(org, "billing"));
        assert!(p.has_role_in(org, "anything-at-all"));
        assert_eq!(p.org_ids_with_role("billing"), vec![org]);

        // …and only in the organisation where they are the admin.
        assert!(!p.has_role_in(other, "billing"));
        assert!(!p.is_admin_of(other));

        // The rule is about checks, never about stored data: an admin's roles
        // are still just the ones they were given, so taking one away is a
        // change that means something.
        assert_eq!(p.roles_in(org), ["admin"]);
    }

    #[test]
    fn a_role_held_twice_is_still_one_role() {
        let org = Uuid::new_v4();
        // The primary role appearing again among the extras is a duplicate, not
        // a second grant — otherwise removing one would appear to do nothing.
        let membership = OrgMembership::new(
            org,
            Some("admin".into()),
            ["admin".to_string(), "billing".to_string(), String::new()],
        );
        assert_eq!(membership.roles, ["admin", "billing"]);
    }

    #[test]
    fn a_member_with_no_role_holds_none() {
        let org = Uuid::new_v4();
        let membership = OrgMembership::new(org, None, []);
        assert!(membership.roles.is_empty());
        assert!(!membership.is_admin());
        assert!(!membership.has_role("member"));
    }
}