rustango 0.61.13

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
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
493
494
495
//! HMAC-SHA256 signed session cookies for the operator console.
//!
//! Stateless — the cookie carries the principal (`operator_id` + `exp`)
//! and a signature. Server validates the signature on every request;
//! no server-side session table for v1. Trade-offs:
//!
//! * **Idle and absolute expiry** — `exp` is the idle deadline, slid by
//!   each request; `iat` + [`SESSION_TTL_SECS`] is the hard cap.
//! * **No revocation list** — a cookie is valid until `exp`, unless the
//!   operator is deactivated, changes password or logs out. Logout stamps
//!   `sessions_revoked_at`, which ends the operator's sessions on every device.
//! * **Secret rotation invalidates all cookies** — a server restart
//!   with auto-generated secret signs everyone out. With
//!   `RUSTANGO_SESSION_SECRET` set in env, sessions survive restarts.
//!
//! Cookie format:
//!
//! ```text
//! Cookie: rustango_op_session=<base64(payload)>.<base64(hmac_sha256)>
//! payload  = JSON {"oid": <i64>, "exp": <unix_seconds>, "iat": .., "pwf": ..}
//! signature = HMAC-SHA256(secret, payload_base64) [first 32 bytes]
//! ```

use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;

// v0.45 (#253) — `SessionSecret` + `sign` were promoted to the
// crate-root `crate::session` module so the bare `admin` module can
// reuse the same HMAC primitives without compiling in `tenancy`.
// The re-exports below keep every pre-existing `crate::tenancy::session::SessionSecret`
// caller working.
pub use crate::session::{PasswordFingerprint, SessionSecret, SessionSecretError};
// `sign` re-exported at crate-internal visibility so existing
// callers (`tenant_console`, `impersonation_handoff`) keep working
// after the v0.45 move to `crate::session`.
pub(crate) use crate::session::{session_survives, sign};

/// Default cookie name. Visible in browser devtools — namespaced so
/// it doesn't collide with tenant cookies.
pub const COOKIE_NAME: &str = "rustango_op_session";

/// Longest a console session lasts from sign-in, however active.
pub const SESSION_TTL_SECS: i64 = 12 * 60 * 60;

/// A console session unused this long ends; each request slides `exp`.
pub const SESSION_IDLE_SECS: i64 = 30 * 60;

/// How stale `exp` must be before a request re-issues the cookie.
const RENEW_AFTER_SECS: i64 = 60;

/// The tenant session key as the server loads it: the env var, or off the
/// prod tier the dev key file. Never a random key, so a CLI and the server agree.
pub(crate) fn tenant_secret_from_env() -> Result<SessionSecret, super::TenancyError> {
    use crate::session::{is_prod_tier, tier_from_env};
    if is_prod_tier(&tier_from_env()) {
        SessionSecret::require_from_env()
            .map_err(|e| super::TenancyError::Validation(e.to_string()))
    } else {
        Ok(SessionSecret::from_env_or_disk(std::path::Path::new(
            crate::session::TENANT_SESSION_KEY_PATH,
        )))
    }
}

/// The keys a tenancy server signs with: one per console, each from the env
/// var or, off prod, its dev key file. The CLI reads the same tenant file.
pub(crate) struct ServerKeys {
    pub(crate) tenant: SessionSecret,
    pub(crate) operator: SessionSecret,
}

impl ServerKeys {
    /// Panics on the prod tier without a valid `RUSTANGO_SESSION_SECRET`.
    pub(crate) fn load() -> Self {
        let tier = crate::session::tier_from_env();
        Self::from_files(|p| crate::session::load_session_secret_for_tier(&tier, p))
    }

    /// Each key from its dev file path, through `load`.
    fn from_files(load: impl Fn(&std::path::Path) -> SessionSecret) -> Self {
        use crate::session::{OPERATOR_SESSION_KEY_PATH, TENANT_SESSION_KEY_PATH};
        Self {
            tenant: load(std::path::Path::new(TENANT_SESSION_KEY_PATH)),
            operator: load(std::path::Path::new(OPERATOR_SESSION_KEY_PATH)),
        }
    }

    /// Off prod, under `root`, with the env value passed in.
    #[cfg(test)]
    fn dev_at(env: Option<&str>, root: &std::path::Path) -> Self {
        Self::from_files(|p| SessionSecret::from_raw_or_disk(env, &root.join(p)))
    }
}

/// Where [`existing_tenant_secret`] found the key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KeySource {
    Env,
    /// The dev key file: only a server reading the same file shares it.
    DevFile,
}

/// What a CLI does when the dev key file is missing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DevKeyFile {
    /// Refuse: API keys minted now must verify on a server already running.
    LoadOnly,
    /// Write it as a server's first boot does, so a fresh dev project works.
    LoadOrCreate,
}

/// The tenant session key the server loads: the env var, or off prod the dev
/// key file. Never a key only this process holds.
pub(crate) fn existing_tenant_secret(
    dev_key: DevKeyFile,
) -> Result<(SessionSecret, KeySource), super::TenancyError> {
    use crate::session::{is_prod_tier, tier_from_env};
    existing_tenant_secret_at(
        std::env::var("RUSTANGO_SESSION_SECRET").ok().as_deref(),
        is_prod_tier(&tier_from_env()),
        std::path::Path::new(crate::session::TENANT_SESSION_KEY_PATH),
        dev_key,
    )
}

fn existing_tenant_secret_at(
    env: Option<&str>,
    prod: bool,
    dev_file: &std::path::Path,
    dev_key: DevKeyFile,
) -> Result<(SessionSecret, KeySource), super::TenancyError> {
    let invalid =
        |e: crate::session::SessionSecretError| super::TenancyError::Validation(e.to_string());
    if prod {
        return env
            .ok_or(crate::session::SessionSecretError::Missing)
            .and_then(SessionSecret::from_b64)
            .map(|s| (s, KeySource::Env))
            .map_err(invalid);
    }
    // Off prod the server falls back to the dev file on a bad env value; match it.
    if let Some(raw) = env {
        match SessionSecret::from_b64(raw) {
            Ok(s) => return Ok((s, KeySource::Env)),
            Err(e) => crate::session::warn_unusable_secret(&e.to_string()),
        }
    }
    let missing = |fix: String| {
        super::TenancyError::Validation(format!(
            "no tenant session key: set RUSTANGO_SESSION_SECRET, or {fix}"
        ))
    };
    match dev_key {
        DevKeyFile::LoadOnly => match std::fs::read(dev_file) {
            Ok(bytes) if bytes.len() >= 32 => {
                Ok((SessionSecret::from_bytes(bytes), KeySource::DevFile))
            }
            _ => Err(missing(format!(
                "start the server once so it writes {}",
                dev_file.display()
            ))),
        },
        // A failed write is an error: a random fallback is a key no server holds.
        DevKeyFile::LoadOrCreate => crate::session::load_or_create_key_file(
            dev_file,
            "session secret",
            "RUSTANGO_SESSION_SECRET",
        )
        .map(|bytes| (SessionSecret::from_bytes(bytes), KeySource::DevFile))
        .map_err(|e| {
            missing(format!(
                "fix the dev key file: cannot read or write {} ({e})",
                dev_file.display()
            ))
        }),
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SessionError {
    #[error("session cookie is malformed")]
    Malformed,
    #[error("session signature mismatch")]
    BadSignature,
    #[error("session expired")]
    Expired,
    /// Cookie's tenant slug doesn't match the resolved tenant — used
    /// by the tenant console to defend against cross-tenant cookie
    /// replay (cookie issued for `acme` should not authenticate at
    /// `globex`'s subdomain).
    #[error("session is bound to a different tenant")]
    WrongTenant,
}

/// Principal payload carried inside the cookie. Compact field names
/// to keep the cookie short (browsers truncate aggressively).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionPayload {
    /// Operator id (from `rustango_operators.id`).
    pub oid: i64,
    /// Expiry as Unix seconds.
    pub exp: i64,
    /// Issued-at as Unix seconds. A session issued before
    /// `rustango_operators.password_changed_at` is rejected. `0` for
    /// cookies minted on pre-0.28.4 servers.
    #[serde(default)]
    pub iat: i64,
    /// Fingerprint of the operator's `password_hash` at login. Any
    /// password change makes it stop matching (#1338).
    #[serde(default)]
    pub pwf: PasswordFingerprint,
}

impl SessionPayload {
    /// `pwf` is the [`PasswordFingerprint`] of the operator's current hash.
    #[must_use]
    pub fn new(operator_id: i64, ttl_secs: i64, pwf: PasswordFingerprint) -> Self {
        let now = chrono::Utc::now().timestamp();
        Self {
            oid: operator_id,
            exp: now + ttl_secs,
            iat: now,
            pwf,
        }
    }

    /// Past the idle deadline `exp`, or [`SESSION_TTL_SECS`] after sign-in.
    fn is_expired(&self) -> bool {
        let now = chrono::Utc::now().timestamp();
        now >= self.exp || now >= self.iat.saturating_add(SESSION_TTL_SECS)
    }

    /// This session with its idle deadline moved to now + [`SESSION_IDLE_SECS`],
    /// capped at the absolute lifetime. `None` while `exp` is still fresh.
    #[must_use]
    pub fn renewed(&self) -> Option<Self> {
        let now = chrono::Utc::now().timestamp();
        let exp = (now + SESSION_IDLE_SECS).min(self.iat.saturating_add(SESSION_TTL_SECS));
        // An older cookie's far `exp` is pulled in, too.
        (exp < self.exp || exp - self.exp >= RENEW_AFTER_SECS).then(|| Self {
            exp,
            ..self.clone()
        })
    }
}

/// Serialize and sign a payload into a cookie value.
#[must_use]
pub fn encode(secret: &SessionSecret, payload: &SessionPayload) -> String {
    let json = serde_json::to_vec(payload).expect("payload serializes");
    let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
    let sig = sign(secret, payload_b64.as_bytes());
    let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
    format!("{payload_b64}.{sig_b64}")
}

/// Verify and deserialize a cookie value.
///
/// # Errors
/// Returns [`SessionError::Malformed`] for missing/bad-base64 parts,
/// [`SessionError::BadSignature`] when HMAC doesn't match (covers
/// secret rotation + tampering), [`SessionError::Expired`] when the
/// payload's `exp` is in the past.
pub fn decode(secret: &SessionSecret, value: &str) -> Result<SessionPayload, SessionError> {
    let (payload_b64, sig_b64) = value.split_once('.').ok_or(SessionError::Malformed)?;
    let expected = sign(secret, payload_b64.as_bytes());
    let provided = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(sig_b64)
        .map_err(|_| SessionError::Malformed)?;
    if expected.ct_eq(&provided[..]).unwrap_u8() == 0 {
        return Err(SessionError::BadSignature);
    }
    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload_b64)
        .map_err(|_| SessionError::Malformed)?;
    let payload: SessionPayload =
        serde_json::from_slice(&payload_bytes).map_err(|_| SessionError::Malformed)?;
    if payload.is_expired() {
        return Err(SessionError::Expired);
    }
    Ok(payload)
}

// `sign` moved to `crate::session` in v0.45 (#253) — `use` above.

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

    fn fp() -> PasswordFingerprint {
        PasswordFingerprint::default()
    }

    /// Off prod a fresh project gets the dev key file the server would write,
    /// and keeps it; on prod nothing is written.
    #[test]
    fn off_prod_the_dev_key_file_is_created_once() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("var").join("tenant.key");
        assert!(existing_tenant_secret_at(None, true, &path, DevKeyFile::LoadOrCreate).is_err());
        assert!(!path.exists(), "prod wrote a key file");
        let (first, source) =
            existing_tenant_secret_at(None, false, &path, DevKeyFile::LoadOrCreate).unwrap();
        assert_eq!(source, KeySource::DevFile);
        assert_eq!(std::fs::read(&path).unwrap(), first.key());
        // The env value is passed in: the caller's own secret must not decide this.
        let server = SessionSecret::from_raw_or_disk(None, &path);
        assert_eq!(
            server.key(),
            first.key(),
            "the server must load the same key"
        );
        let (again, _) =
            existing_tenant_secret_at(None, false, &path, DevKeyFile::LoadOrCreate).unwrap();
        assert_eq!(again.key(), first.key());
    }

    /// A set-password link the CLI signs with the tenant key verifies under
    /// the key a tenancy server (run-server or `server::Builder`) loads.
    #[test]
    fn a_cli_link_verifies_under_the_servers_tenant_key() {
        let root = tempfile::tempdir().unwrap();
        let file = root.path().join(crate::session::TENANT_SESSION_KEY_PATH);
        let (cli, _) =
            existing_tenant_secret_at(None, false, &file, DevKeyFile::LoadOrCreate).unwrap();
        let server = ServerKeys::dev_at(None, root.path());
        let scope = crate::auth_flows::LinkScope::from(&crate::testkit::org());
        let link = crate::auth_flows::PasswordReset::issue(
            &scope,
            "/change-password",
            42,
            &super::super::first_admin::link_key(&cli),
            std::time::Duration::from_secs(60),
        );
        let key = super::super::first_admin::link_key(&server.tenant);
        assert_eq!(
            crate::auth_flows::PasswordReset::verify(&scope, &link, &key),
            Ok(42)
        );
        assert_ne!(
            server.operator.key(),
            server.tenant.key(),
            "one key per console"
        );
    }

    /// Off prod an unusable env secret falls back to the dev file, as the server does.
    #[test]
    fn an_unusable_env_secret_uses_the_servers_dev_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("tenant.key");
        std::fs::write(&path, [7u8; 32]).unwrap();
        let (_, source) =
            existing_tenant_secret_at(Some("not-base64!"), false, &path, DevKeyFile::LoadOnly)
                .unwrap();
        assert_eq!(source, KeySource::DevFile);
        assert!(
            existing_tenant_secret_at(Some("not-base64!"), true, &path, DevKeyFile::LoadOnly)
                .is_err()
        );
        let good = "A".repeat(44);
        let (_, source) =
            existing_tenant_secret_at(Some(&good), false, &path, DevKeyFile::LoadOnly).unwrap();
        assert_eq!(source, KeySource::Env);
    }

    #[test]
    fn round_trip_valid_payload() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(42, 3600, PasswordFingerprint::of(&secret, "$h"));
        let cookie = encode(&secret, &payload);
        let back = decode(&secret, &cookie).unwrap();
        assert_eq!(back, payload);
    }

    #[test]
    fn rejects_tampered_payload() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(42, 3600, fp());
        let cookie = encode(&secret, &payload);
        // Replace payload but keep signature → BadSignature.
        let (_, sig) = cookie.split_once('.').unwrap();
        let evil_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(br#"{"oid":999,"exp":9999999999}"#);
        let tampered = format!("{evil_payload}.{sig}");
        let err = decode(&secret, &tampered).unwrap_err();
        assert!(matches!(err, SessionError::BadSignature));
    }

    #[test]
    fn rejects_wrong_secret() {
        let s1 = SessionSecret::from_bytes(b"first-test-secret-thirty-2-bytes".to_vec());
        let s2 = SessionSecret::from_bytes(b"second-test-secret-thirty2-bytes".to_vec());
        let cookie = encode(&s1, &SessionPayload::new(1, 3600, fp()));
        let err = decode(&s2, &cookie).unwrap_err();
        assert!(matches!(err, SessionError::BadSignature));
    }

    #[test]
    fn rejects_expired() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(1, -10, fp());
        let cookie = encode(&secret, &payload);
        let err = decode(&secret, &cookie).unwrap_err();
        assert!(matches!(err, SessionError::Expired));
    }

    /// Renewal slides `exp` to the idle window, never past the cap.
    #[test]
    fn renewal_slides_exp_up_to_the_cap() {
        let now = chrono::Utc::now().timestamp();
        let renewed = SessionPayload::new(1, 30, fp()).renewed().expect("stale");
        assert!(renewed.exp >= now + SESSION_IDLE_SECS);
        assert!(renewed.renewed().is_none(), "fresh needs no new cookie");

        let mut near_cap = SessionPayload::new(1, 30, fp());
        near_cap.iat = now - SESSION_TTL_SECS + 10;
        assert!(near_cap.renewed().unwrap().exp <= now + 10);

        // An older week-long cookie is cut to the idle limit.
        let old = SessionPayload::new(1, 7 * 24 * 3600, fp());
        let cut = old.renewed().expect("a far exp is pulled in");
        assert!(cut.exp <= chrono::Utc::now().timestamp() + SESSION_IDLE_SECS);
    }

    /// A session older than the absolute cap is refused, whatever its `exp`.
    #[test]
    fn rejects_a_session_past_the_absolute_cap() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let mut payload = SessionPayload::new(1, 3600, fp());
        payload.iat -= SESSION_TTL_SECS;
        let err = decode(&secret, &encode(&secret, &payload)).unwrap_err();
        assert!(matches!(err, SessionError::Expired));
    }

    #[test]
    fn rejects_malformed_no_dot() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let err = decode(&secret, "not-a-cookie").unwrap_err();
        assert!(matches!(err, SessionError::Malformed));
    }

    /// A new hash ends the session even when `iat` equals the change second.
    #[test]
    fn a_new_password_hash_ends_a_same_second_session() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let now = chrono::Utc::now();
        let pwf = PasswordFingerprint::of(&secret, "$old");
        assert!(session_survives(
            &secret,
            &pwf,
            now.timestamp(),
            "$old",
            Some(now),
            None
        ));
        assert!(!session_survives(
            &secret,
            &pwf,
            now.timestamp(),
            "$new",
            Some(now),
            None
        ));
        // A cookie from before this field decodes empty and never matches.
        assert!(!session_survives(
            &secret,
            &fp(),
            now.timestamp(),
            "$old",
            None,
            None
        ));
    }

    /// With the hash unchanged, a `password_changed_at` bump after `iat` still ends it.
    #[test]
    fn a_later_password_changed_at_ends_a_session_with_the_same_hash() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let pwf = PasswordFingerprint::of(&secret, "$h");
        let iat = 1_700_000_000;
        let at = |secs| chrono::DateTime::from_timestamp(secs, 0);
        assert!(session_survives(&secret, &pwf, iat, "$h", None, None));
        assert!(session_survives(&secret, &pwf, iat, "$h", at(iat), None));
        assert!(!session_survives(
            &secret,
            &pwf,
            iat,
            "$h",
            at(iat + 1),
            None
        ));
    }
}