rustango 0.62.0

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
//! Signed-cookie session auth for the bare `admin` module: a `/login`
//! form, a signed cookie and a sidebar `Logout`, without the tenancy
//! stack.
//!
//! The HMAC primitive ([`crate::session::SessionSecret`] and
//! [`crate::session::sign`]) lives at the crate root and is shared with
//! `tenancy::session`. Each module adds its own payload on top, so the
//! two cookies differ in name and shape while the crypto stays in one
//! place.

use serde::{Deserialize, Serialize};

use crate::session::PasswordFingerprint;
pub use crate::session::SessionSecret as AdminSessionSecret;

tokio::task_local! {
    /// Per-request session, set by the `require_session` middleware, so
    /// deep-stack helpers such as the chrome context can read the
    /// current user without every handler passing it down. Tokio clears
    /// it when the scoped future finishes.
    pub(crate) static CURRENT_SESSION: AdminSession;
}

/// The current request's session, if the middleware installed one.
/// `None` outside an admin request, or when the request is
/// unauthenticated.
#[must_use]
pub fn current() -> Option<AdminSession> {
    CURRENT_SESSION.try_with(|s| s.clone()).ok()
}

/// Request marker the admin router adds: `true` under `with_user_perms`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct PermsScoped(pub(crate) bool);

/// The one "acts as superuser" rule for admin writes: no per-user perm set
/// and, when signed in, a superuser session. No login means full access.
pub(crate) fn acting_superuser(perms_scoped: bool) -> bool {
    !perms_scoped && current().is_none_or(|s| s.is_superuser)
}

/// The old name of [`crate::object_permissions::is_superuser`], which also
/// reads API principals.
#[deprecated(
    since = "0.62.0",
    note = "use `rustango::object_permissions::is_superuser`"
)]
#[must_use]
pub fn is_superuser(parts: &axum::http::request::Parts) -> bool {
    crate::object_permissions::is_superuser(parts)
}

/// The request's session: the extension, else the task-local. A
/// handler that reads only one of them misses the other mount path.
#[must_use]
pub fn from_extensions(extensions: &axum::http::Extensions) -> Option<AdminSession> {
    extensions.get::<AdminSession>().cloned().or_else(current)
}

/// Scope the task-local to the request's [`from_extensions`] session, so a
/// custom gate that sets only the extension is seen by every reader.
pub(crate) async fn scope_request_session(
    req: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    match from_extensions(req.extensions()) {
        Some(s) => CURRENT_SESSION.scope(s, next.run(req)).await,
        None => next.run(req).await,
    }
}

tokio::task_local! {
    /// Per-request CSRF token, set by `csrf_context`.
    ///
    /// Same reasoning as `CURRENT_SESSION`: `chrome_context` builds the
    /// variables for every admin template and is called from many
    /// render sites. A task-local avoids threading one request-scoped
    /// value through all of them.
    pub(crate) static CURRENT_CSRF_TOKEN: String;
}

/// The current request's CSRF token, if the admin middleware installed
/// one.
///
/// `None` outside an admin request, so `chrome_context` treats a
/// missing token as normal: hand-rendered pages and tests run with no
/// request in scope. This does not weaken enforcement. `CsrfLayer`
/// still rejects an unsafe request whatever the template rendered.
#[must_use]
pub fn current_csrf_token() -> Option<String> {
    CURRENT_CSRF_TOKEN.try_with(Clone::clone).ok()
}

/// Session lifetime: 8 hours, so an operator signs in once a workday.
pub(crate) const DEFAULT_TTL_SECS: i64 = 8 * 60 * 60;

/// Cookie name the admin session is stored under. Distinct from any
/// `tenancy` cookies so the two layers can coexist on one host.
pub(crate) const SESSION_COOKIE: &str = "rustango_admin_session";

/// The session the login middleware puts in the request extensions on
/// every authenticated request. Use it as an extractor in an admin
/// handler to see who is signed in.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminSession {
    /// Primary key of the [`AdminUser`](super::user::AdminUser) row.
    pub user_id: i64,
    /// Username, cached on the cookie so the chrome can render
    /// "Signed in as …" without a query per request.
    pub username: String,
    /// The user's `is_superuser` flag at login time, cached on the
    /// cookie so the chrome's visibility check needs no query.
    pub is_superuser: bool,
    /// `Some(operator id)` when an operator is impersonating a tenant;
    /// then `user_id` is 0 and `username` empty (#2110).
    #[serde(default)]
    pub impersonated_by: Option<i64>,
}

impl AdminSession {
    /// A signed-in user's session.
    #[must_use]
    pub fn new(user_id: i64, username: impl Into<String>, is_superuser: bool) -> Self {
        Self {
            user_id,
            username: username.into(),
            is_superuser,
            impersonated_by: None,
        }
    }

    /// An operator impersonating a tenant, as superuser.
    #[must_use]
    pub fn impersonation(operator_id: i64) -> Self {
        Self {
            user_id: 0,
            username: String::new(),
            is_superuser: true,
            impersonated_by: Some(operator_id),
        }
    }

    /// Who authored a write, by id only: a username could pose as an
    /// operator. `as_token()` gives `user:<id>` or `operator:<id>:impersonating`.
    #[must_use]
    pub fn actor(&self) -> crate::audit::AuditSource {
        match self.impersonated_by {
            Some(id) => crate::audit::AuditSource::Custom(format!("operator:{id}:impersonating")),
            None => crate::audit::AuditSource::User {
                id: self.user_id.to_string(),
            },
        }
    }
}

/// Wire payload: signed, then base64-encoded. Wraps [`AdminSession`]
/// with an `exp` timestamp so an expired cookie fails closed.
#[derive(Serialize, Deserialize)]
struct CookiePayload {
    user_id: i64,
    username: String,
    is_superuser: bool,
    /// Unix timestamp the session expires at.
    exp: i64,
    /// Fingerprint of the user's `password_hash` at login. The gate
    /// recomputes it from the current hash on every request, so a
    /// password change or reset invalidates every cookie minted before
    /// it. `#[serde(default)]` lets older cookies decode: they carry
    /// `""`, which never matches, so they need one fresh login.
    #[serde(default)]
    auth_hash: PasswordFingerprint,
    /// Issued-at, Unix seconds; checked against `sessions_revoked_at` (#1855).
    /// `0` for older cookies.
    #[serde(default)]
    iat: i64,
}

/// What the gate checks a cookie against the user's live row with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CookieAuth {
    /// Fingerprint of the password hash at login.
    pub(crate) auth_hash: PasswordFingerprint,
    /// Issued-at, Unix seconds.
    pub(crate) iat: i64,
    /// Expiry, Unix seconds: a re-minted cookie keeps it.
    pub(crate) exp: i64,
}

impl crate::session::Expiring for CookiePayload {
    fn is_expired_at(&self, now: i64) -> bool {
        now >= self.exp
    }
}

/// Seal tag of the admin session cookie, apart from every other token kind.
const ADMIN_SESSION_TAG: &[u8] = b"rustango-admin-session-v1";

/// Sign a fresh session and return the cookie value to set. Lasts 8
/// hours. `auth_hash` is the fingerprint of the user's current
/// `password_hash`; `sessions_revoked_at` is the user's logout cut-off.
#[must_use]
pub(crate) fn encode(
    secret: &AdminSessionSecret,
    session: AdminSession,
    auth_hash: &PasswordFingerprint,
    sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
) -> String {
    encode_for(
        secret,
        session,
        auth_hash,
        sessions_revoked_at,
        DEFAULT_TTL_SECS,
    )
}

/// [`encode`] for a cookie expiring at `exp` (Unix seconds), at most 8 hours out.
pub(crate) fn encode_until(
    secret: &AdminSessionSecret,
    session: AdminSession,
    auth_hash: &PasswordFingerprint,
    sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
    exp: i64,
) -> String {
    let left = (exp - chrono::Utc::now().timestamp()).clamp(1, DEFAULT_TTL_SECS);
    encode_for(secret, session, auth_hash, sessions_revoked_at, left)
}

/// [`encode`] with a lifetime of `ttl_secs`.
pub(crate) fn encode_for(
    secret: &AdminSessionSecret,
    session: AdminSession,
    auth_hash: &PasswordFingerprint,
    sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
    ttl_secs: i64,
) -> String {
    let payload = CookiePayload {
        user_id: session.user_id,
        username: session.username,
        is_superuser: session.is_superuser,
        exp: chrono::Utc::now().timestamp() + ttl_secs,
        auth_hash: auth_hash.clone(),
        iat: crate::session::issued_at(sessions_revoked_at),
    };
    crate::session::SealedToken::seal(secret.key(), ADMIN_SESSION_TAG, &payload)
}

/// Verify and decode a cookie value. Returns `Some` only when the
/// signature is valid **and** the payload has not expired. Every other
/// case, such as a malformed or tampered cookie, returns `None`, and the
/// caller must treat the request as unauthenticated. The [`CookieAuth`]
/// lets the gate drop sessions from before a password change or logout.
#[must_use]
pub(crate) fn decode_full(
    secret: &AdminSessionSecret,
    value: &str,
) -> Option<(AdminSession, CookieAuth)> {
    let payload: CookiePayload =
        crate::session::SealedToken::open(secret.key(), ADMIN_SESSION_TAG, value).ok()?;
    Some((
        AdminSession::new(payload.user_id, payload.username, payload.is_superuser),
        CookieAuth {
            auth_hash: payload.auth_hash,
            iat: payload.iat,
            exp: payload.exp,
        },
    ))
}

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

    fn session(user_id: i64, username: &str, is_superuser: bool) -> AdminSession {
        AdminSession::new(user_id, username, is_superuser)
    }

    /// Both halves of the rule: the perm set and the session (#2520).
    #[tokio::test]
    async fn acting_superuser_needs_no_perm_set_and_a_superuser_session() {
        assert!(acting_superuser(false), "no login is full access");
        assert!(!acting_superuser(true));
        let under = |su: bool, scoped: bool| {
            CURRENT_SESSION.scope(session(1, "u", su), async move { acting_superuser(scoped) })
        };
        assert!(under(true, false).await);
        assert!(!under(false, false).await, "a non-superuser session");
        assert!(!under(true, true).await, "with_user_perms wins");
    }

    #[test]
    fn round_trip_recovers_session_fields_and_auth_hash() {
        let secret = AdminSessionSecret::from_bytes(vec![42u8; 32]);
        let fp = PasswordFingerprint::of(&secret, "$argon2id$fake-hash");
        let cookie = encode(&secret, session(7, "alice", true), &fp, None);
        let (s, auth) = decode_full(&secret, &cookie).expect("valid cookie verifies");
        assert_eq!(s.user_id, 7);
        assert!(s.is_superuser);
        assert_eq!(auth.auth_hash, fp);
        assert!(auth.iat > 0);
    }

    /// A re-minted cookie keeps a short SSO expiry and never outlives 8 hours.
    #[test]
    fn a_reminted_cookie_keeps_its_expiry() {
        let secret = AdminSessionSecret::from_bytes(vec![42u8; 32]);
        let fp = PasswordFingerprint::of(&secret, "$argon2id$fake-hash");
        let short = encode_for(&secret, session(7, "alice", true), &fp, None, 600);
        let exp = decode_full(&secret, &short).unwrap().1.exp;
        let again = encode_until(&secret, session(7, "alice", true), &fp, None, exp);
        assert!((decode_full(&secret, &again).unwrap().1.exp - exp).abs() <= 1);
        let far = chrono::Utc::now().timestamp() + 10 * DEFAULT_TTL_SECS;
        let capped = encode_until(&secret, session(7, "alice", true), &fp, None, far);
        let left = decode_full(&secret, &capped).unwrap().1.exp - chrono::Utc::now().timestamp();
        assert!(left <= DEFAULT_TTL_SECS);
    }

    #[test]
    fn auth_hash_changes_with_password_hash() {
        // The fingerprint changes with the password hash, so the
        // gate's compare invalidates old cookies.
        let secret = AdminSessionSecret::from_bytes(vec![9u8; 32]);
        let before = PasswordFingerprint::of(&secret, "$argon2id$old");
        let after = PasswordFingerprint::of(&secret, "$argon2id$new");
        assert_ne!(before, after);
    }

    #[test]
    fn tampered_signature_rejected() {
        let secret = AdminSessionSecret::from_bytes(vec![1u8; 32]);
        let cookie = encode(
            &secret,
            session(1, "bob", false),
            &PasswordFingerprint::default(),
            None,
        );
        let (body, _sig) = cookie.split_once('.').unwrap();
        let bad = format!("{body}.AAAA");
        assert!(decode_full(&secret, &bad).is_none());
    }

    #[test]
    fn wrong_secret_rejected() {
        let secret_a = AdminSessionSecret::from_bytes(vec![1u8; 32]);
        let secret_b = AdminSessionSecret::from_bytes(vec![2u8; 32]);
        let cookie = encode(
            &secret_a,
            session(1, "bob", false),
            &PasswordFingerprint::default(),
            None,
        );
        assert!(decode_full(&secret_b, &cookie).is_none());
    }
}