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}