ai-model-vault 4.6.1

DEPRECATED - renamed to `ironvault`. This crate is no longer maintained; 4.6.1 is identical to 4.6.0 apart from this notice.
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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! JWT authentication for the REST API.

use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};

/// Revoked JWT IDs, each mapped to the `exp` of the token it came from.
///
/// Keeping the expiry is what makes the list prunable. A revoked JTI only has
/// to be remembered until the token would have expired on its own — after that
/// `Validation` rejects it regardless — so entries past their `exp` are dead
/// weight. The previous `HashSet<String>` had no way to know when an entry
/// stopped mattering, so the set grew for the life of the process and a busy
/// server that revoked on every logout leaked memory indefinitely.
#[allow(clippy::incompatible_msrv)]
static REVOKED_TOKENS: std::sync::LazyLock<RwLock<RevocationList>> =
    std::sync::LazyLock::new(|| RwLock::new(RevocationList::default()));

/// JTI → expiry, plus the optional file the list is mirrored to.
#[derive(Default)]
struct RevocationList {
    entries: HashMap<String, u64>,
    /// When set, every mutation is persisted here so revocations survive a
    /// restart. Without it the list is process-local: restarting the server
    /// silently un-revokes every token that has not yet expired.
    store: Option<PathBuf>,
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

impl RevocationList {
    /// Drop entries whose token has expired anyway.
    fn prune(&mut self) {
        let now = now_secs();
        self.entries.retain(|_, exp| *exp > now);
    }

    /// Rewrite the backing file, if one is configured.
    ///
    /// The whole map is rewritten rather than appended to: revocations are
    /// rare, the file stays small because it is pruned first, and a full
    /// rewrite through a temporary file plus rename means a crash mid-write
    /// leaves the previous list intact rather than a truncated one. A
    /// truncated list is the dangerous failure — it un-revokes tokens.
    fn persist(&self) -> std::io::Result<()> {
        let Some(path) = &self.store else {
            return Ok(());
        };

        let json = serde_json::to_vec(&self.entries)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;

        let tmp = path.with_extension("tmp");
        std::fs::write(&tmp, &json)?;
        std::fs::rename(&tmp, path)?;
        Ok(())
    }
}

/// Persist revocations to `path`, loading any already recorded there.
///
/// Call once at startup. Until it is called the revocation list lives only in
/// this process: a restart re-admits every unexpired token that had been
/// revoked, which is the difference between "logged out" and "logged out until
/// the next deploy".
///
/// A missing file is a valid empty list (first run). A file that exists but
/// cannot be read or parsed is an error rather than an empty list, because
/// starting with a silently empty list is exactly the un-revocation this is
/// meant to prevent.
///
/// # Errors
///
/// Returns an error if `path` exists but cannot be read or parsed, or if the
/// list cannot be written back.
///
/// # Note on horizontal scaling
///
/// This is a single-node store. Replicas do not share it, so a token revoked
/// on one replica stays valid on the others. Multi-replica deployments need a
/// shared backend (Redis, a database) or short token lifetimes; the chart's
/// default `token_expiry_secs` of one hour bounds the exposure.
pub fn configure_revocation_store(path: impl AsRef<Path>) -> std::io::Result<()> {
    let path = path.as_ref().to_path_buf();

    let entries: HashMap<String, u64> = match std::fs::read(&path) {
        Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("Revocation store {} is corrupt: {e}", path.display()),
            )
        })?,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
        Err(err) => return Err(err),
    };

    let mut list = REVOKED_TOKENS
        .write()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    list.entries = entries;
    list.store = Some(path);
    list.prune();
    list.persist()
}

/// Roles that control access to audit logs and admin operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Full access to all operations including audit logs.
    Admin,
    /// Standard model operations; audit logs are filtered to own actions.
    Operator,
    /// Read-only access; cannot modify models or view security events.
    Viewer,
}

impl std::fmt::Display for Role {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Role::Admin => write!(f, "admin"),
            Role::Operator => write!(f, "operator"),
            Role::Viewer => write!(f, "viewer"),
        }
    }
}

/// JWT claims payload.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    /// Subject (unique session identifier).
    pub sub: String,
    /// JWT ID for revocation tracking.
    pub jti: String,
    /// Role for RBAC.
    #[serde(default = "default_role")]
    pub role: Role,
    /// Issued-at (Unix epoch seconds).
    pub iat: u64,
    /// Expiration (Unix epoch seconds).
    pub exp: u64,
}

fn default_role() -> Role {
    Role::Admin
}

/// Create a signed JWT token with a unique session ID.
pub fn create_token(secret: &str, expiry_secs: u64) -> Result<String, jsonwebtoken::errors::Error> {
    create_token_with_role(secret, expiry_secs, Role::Admin)
}

/// Create a signed JWT token with a specific role.
pub fn create_token_with_role(
    secret: &str,
    expiry_secs: u64,
    role: Role,
) -> Result<String, jsonwebtoken::errors::Error> {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let jti = uuid::Uuid::new_v4().to_string();

    let claims = Claims {
        sub: "vault-user".into(),
        jti,
        role,
        iat: now,
        exp: now + expiry_secs,
    };

    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
}

/// Verify and decode a JWT token, returning the claims.
///
/// Rejects expired tokens, tokens with invalid signatures,
/// and tokens that have been revoked.
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    let data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )?;

    // Check if token has been revoked.
    //
    // A poisoned lock is recovered rather than skipped: `if let Ok(..)` treated
    // poisoning as "not revoked" and would have admitted every revoked token
    // for the rest of the process's life.
    {
        let revoked = REVOKED_TOKENS
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if revoked.entries.contains_key(&data.claims.jti) {
            return Err(jsonwebtoken::errors::Error::from(
                jsonwebtoken::errors::ErrorKind::InvalidToken,
            ));
        }
    }

    Ok(data.claims)
}

/// Revoke the token these claims came from.
///
/// This is the entry point to prefer: the claims carry the `exp` needed to
/// retire the entry once the token would have expired anyway.
///
/// Returns an error only if a revocation store is configured and could not be
/// written. The in-memory list is updated either way, so a storage failure
/// degrades to process-local revocation rather than to no revocation — but it
/// is surfaced, because silently losing a revocation on restart is a security
/// event the operator needs to see.
///
/// # Errors
///
/// Returns an error if the configured revocation store cannot be written.
pub fn revoke_claims(claims: &Claims) -> std::io::Result<()> {
    let mut revoked = REVOKED_TOKENS
        .write()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    revoked.entries.insert(claims.jti.clone(), claims.exp);
    revoked.prune();
    revoked.persist()
}

/// Revoke a token by its JTI (JWT ID), with no expiry.
///
/// Prefer [`revoke_claims`], which supplies the token's `exp`. Without it the
/// entry can never be retired — it is stored with an effectively infinite
/// expiry and stays in the list (and in the store, if one is configured) for
/// as long as the process runs.
#[deprecated(
    since = "4.0.0",
    note = "use `revoke_claims`, which records the token's expiry so the entry can be pruned"
)]
pub fn revoke_token(jti: &str) {
    let mut revoked = REVOKED_TOKENS
        .write()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    revoked.entries.insert(jti.to_string(), u64::MAX);
    let _ = revoked.persist();
}

/// Number of revocations currently held, after pruning expired entries.
#[must_use]
pub fn revoked_count() -> usize {
    let mut revoked = REVOKED_TOKENS
        .write()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    revoked.prune();
    revoked.entries.len()
}

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

    #[test]
    fn test_create_and_verify_token() {
        let secret = "test-secret-key-for-jwt-signing";
        let token = create_token(secret, 3600).unwrap();
        let claims = verify_token(&token, secret).unwrap();
        assert_eq!(claims.sub, "vault-user");
        assert!(!claims.jti.is_empty());
        assert_eq!(claims.role, Role::Admin);
    }

    #[test]
    fn test_invalid_secret_rejects() {
        let token = create_token("good-secret", 3600).unwrap();
        let result = verify_token(&token, "wrong-secret");
        assert!(result.is_err());
    }

    #[test]
    fn test_expired_token_rejects() {
        let secret = "test-secret";
        // Token that expired well beyond the default leeway (60s)
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let claims = Claims {
            sub: "vault-user".into(),
            jti: uuid::Uuid::new_v4().to_string(),
            role: Role::Admin,
            iat: now - 7200,
            exp: now - 3600,
        };
        let token = encode(
            &Header::default(),
            &claims,
            &EncodingKey::from_secret(secret.as_bytes()),
        )
        .unwrap();
        let result = verify_token(&token, secret);
        assert!(result.is_err());
    }

    /// The revocation list is process-global and cargo runs tests on parallel
    /// threads, so every test that touches it must serialise here.
    ///
    /// This originally guarded only `store`, on the reasoning that the file was
    /// the shared resource. That was wrong: `configure_revocation_store`
    /// replaces `entries` wholesale, so a test holding the lock wiped the
    /// entries of a test that was not holding it. `test_revoke_claims` revoked
    /// a token and had the revocation deleted out from under it before it could
    /// verify — it failed on macOS and passed elsewhere purely because of
    /// thread scheduling, which is what a missing lock looks like rather than a
    /// platform difference.
    static REVOCATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// Serialise against every other revocation test and start from an empty
    /// list.
    ///
    /// The reset happens on *acquire* rather than on release: a test that
    /// panics never runs its cleanup, and leaving the next test to inherit that
    /// state turns one failure into an unrelated cascade.
    fn revocation_guard() -> std::sync::MutexGuard<'static, ()> {
        let guard = REVOCATION_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);

        let mut list = REVOKED_TOKENS
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        list.entries.clear();
        list.store = None;

        drop(list);
        guard
    }

    #[test]
    fn test_revoke_claims() {
        let _guard = revocation_guard();

        let secret = "test-revoke-secret";
        let token = create_token(secret, 3600).unwrap();
        let claims = verify_token(&token, secret).unwrap();

        revoke_claims(&claims).unwrap();

        let result = verify_token(&token, secret);
        assert!(result.is_err(), "a revoked token must not verify");
    }

    #[test]
    fn test_expired_revocations_are_pruned() {
        let _guard = revocation_guard();

        let before = revoked_count();

        // An entry whose token expired an hour ago carries no information —
        // `Validation` already rejects the token on its own.
        {
            let mut list = REVOKED_TOKENS
                .write()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            list.entries
                .insert("stale-jti-for-pruning-test".into(), now_secs() - 3600);
        }

        // `revoked_count` prunes, so the stale entry must not be counted.
        assert_eq!(
            revoked_count(),
            before,
            "an entry past its exp should have been pruned"
        );

        let list = REVOKED_TOKENS
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        assert!(!list.entries.contains_key("stale-jti-for-pruning-test"));
    }

    #[test]
    fn test_unexpired_revocations_are_kept() {
        let _guard = revocation_guard();

        let jti = "live-jti-for-pruning-test";
        {
            let mut list = REVOKED_TOKENS
                .write()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            list.entries.insert(jti.into(), now_secs() + 3600);
        }

        let _ = revoked_count(); // triggers a prune

        let mut list = REVOKED_TOKENS
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        assert!(
            list.entries.contains_key(jti),
            "pruning must not drop an entry whose token is still valid"
        );
        list.entries.remove(jti);
    }

    #[test]
    fn test_revocations_survive_a_restart() {
        let _guard = revocation_guard();

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("revocations.json");

        // First "run": configure a store and revoke a token.
        configure_revocation_store(&path).unwrap();
        let secret = "test-persistence-secret";
        let token = create_token(secret, 3600).unwrap();
        let claims = verify_token(&token, secret).unwrap();
        revoke_claims(&claims).unwrap();

        // Simulate a restart: wipe the in-memory list entirely, then reload.
        {
            let mut list = REVOKED_TOKENS
                .write()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            list.entries.clear();
            list.store = None;
        }
        assert!(
            verify_token(&token, secret).is_ok(),
            "precondition: with the list wiped and no store, the token verifies \
             again — this is exactly the bug persistence fixes"
        );

        configure_revocation_store(&path).unwrap();
        assert!(
            verify_token(&token, secret).is_err(),
            "a revocation recorded before the restart must still apply after it"
        );
    }

    #[test]
    fn test_a_corrupt_revocation_store_is_an_error_not_an_empty_list() {
        let _guard = revocation_guard();

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("revocations.json");
        std::fs::write(&path, b"{not json").unwrap();

        let err = configure_revocation_store(&path).unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
        assert!(
            err.to_string().contains("corrupt"),
            "the operator needs to know the store was unreadable, not silently \
             start with zero revocations; got: {err}"
        );
    }

    #[test]
    fn test_a_missing_revocation_store_is_a_valid_empty_list() {
        let _guard = revocation_guard();

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("does-not-exist-yet.json");

        // First run on a fresh volume must not fail.
        configure_revocation_store(&path).unwrap();
        assert!(path.exists(), "the store should be created on first use");
    }

    #[test]
    fn test_create_token_with_role() {
        let secret = "test-role-secret";
        let token = create_token_with_role(secret, 3600, Role::Operator).unwrap();
        let claims = verify_token(&token, secret).unwrap();
        assert_eq!(claims.role, Role::Operator);

        let token2 = create_token_with_role(secret, 3600, Role::Viewer).unwrap();
        let claims2 = verify_token(&token2, secret).unwrap();
        assert_eq!(claims2.role, Role::Viewer);
    }
}