Skip to main content

rustango/tenancy/
auth.rs

1//! 2-domain auth — registry-scoped [`Operator`] and per-tenant
2//! [`User`].
3//!
4//! ## Hard wall
5//!
6//! These are **two distinct identity domains** with no crossover:
7//!
8//! * `Operator` lives in `rustango_operators` in the **registry**
9//!   database. They sign in at `/operator/*` against the registry
10//!   pool. They NEVER appear in any tenant table.
11//! * `User` lives in `rustango_users` inside the **tenant's**
12//!   storage (schema or dedicated DB). They sign in at the tenant
13//!   URL against the tenant pool. The `is_superuser` flag elevates
14//!   them to org-admin INSIDE that tenant — but they NEVER reach
15//!   `/operator` even with that flag.
16//!
17//! The hard wall is enforced by middleware: operator routes call
18//! [`authenticate_operator`] against the registry; tenant routes
19//! call [`authenticate_user`] against the resolved tenant's pool.
20//! An operator's `Authorization: Basic …` header sent to a tenant
21//! URL won't authenticate (the username doesn't exist in the
22//! tenant's `rustango_users`); a user's header sent to `/operator`
23//! won't authenticate (the username doesn't exist in the registry's
24//! `rustango_operators`). Browser cookie isolation by subdomain
25//! plus this hard wall gives defense in depth.
26//!
27//! ## Auth mechanism
28//!
29//! Slice 6 ships **HTTP Basic** auth backed by argon2id-hashed
30//! passwords in the database. Sessions / cookies / login forms /
31//! password reset / OAuth land in v0.6.x; the model + crypto
32//! foundations stay the same.
33//!
34//! ## Bootstrap
35//!
36//! Use `cargo run -- create-operator <username> --password <p>` and
37//! `cargo run -- create-user <slug> <username> --password <p>
38//! [--superuser]` — see [`super::manage`]. (Replace `cargo run` with
39//! your project's binary name if different; the verbs route through
40//! `rustango::manage::Cli`.)
41
42use crate::core::Column as _;
43#[cfg(feature = "postgres")]
44use crate::sql::sqlx::{PgConnection, PgPool};
45#[allow(unused_imports)]
46use crate::sql::Auto;
47use crate::Model;
48use base64::Engine;
49
50use super::error::TenancyError;
51use super::password;
52
53/// Registry-scoped operator. Single identity domain for the main app
54/// administrator(s); not visible to tenants.
55#[derive(Model, Debug, Clone)]
56#[rustango(table = "rustango_operators", display = "username", scope = "registry")]
57#[allow(dead_code)]
58pub struct Operator {
59    #[rustango(primary_key)]
60    pub id: rustango::sql::Auto<i64>,
61    /// Login handle. Globally unique within the registry.
62    #[rustango(max_length = 64, unique)]
63    pub username: String,
64    /// PHC-format Argon2id hash. NEVER stored as plaintext.
65    #[rustango(max_length = 255)]
66    pub password_hash: String,
67    /// Soft-disable — `false` rejects login without dropping the row.
68    pub active: bool,
69    pub created_at: chrono::DateTime<chrono::Utc>,
70    /// Timestamp of the last password rotation (v0.28.4, #77).
71    /// Set on every reset / change-password verb. Sessions whose
72    /// `iat` (issued-at) is strictly less than this value are
73    /// rejected by `validate_session`. `None` for accounts that
74    /// haven't rotated since v0.28.4 — those sessions stay valid.
75    pub password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
76}
77
78/// Per-tenant user. Lives in the tenant's storage (schema or
79/// dedicated DB). `is_superuser = true` elevates to org-admin inside
80/// the tenant — never grants access to `/operator`.
81#[derive(Model, Debug, Clone)]
82#[rustango(
83    table = "rustango_users",
84    display = "username",
85    admin(
86        list_display = "username, is_superuser, active, created_at",
87        search_fields = "username",
88        ordering = "username",
89        readonly_fields = "password_hash, created_at",
90    )
91)]
92#[allow(dead_code)]
93pub struct User {
94    #[rustango(primary_key)]
95    pub id: rustango::sql::Auto<i64>,
96    /// Login handle. Unique within this tenant.
97    #[rustango(max_length = 64, unique)]
98    pub username: String,
99    /// PHC-format Argon2id hash.
100    #[rustango(max_length = 255)]
101    pub password_hash: String,
102    /// Email used to link an external SSO identity (OIDC / social
103    /// OAuth) to this tenant user. Both the admin/tenant-console login
104    /// and the member login match the IdP's verified email against this
105    /// column; `None` means the account can't sign in via SSO. Unique
106    /// within the tenant, but multiple `NULL`s are allowed.
107    ///
108    /// `#[cfg(feature = "sso")]`-gated (the admin-independent SSO core,
109    /// which `admin-sso` also enables) — enabling/disabling SSO generates
110    /// an Add/DropColumn migration for this column.
111    #[cfg(feature = "sso")]
112    #[rustango(max_length = 254, unique)]
113    pub email: Option<String>,
114    /// Org-admin within this tenant. Renders write-buttons, allows
115    /// edit/delete; non-superusers see read-only views (admin
116    /// authorization is the v0.6.x story; slice 6 stores the flag).
117    pub is_superuser: bool,
118    /// Soft-disable.
119    pub active: bool,
120    pub created_at: chrono::DateTime<chrono::Utc>,
121    /// Flexible per-user metadata bag. Store preferences, onboarding
122    /// state, app-specific attributes — anything that doesn't need its
123    /// own column. Never read by the framework itself.
124    #[rustango(default = "'{}'")]
125    pub data: serde_json::Value,
126    /// Timestamp of the last password rotation (v0.28.4, #77).
127    /// Set on every reset / change-password verb. Sessions whose
128    /// `iat` is strictly less than this value are rejected by
129    /// `validate_session`. `None` for accounts that haven't
130    /// rotated since v0.28.4 — those sessions stay valid until
131    /// they expire normally.
132    pub password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
133}
134
135/// Look up an operator by username and verify the password.
136///
137/// Returns `Ok(Some(operator))` on success, `Ok(None)` for an unknown
138/// username, a wrong password, OR an inactive (`active = false`)
139/// operator — always the same `Ok(None)`. The unknown-username path
140/// runs a dummy Argon2 verify ([`password::verify_dummy`]) and the
141/// active check happens *after* the real verify, so response timing
142/// doesn't reveal whether the account exists (audit H1).
143///
144/// # Errors
145/// Returns [`TenancyError::Driver`]/[`TenancyError::Exec`] for SQL
146/// failures, or [`TenancyError::Validation`] for malformed stored
147/// hashes (corrupt row).
148#[cfg(feature = "postgres")]
149pub async fn authenticate_operator(
150    registry: &PgPool,
151    username: &str,
152    password: &str,
153) -> Result<Option<Operator>, TenancyError> {
154    authenticate_operator_pool(
155        &crate::sql::Pool::Postgres(registry.clone()),
156        username,
157        password,
158    )
159    .await
160}
161
162/// Backend-agnostic counterpart of [`authenticate_operator`]. Routes
163/// the Operator lookup through [`crate::sql::FetcherPool`] so the same
164/// auth path works against `Pool::Postgres` / `Pool::Mysql` /
165/// `Pool::Sqlite`. Preferred for new code; the PG-typed wrapper above
166/// keeps existing callers compiling.
167///
168/// # Errors
169/// As [`authenticate_operator`].
170pub async fn authenticate_operator_pool(
171    registry: &crate::sql::Pool,
172    username: &str,
173    password: &str,
174) -> Result<Option<Operator>, TenancyError> {
175    use crate::sql::FetcherPool as _;
176    let rows: Vec<Operator> = Operator::objects()
177        .where_(Operator::username.eq(username.to_owned()))
178        .fetch(registry)
179        .await?;
180    let Some(op) = rows.into_iter().next() else {
181        // H1: spend a verify's worth of work on the unknown-user path
182        // so timing doesn't reveal whether the account exists.
183        password::verify_dummy(password);
184        return Ok(None);
185    };
186    // Verify before the active check so active vs inactive accounts
187    // take the same time (audit H1).
188    let password_ok = password::verify(password, &op.password_hash)?;
189    if !op.active || !password_ok {
190        return Ok(None);
191    }
192    Ok(Some(op))
193}
194
195/// Look up a tenant user by username and verify the password.
196///
197/// `conn` must be already scoped to the tenant — typically obtained
198/// via [`super::TenantPools::acquire`] (schema mode pre-sets
199/// `search_path`; database mode is naturally scoped to the tenant
200/// DB).
201///
202/// Same return semantics as [`authenticate_operator`]: `Ok(None)`
203/// for unknown user, wrong password, or inactive row; `Ok(Some(_))`
204/// only on a successful match.
205///
206/// v0.38 — kept as the PG-only entry point that runs against a raw
207/// `&mut PgConnection` for schema-mode tenants whose `search_path` is
208/// set on the connection. New code on any backend should reach for
209/// [`authenticate_user_pool`] instead, which takes the unified
210/// [`crate::sql::Pool`] enum and works on PG / SQLite / MySQL.
211///
212/// # Errors
213/// As [`authenticate_operator`].
214#[cfg(feature = "postgres")]
215pub async fn authenticate_user(
216    conn: &mut PgConnection,
217    username: &str,
218    password: &str,
219) -> Result<Option<User>, TenancyError> {
220    use crate::sql::sqlx::Row;
221    // We can't reuse `User::objects().fetch(&pool)` here because we
222    // have a connection, not a pool. Hand-write the query — small
223    // surface, not a hot path.
224    let user_rows = rustango::sql::sqlx::query(
225        "SELECT id, username, password_hash, is_superuser, active, created_at \
226         FROM rustango_users WHERE username = $1",
227    )
228    .bind(username)
229    .fetch_optional(&mut *conn)
230    .await?;
231    let Some(row) = user_rows else {
232        // H1: equalize timing for the unknown-user path.
233        password::verify_dummy(password);
234        return Ok(None);
235    };
236    let user = User {
237        id: Auto::Set(row.try_get::<i64, _>("id")?),
238        username: row.try_get::<String, _>("username")?,
239        password_hash: row.try_get::<String, _>("password_hash")?,
240        // Defensive get — tolerates rows from tenants not yet migrated
241        // to the SSO `email` column (mirrors `data` / `password_changed_at`).
242        #[cfg(feature = "sso")]
243        email: row.try_get::<Option<String>, _>("email").ok().flatten(),
244        is_superuser: row.try_get::<bool, _>("is_superuser")?,
245        active: row.try_get::<bool, _>("active")?,
246        created_at: row.try_get::<chrono::DateTime<chrono::Utc>, _>("created_at")?,
247        data: row
248            .try_get::<serde_json::Value, _>("data")
249            .unwrap_or_else(|_| serde_json::json!({})),
250        password_changed_at: row
251            .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("password_changed_at")
252            .ok()
253            .flatten(),
254    };
255    let password_ok = password::verify(password, &user.password_hash)?;
256    if !user.active || !password_ok {
257        return Ok(None);
258    }
259    Ok(Some(user))
260}
261
262/// Tri-dialect counterpart of [`authenticate_user`] (v0.38). Takes the
263/// unified [`crate::sql::Pool`] enum so the same body runs on PG,
264/// SQLite, and MySQL — the underlying ORM `_pool` helper picks the
265/// right placeholder + identifier-quoting rules per dialect.
266///
267/// `pool` must point at the tenant's storage:
268/// * Database-mode (any backend): a cached `sqlx::Pool<DB>` for the
269///   tenant DB.
270/// * Schema-mode (PG-only by language): a short-lived `PgPool` whose
271///   `after_connect` set `search_path` to the tenant's schema —
272///   typically built via
273///   [`super::TenantPools::scoped_pool_dyn`].
274///
275/// Same return semantics as [`authenticate_operator`]: `Ok(None)`
276/// for unknown user, wrong password, or inactive row; `Ok(Some(_))`
277/// only on a successful match.
278///
279/// # Errors
280/// As [`authenticate_operator_pool`].
281pub async fn authenticate_user_pool(
282    pool: &crate::sql::Pool,
283    username: &str,
284    password: &str,
285) -> Result<Option<User>, TenancyError> {
286    use crate::core::Column as _;
287    use crate::sql::FetcherPool as _;
288    let rows: Vec<User> = User::objects()
289        .where_(User::username.eq(username.to_owned()))
290        .fetch(pool)
291        .await?;
292    let Some(user) = rows.into_iter().next() else {
293        // H1: equalize timing for the unknown-user path.
294        password::verify_dummy(password);
295        return Ok(None);
296    };
297    let password_ok = password::verify(password, &user.password_hash)?;
298    if !user.active || !password_ok {
299        return Ok(None);
300    }
301    Ok(Some(user))
302}
303
304// ---------- Swappable user model ----------
305
306/// Marker trait for the model backing `rustango_users` in a tenant's
307/// storage. The framework's [`User`] implements it as the default.
308///
309/// Implement it on your own `#[derive(Model)]` struct when you want the
310/// tenant `rustango_users` table to carry **extra columns** beyond the
311/// framework's defaults (display name, timezone, avatar URL, …).
312///
313/// ## Contract
314///
315/// The implementing struct's [`crate::core::ModelSchema`] MUST:
316/// * have `table = "rustango_users"`,
317/// * include every column in [`REQUIRED_USER_COLUMNS`] with a compatible
318///   Rust type (the framework's auth path reads these by name).
319///
320/// Extras must be NULL-able or carry a `default = "…"` so existing
321/// tenants can run the bootstrap migration without per-row backfill.
322///
323/// Wire your model in via [`crate::manage::Cli::user_model`].
324///
325/// ```ignore
326/// #[derive(rustango::Model)]
327/// #[rustango(table = "rustango_users")]
328/// pub struct AppUser {
329///     #[rustango(primary_key)] pub id: rustango::sql::Auto<i64>,
330///     #[rustango(max_length = 64, unique)] pub username: String,
331///     #[rustango(max_length = 255)] pub password_hash: String,
332///     pub is_superuser: bool,
333///     pub active: bool,
334///     pub created_at: chrono::DateTime<chrono::Utc>,
335///     #[rustango(default = "'{}'")] pub data: serde_json::Value,
336///     // extras —
337///     #[rustango(max_length = 128, default = "''")] pub display_name: String,
338///     #[rustango(max_length = 64, default = "'UTC'")] pub timezone: String,
339/// }
340/// impl rustango::tenancy::TenantUserModel for AppUser {}
341/// ```
342pub trait TenantUserModel: crate::core::Model {}
343
344impl TenantUserModel for User {}
345
346/// Column names the framework's auth/admin paths read directly from
347/// `rustango_users`. A [`TenantUserModel`] schema must contain every
348/// one of these.
349pub const REQUIRED_USER_COLUMNS: &[&str] = &[
350    "id",
351    "username",
352    "password_hash",
353    "is_superuser",
354    "active",
355    "created_at",
356    "data",
357];
358
359/// Validate that `schema` is a viable `rustango_users` model — same
360/// table name and all [`REQUIRED_USER_COLUMNS`] present. Called by
361/// the bootstrap-migration generators so a misconfigured override
362/// fails fast with a clear message at `init-tenancy` time, not later
363/// during a tenant create or login.
364///
365/// # Errors
366/// Returns [`TenancyError::Validation`] when the table name is wrong
367/// or a required column is missing.
368pub fn validate_tenant_user_schema(schema: &crate::core::ModelSchema) -> Result<(), TenancyError> {
369    if schema.table != "rustango_users" {
370        return Err(TenancyError::Validation(format!(
371            "TenantUserModel must point at table \"rustango_users\", got \"{}\"",
372            schema.table
373        )));
374    }
375    for required in REQUIRED_USER_COLUMNS {
376        if !schema.fields.iter().any(|f| f.column == *required) {
377            return Err(TenancyError::Validation(format!(
378                "TenantUserModel \"{}\" is missing required column \"{}\"",
379                schema.name, required
380            )));
381        }
382    }
383    Ok(())
384}
385
386// ---------- HTTP Basic helpers ----------
387
388/// Parse an `Authorization: Basic <base64>` header value into
389/// `(username, password)`. Returns `None` for missing or malformed
390/// headers; the caller surfaces a 401 in that case.
391#[must_use]
392pub fn parse_basic_auth(header_value: Option<&str>) -> Option<(String, String)> {
393    let raw = header_value?;
394    let encoded = raw.strip_prefix("Basic ")?;
395    let decoded = base64::engine::general_purpose::STANDARD
396        .decode(encoded.trim())
397        .ok()?;
398    let s = String::from_utf8(decoded).ok()?;
399    let (user, pass) = s.split_once(':')?;
400    Some((user.to_owned(), pass.to_owned()))
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn parse_basic_auth_decodes_standard_format() {
409        // base64 of "alice:hunter2" → "YWxpY2U6aHVudGVyMg=="
410        let v = "Basic YWxpY2U6aHVudGVyMg==";
411        let (u, p) = parse_basic_auth(Some(v)).unwrap();
412        assert_eq!(u, "alice");
413        assert_eq!(p, "hunter2");
414    }
415
416    #[test]
417    fn parse_basic_auth_rejects_non_basic_scheme() {
418        assert!(parse_basic_auth(Some("Bearer tokenhere")).is_none());
419        assert!(parse_basic_auth(Some("Digest qop=auth")).is_none());
420    }
421
422    #[test]
423    fn parse_basic_auth_rejects_missing_colon() {
424        // base64 of "no-colon-here"
425        let v = "Basic bm8tY29sb24taGVyZQ==";
426        assert!(parse_basic_auth(Some(v)).is_none());
427    }
428
429    #[test]
430    fn parse_basic_auth_handles_none_header() {
431        assert!(parse_basic_auth(None).is_none());
432    }
433
434    #[test]
435    fn validate_accepts_default_user() {
436        use crate::core::Model as _;
437        validate_tenant_user_schema(&User::SCHEMA).unwrap();
438    }
439
440    #[test]
441    fn validate_rejects_wrong_table() {
442        use crate::core::Model as _;
443        // Use Operator's schema — same shape-ish but wrong table name.
444        let err = validate_tenant_user_schema(&Operator::SCHEMA).unwrap_err();
445        let msg = format!("{err}");
446        assert!(msg.contains("rustango_users"), "{msg}");
447    }
448
449    #[derive(crate::Model, Debug, Clone)]
450    #[rustango(table = "rustango_users")]
451    #[allow(dead_code)]
452    pub struct MissingDataColumn {
453        #[rustango(primary_key)]
454        pub id: rustango::sql::Auto<i64>,
455        #[rustango(max_length = 64, unique)]
456        pub username: String,
457        #[rustango(max_length = 255)]
458        pub password_hash: String,
459        pub is_superuser: bool,
460        pub active: bool,
461        pub created_at: chrono::DateTime<chrono::Utc>,
462        // `data` deliberately omitted
463    }
464
465    #[test]
466    fn validate_rejects_missing_required_column() {
467        use crate::core::Model as _;
468        let err = validate_tenant_user_schema(&MissingDataColumn::SCHEMA).unwrap_err();
469        let msg = format!("{err}");
470        assert!(msg.contains("data"), "{msg}");
471    }
472}