rustango 0.57.0

Django-shaped 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
//! `Org` — the registry-level tenant record.
//!
//! Lives in the **registry database** (the one configured at boot via
//! `RUSTANGO_REGISTRY_URL`). Every other database the application
//! reaches is discovered through this table. Adding a tenant is
//! `INSERT INTO rustango_orgs (...)`, no config edit and no restart.
//!
//! ## Fields and what they mean
//!
//! | Field            | Purpose                                                     |
//! |------------------|-------------------------------------------------------------|
//! | `id`             | Server-assigned PK (`Auto<i64>` → `BIGSERIAL`).             |
//! | `slug`           | Globally-unique URL-safe handle (e.g. `"acme"`).            |
//! | `display_name`   | Human-friendly label for the admin UI.                      |
//! | `storage_mode`   | `"schema"` (in the registry DB) or `"database"` (dedicated).|
//! | `database_url`   | Secret reference (`vault://...`/`env://...`/literal URL).   |
//! | `schema_name`    | Per-tenant schema in `"schema"` mode (defaults to `slug`).  |
//! | `host_pattern`   | `Host` header to match (e.g. `"acme.app.com"`).             |
//! | `port`           | Incoming port to match for port-based routing.              |
//! | `path_prefix`    | URL path prefix (e.g. `"/acme"`) — opt-in, null by default. |
//! | `active`         | Soft-disable without dropping data.                         |
//! | `created_at`     | Set on insert.                                              |
//!
//! ## What's missing in Slice 1
//!
//! * `slug` carries `#[rustango(unique)]` so the DDL writer emits the
//!   `UNIQUE` constraint inline; the bootstrap migration uses a plain
//!   `CreateTable` op with no separate `DataOp`.
//! * `storage_mode` is a raw `String` because rustango models can't
//!   carry custom enums yet. The [`StorageMode`] helper enum in this
//!   module wraps the conversion.
//! * `database_url` carries plaintext for now. v0.5 Slice 3.5 adds the
//!   `SecretsResolver` indirection so the value can be a vault
//!   reference instead of a literal URL.

use crate::Model;

/// Registry record describing one tenant.
///
/// `#[derive(Model)]` registers it in the inventory, so any binary
/// linking `rustango-tenancy` automatically picks up the table when
/// `migrate::apply_all` walks the registry — or via
/// `make_migrations` against the registry DB.
#[derive(Model, Debug, Clone, serde::Serialize)]
#[rustango(table = "rustango_orgs", display = "slug", scope = "registry")]
#[allow(dead_code)] // Slices 2+ wire these fields into resolvers / pools / migrations.
pub struct Org {
    #[rustango(primary_key)]
    pub id: rustango::Auto<i64>,

    #[rustango(max_length = 64, unique)]
    pub slug: String,

    #[rustango(max_length = 255)]
    pub display_name: String,

    /// `"schema"` or `"database"`. See [`StorageMode`].
    #[rustango(max_length = 16)]
    pub storage_mode: String,

    /// Backend driver for this tenant. `"postgres"` (default),
    /// `"mysql"`, or `"sqlite"`. See [`BackendKind`]. v0.33
    /// constrains `storage_mode = "schema" ⟹ backend_kind = "postgres"`
    /// — see [`BackendKind::validate_storage_mode`].
    ///
    /// Defaults to `"postgres"` for orgs created before v0.33; the
    /// migration backfills the column with that value.
    #[rustango(max_length = 16, default = "'postgres'")]
    pub backend_kind: String,

    /// Secret reference resolved by `SecretsResolver` at pool-build
    /// time. For `storage_mode = "schema"` this is `None` (the
    /// registry pool is reused). For `"database"` it is the
    /// connection-URL reference (literal, env-var, or vault path).
    pub database_url: Option<String>,

    /// Schema name in `"schema"` mode. `None` means "use the slug".
    #[rustango(max_length = 64)]
    pub schema_name: Option<String>,

    /// `Host` header pattern (e.g. `"acme.app.com"`). The default
    /// resolver chain matches subdomain-first, so `manage
    /// create-tenant <slug>` populates this as `"<slug>.<apex>"`
    /// where `<apex>` comes from `RUSTANGO_APEX_DOMAIN`.
    #[rustango(max_length = 255)]
    pub host_pattern: Option<String>,

    /// Incoming port for port-based resolution. Niche — used for
    /// hard-isolated tenant ports in compliance scenarios.
    pub port: Option<i32>,

    /// URL path prefix (e.g. `"/acme"`). Opt-in, null by default —
    /// the headline routing mode is subdomain. Operators set this
    /// per-tenant when they need both modes (subdomain in prod,
    /// path in dev).
    #[rustango(max_length = 255)]
    pub path_prefix: Option<String>,

    /// Soft-disable. Inactive orgs return 404 from the resolver
    /// without dropping their data.
    pub active: bool,

    pub created_at: chrono::DateTime<chrono::Utc>,

    // ----- Branding (v0.26+).
    //
    // Per-tenant branding for the admin UI. All optional — when unset
    // the admin falls back to the static `Builder.title()/subtitle()`
    // defaults. Logo / favicon paths point inside the brand storage
    // root (configurable via `RUSTANGO_BRAND_STORAGE_DIR`); the
    // operator console serves them at `/__brand__/{slug}/{filename}`.
    /// Display name shown in the tenant admin sidebar header. When
    /// `None`, the admin falls back to `display_name`, then to the
    /// static admin title.
    #[rustango(max_length = 80)]
    pub brand_name: Option<String>,

    /// Optional tagline shown below the brand name.
    #[rustango(max_length = 200)]
    pub brand_tagline: Option<String>,

    /// Relative filename of the uploaded logo inside the per-org brand
    /// directory. Set by `POST /orgs/{slug}/edit/branding`. Read-only
    /// in the regular config form — uploads happen through the
    /// multipart sub-form.
    #[rustango(max_length = 120)]
    pub logo_path: Option<String>,

    /// Relative filename of the uploaded favicon. Same shape as
    /// `logo_path`.
    #[rustango(max_length = 120)]
    pub favicon_path: Option<String>,

    /// Hex primary color (e.g. `"#b04a2c"`). Maps to the
    /// `--color-accent` CSS variable; the branding module derives
    /// hover + soft-bg shades from it.
    #[rustango(max_length = 7)]
    pub primary_color: Option<String>,

    /// Theme mode — one of `"light"`, `"dark"`, `"auto"`. `None` is
    /// treated as `"auto"` (follow `prefers-color-scheme`).
    #[rustango(max_length = 8)]
    pub theme_mode: Option<String>,
    // SSO / OpenID Connect config moved off the `Org` row (v0.47): it now
    // lives in the `SsoProvider` (per-tenant) + `SharedSsoProvider`
    // (registry-wide) models, so a tenant can offer MANY providers, all
    // managed from the admin UI. See `crate::admin::sso_provider` and
    // `crate::tenancy::sso::SharedSsoProvider`.
}

/// Convenience enum for [`Org::storage_mode`]. The model field stays
/// a raw `String` because rustango doesn't yet carry custom enums in
/// the schema layer; this wrapper bridges to/from `&str`.
///
/// ```
/// use rustango::tenancy::StorageMode;
/// assert_eq!(StorageMode::Schema.as_str(), "schema");
/// assert_eq!(StorageMode::Database.as_str(), "database");
/// assert_eq!(StorageMode::parse("schema").unwrap(), StorageMode::Schema);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageMode {
    /// Tenant data lives in a dedicated schema in the **registry**
    /// Postgres database. Cheap (one connection pool for all
    /// tenants), good for small/medium tenant counts.
    Schema,
    /// Tenant data lives in a fully separate Postgres database
    /// (possibly on a different server). Strong isolation,
    /// per-tenant pools.
    Database,
}

impl StorageMode {
    /// Stable string form persisted to `Org.storage_mode`.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Schema => "schema",
            Self::Database => "database",
        }
    }

    /// Parse the string form. Errors are returned as
    /// [`super::TenancyError::Validation`] by the caller — this
    /// returns a static `&str` so the error message can name the
    /// allowed values without an alloc.
    ///
    /// # Errors
    /// Returns `Err(unrecognized_value)` when `s` doesn't match a
    /// known variant — caller wraps in `TenancyError::Validation`.
    pub fn parse(s: &str) -> Result<Self, &str> {
        match s {
            "schema" => Ok(Self::Schema),
            "database" => Ok(Self::Database),
            other => Err(other),
        }
    }
}

impl core::fmt::Display for StorageMode {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Backend driver for a tenant's storage. Mirrors [`StorageMode`]'s
/// string-on-the-row pattern — the column is a raw `String`, this
/// enum is the typed bridge.
///
/// **Storage-mode constraint:** `StorageMode::Schema` is only valid
/// for `BackendKind::Postgres`. MySQL "schema" ≡ "database" with
/// different transaction semantics; SQLite has no namespaces at all.
/// [`Self::validate_storage_mode`] enforces this — the admin's
/// org form, the `manage create-tenant` CLI, and
/// `migrate-tenant-storage` all call it before persisting.
///
/// ```
/// use rustango::tenancy::{BackendKind, StorageMode};
/// assert_eq!(BackendKind::Postgres.as_str(), "postgres");
/// assert!(BackendKind::Postgres.validate_storage_mode(StorageMode::Schema).is_ok());
/// assert!(BackendKind::Sqlite.validate_storage_mode(StorageMode::Schema).is_err());
/// assert!(BackendKind::MySql.validate_storage_mode(StorageMode::Database).is_ok());
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BackendKind {
    /// Postgres — the v0.5+ default. Supports schema-mode + database-mode.
    #[default]
    Postgres,
    /// MySQL — database-mode only (schema-mode rejected at validation time).
    MySql,
    /// SQLite — database-mode only. Each tenant gets its own file.
    Sqlite,
}

impl BackendKind {
    /// Stable string form persisted to `Org.backend_kind`.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Postgres => "postgres",
            Self::MySql => "mysql",
            Self::Sqlite => "sqlite",
        }
    }

    /// Parse the string form.
    ///
    /// # Errors
    /// Returns `Err(unrecognized_value)` when `s` doesn't match a
    /// known variant — caller wraps in `TenancyError::Validation`.
    pub fn parse(s: &str) -> Result<Self, &str> {
        match s {
            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
            "mysql" | "mariadb" => Ok(Self::MySql),
            "sqlite" | "sqlite3" => Ok(Self::Sqlite),
            other => Err(other),
        }
    }

    /// Validate the `(storage_mode, backend_kind)` pair. Returns
    /// `Err` with a human-readable explanation when the
    /// combination isn't supported.
    ///
    /// The only currently-disallowed combination is
    /// `Schema + (MySql | Sqlite)` — see the type-level comment on
    /// [`Self`] for rationale. All other pairs are permitted.
    ///
    /// # Errors
    /// `Err(&'static str)` on an unsupported combination.
    pub const fn validate_storage_mode(self, mode: StorageMode) -> Result<(), &'static str> {
        match (self, mode) {
            (Self::Postgres, _) => Ok(()),
            (_, StorageMode::Database) => Ok(()),
            (Self::MySql, StorageMode::Schema) => {
                Err("MySQL backend doesn't support schema-mode tenancy \
                 (use storage_mode=database)")
            }
            (Self::Sqlite, StorageMode::Schema) => {
                Err("SQLite backend doesn't support schema-mode tenancy \
                 (use storage_mode=database; each tenant gets its own file)")
            }
        }
    }
}

impl core::fmt::Display for BackendKind {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A cheap fingerprint of `rustango_orgs`, used to notice a change made
/// by **another process**.
///
/// The base-host resolution cache is per-process, so without this a
/// tenant created or suspended on one pod would keep resolving the old
/// way on every other pod until their entries aged out. Each pod re-reads
/// this periodically and drops its cache when it moves, which bounds
/// cross-pod convergence at the poll interval instead of the cache TTL.
///
/// Clock-free by design, for the same two reasons the sibling
/// `rustango_org_hosts` fingerprint is: an `auto_now` column takes its
/// value from the database clock on INSERT but the writing process's
/// clock on UPDATE, so a lagging pod can write a timestamp older than
/// the current max and the change never propagates — and adding a
/// `NOT NULL` column with a non-constant default is rejected outright by
/// SQLite on any table that already has rows.
///
/// | mutation | what moves |
/// |---|---|
/// | tenant created | `count`, `max_id`, `active`, `active_id_sum` |
/// | tenant suspended (`active = false`) | `active`, `active_id_sum` |
/// | tenant hard-deleted | `count`, `active_id_sum` |
///
/// **What it does not catch:** an in-place field edit that changes
/// neither the row count nor the active set — rotating `database_url`,
/// renaming `host_pattern`. Those converge on the cache TTL instead.
/// That is a deliberate trade: the alternative is a per-row checksum on
/// the hot path, and the events that need to be fast (create, suspend)
/// are exactly the ones these terms cover.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OrgGeneration {
    /// Total rows — moves on create and hard-delete.
    pub count: i64,
    /// Rows with `active = true` — moves on suspend/reactivate, which
    /// changes neither `count` nor `max_id`.
    pub active: i64,
    /// Largest row id — distinguishes a create-plus-delete inside one
    /// interval, which leaves `count` unchanged.
    pub max_id: i64,
    /// Sum of the ids of the active rows — identifies *which* tenants
    /// are live, not just how many, so suspending one while
    /// reactivating another in the same interval still moves it.
    pub active_id_sum: i64,
}

/// Read the [`OrgGeneration`] fingerprint — one aggregate query, four
/// scalars, no rows decoded.
///
/// # Errors
/// Driver / query failures.
pub async fn generation(
    registry: &crate::sql::Pool,
) -> Result<OrgGeneration, crate::sql::ExecError> {
    use crate::core::aggregates::{count_all, max, sum};
    use crate::core::{AggregateQuery, Column as _, Model as _, WhereExpr};
    use crate::sql::fetch_aggregate_pool;

    let q = AggregateQuery {
        model: Org::SCHEMA,
        joins: Vec::new(),
        where_clause: WhereExpr::And(Vec::new()),
        group_by: Vec::new(),
        aggregates: vec![
            ("n".into(), count_all().into()),
            (
                "n_on".into(),
                count_all().filter(Org::active.eq(true)).into(),
            ),
            ("max_id".into(), max("id").into()),
            (
                "on_id_sum".into(),
                sum("id").filter(Org::active.eq(true)).into(),
            ),
        ],
        aliases: Vec::new(),
        having: None,
        order_by: Vec::new(),
        limit: None,
        offset: None,
    };
    // Every term is NULL-able on an empty table (`MAX` / `SUM` of no
    // rows), so each decodes as `Option` and folds to 0 — an empty
    // registry fingerprints stably rather than erroring.
    let rows: Vec<(Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
        fetch_aggregate_pool(registry, &q).await?;
    let (n, n_on, max_id, on_id_sum) = rows.into_iter().next().unwrap_or((None, None, None, None));
    Ok(OrgGeneration {
        count: n.unwrap_or(0),
        active: n_on.unwrap_or(0),
        max_id: max_id.unwrap_or(0),
        active_id_sum: on_id_sum.unwrap_or(0),
    })
}

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

    #[test]
    fn parse_accepts_aliases() {
        assert_eq!(
            BackendKind::parse("postgres").unwrap(),
            BackendKind::Postgres
        );
        assert_eq!(
            BackendKind::parse("postgresql").unwrap(),
            BackendKind::Postgres
        );
        assert_eq!(BackendKind::parse("pg").unwrap(), BackendKind::Postgres);
        assert_eq!(BackendKind::parse("mysql").unwrap(), BackendKind::MySql);
        assert_eq!(BackendKind::parse("mariadb").unwrap(), BackendKind::MySql);
        assert_eq!(BackendKind::parse("sqlite").unwrap(), BackendKind::Sqlite);
        assert_eq!(BackendKind::parse("sqlite3").unwrap(), BackendKind::Sqlite);
    }

    #[test]
    fn parse_rejects_unknown() {
        assert!(BackendKind::parse("oracle").is_err());
        assert!(BackendKind::parse("").is_err());
    }

    #[test]
    fn validate_storage_mode_postgres_accepts_both() {
        assert!(BackendKind::Postgres
            .validate_storage_mode(StorageMode::Schema)
            .is_ok());
        assert!(BackendKind::Postgres
            .validate_storage_mode(StorageMode::Database)
            .is_ok());
    }

    #[test]
    fn validate_storage_mode_mysql_rejects_schema() {
        let err = BackendKind::MySql
            .validate_storage_mode(StorageMode::Schema)
            .unwrap_err();
        assert!(err.contains("MySQL"));
    }

    #[test]
    fn validate_storage_mode_sqlite_rejects_schema() {
        let err = BackendKind::Sqlite
            .validate_storage_mode(StorageMode::Schema)
            .unwrap_err();
        assert!(err.contains("SQLite"));
    }

    #[test]
    fn non_postgres_accepts_database_mode() {
        assert!(BackendKind::MySql
            .validate_storage_mode(StorageMode::Database)
            .is_ok());
        assert!(BackendKind::Sqlite
            .validate_storage_mode(StorageMode::Database)
            .is_ok());
    }

    #[test]
    fn default_is_postgres() {
        assert_eq!(BackendKind::default(), BackendKind::Postgres);
    }
}