use std::path::{Path, PathBuf};
use autumn_web::config::DatabaseBackend;
use super::emit::{Plan, Revert};
use super::model::ensure_cargo_dependencies;
use super::schema_edit::{ensure_autumn_web_feature, update_main_rs};
use super::{GenerateError, ensure_project_root, read_or_empty};
const TEAMS_DEPS: &[(&str, &str)] = &[
("chrono", "{ version = \"0.4\", features = [\"serde\"] }"),
(
"diesel",
"{ version = \"2\", features = [\"postgres\", \"chrono\"] }",
),
(
"diesel-async",
"{ version = \"0.9\", features = [\"postgres\"] }",
),
(
"pq-sys",
"{ version = \"0.7\", features = [\"bundled_without_openssl\"] }",
),
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
(
"validator",
"{ version = \"0.21\", features = [\"derive\"] }",
),
("maud", "{ version = \"0.27\", features = [\"axum\"] }"),
];
const TEAMS_DEPS_SQLITE: &[(&str, &str)] = &[
("chrono", "{ version = \"0.4\", features = [\"serde\"] }"),
(
"diesel",
"{ version = \"2\", features = [\"sqlite\", \"chrono\", \"serde_json\", \
\"returning_clauses_for_sqlite_3_35\"] }",
),
(
"diesel-async",
"{ version = \"0.9\", features = [\"sync-connection-wrapper\"] }",
),
(
"libsqlite3-sys",
"{ version = \"0.38\", features = [\"bundled\"] }",
),
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
(
"validator",
"{ version = \"0.21\", features = [\"derive\"] }",
),
("maud", "{ version = \"0.27\", features = [\"axum\"] }"),
];
const fn teams_deps(backend: DatabaseBackend) -> &'static [(&'static str, &'static str)] {
match backend {
DatabaseBackend::Postgres => TEAMS_DEPS,
DatabaseBackend::Sqlite => TEAMS_DEPS_SQLITE,
}
}
pub fn plan_teams(
project_root: &Path,
timestamp: &str,
_for_destroy: bool,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let backend = super::detect_backend(project_root);
let mut plan = Plan::new(project_root);
let teams_dir = project_root.join("src").join("teams");
plan.create(teams_dir.join("mod.rs"), render_mod_rs());
plan.create(teams_dir.join("schema.rs"), render_schema_rs());
plan.create(teams_dir.join("models.rs"), render_models_rs());
plan.create(teams_dir.join("role.rs"), render_role_rs());
plan.create(teams_dir.join("repositories.rs"), render_repositories_rs());
plan.create(
teams_dir.join("mailers").join("mod.rs"),
render_mailers_mod_rs(),
);
plan.create(
teams_dir.join("mailers").join("invitation_mailer.rs"),
render_invitation_mailer_rs(),
);
plan.create(
teams_dir.join("routes").join("mod.rs"),
render_routes_mod_rs(),
);
plan.create(
teams_dir.join("routes").join("organizations.rs"),
render_routes_organizations_rs(),
);
plan.create(
teams_dir.join("routes").join("invitations.rs"),
render_routes_invitations_rs(),
);
plan.create(
teams_dir.join("routes").join("members.rs"),
render_routes_members_rs(),
);
let migration_dir = existing_teams_migration_dir(project_root).unwrap_or_else(|| {
project_root
.join("migrations")
.join(format!("{timestamp}_create_teams"))
});
plan.create(migration_dir.join("up.sql"), render_migration_up(backend));
plan.create(migration_dir.join("down.sql"), MIGRATION_DOWN.to_owned());
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|e| {
GenerateError::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", main_path.display()),
))
})?;
let route_entries = teams_route_entries();
let updated_main = update_main_rs(&main_existing, &["teams"], &route_entries);
plan.modify(main_path.clone(), updated_main);
plan.push_revert(Revert::RoutesEntries {
path: main_path,
entries: route_entries,
});
plan_teams_cargo_toml(&mut plan, project_root, teams_dir, backend);
plan.warn(
"teams' Membership/Invitation repositories are tenant_scoped by the active \
organization (issue #695). Make sure autumn.toml has:\n\
\x20 [tenancy]\n\
\x20 enabled = true\n\
\x20 source = \"session\"\n\
\x20 session_key = \"organization_id\"\n\
if it isn't already configured — see docs/generate-teams.md. If you want \
logged-out invitees to load the invite-accept page, add \"/invite\" (NOT \
\"/invitations\") to [tenancy] public_paths — public_paths matches by path \
prefix, so listing \"/invitations\" would also exempt the Admin-only \
create/revoke/resend routes from tenant resolution. src/teams/routes/invitations.rs' \
`caller_email_lookup` module hard-codes a `users` table name — if you generated \
auth with a custom resource name (e.g. `autumn generate auth Account`, whose table \
is `accounts`, not `users`), edit that module to match your actual auth table or \
invitation acceptance will fail with a missing-relation database error.",
);
Ok(plan)
}
fn plan_teams_cargo_toml(
plan: &mut Plan,
project_root: &Path,
teams_dir: PathBuf,
backend: DatabaseBackend,
) {
let autumn_web_features: &[&str] = match backend {
DatabaseBackend::Postgres => &["mail", "maud"],
DatabaseBackend::Sqlite => &["mail", "maud", "sqlite"],
};
let cargo_path = project_root.join("Cargo.toml");
let cargo_existing = read_or_empty(&cargo_path);
let mut updated_cargo = cargo_existing.clone();
for feature in autumn_web_features {
updated_cargo = ensure_autumn_web_feature(&updated_cargo, feature);
}
let deps = teams_deps(backend);
updated_cargo = ensure_cargo_dependencies(&updated_cargo, deps);
if updated_cargo != cargo_existing {
plan.modify(cargo_path.clone(), updated_cargo);
}
for feature in autumn_web_features {
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path.clone(),
feature: (*feature).to_owned(),
owner_dir: Some(teams_dir.clone()),
});
}
let dep_names: Vec<String> = deps
.iter()
.map(|(name, _)| *name)
.filter(|name| *name != "autumn-web" && *name != "diesel_migrations")
.map(str::to_owned)
.collect();
if !dep_names.is_empty() {
plan.push_revert(Revert::CargoDeps {
path: cargo_path,
names: dep_names,
owner_dir: teams_dir,
});
}
}
fn existing_teams_migration_dir(project_root: &Path) -> Option<PathBuf> {
let mut matches: Vec<PathBuf> = std::fs::read_dir(project_root.join("migrations"))
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("_create_teams"))
})
.collect();
matches.sort();
matches.into_iter().next()
}
fn teams_route_entries() -> Vec<String> {
vec![
"teams::routes::organizations::create_organization".to_owned(),
"teams::routes::organizations::switch_organization".to_owned(),
"teams::routes::invitations::create_invitation".to_owned(),
"teams::routes::invitations::show_invitation".to_owned(),
"teams::routes::invitations::accept_invitation".to_owned(),
"teams::routes::invitations::revoke_invitation".to_owned(),
"teams::routes::invitations::resend_invitation".to_owned(),
"teams::routes::members::list_members".to_owned(),
"teams::routes::members::change_role".to_owned(),
"teams::routes::members::remove_member".to_owned(),
]
}
fn render_mod_rs() -> String {
MOD_RS.to_owned()
}
const MOD_RS: &str = r#"//! Team membership, roles, and email invitations (issue #1261).
//!
//! Generated by `autumn generate teams`. Composes three already-stable
//! Autumn primitives rather than introducing a new authorization mechanism:
//! - `#[repository(..., tenant_scoped)]` (issue #695) — every `Membership`/
//! `Invitation` read and write is automatically filtered/stamped by the
//! active organization.
//! - The session `"role"` key (issue #496) — the same key
//! `#[secured("...")]`/`PolicyContext::has_role` already read, so
//! [`role::require_role`] layers on top of existing session plumbing
//! rather than adding a second one.
//! - The Mail stack (`#[mailer]`) — [`mailers::invitation_mailer::InvitationMailer`]
//! sends the invite email.
//!
//! ## Composition boundary with your own auth
//!
//! This module intentionally does NOT generate a `routes/auth.rs` — your
//! app's login/signup already exists (e.g. via `autumn generate auth`), and
//! this module has no idea what your `User` model or `users` table look
//! like. `Membership::user_id` / `Invitation::invited_by_user_id` are bare
//! `i64` with no foreign-key coupling to your schema (see `schema.rs`'s doc
//! comment).
//!
//! Two lines of integration code are all that's needed to wire teams into
//! your existing auth flow (see `docs/generate-teams.md` for the full
//! walkthrough):
//!
//! 1. **At signup**, right after your handler creates the account row, call
//! [`routes::organizations::provision_default_organization`] to create
//! the new user's personal organization and make them its `Owner`. This
//! only writes the organization/membership rows — it deliberately does
//! NOT touch the session (see its own doc comment for why: signup and
//! "authenticated" aren't the same event once email confirmation is
//! involved, e.g. with `autumn generate auth`'s scaffolded `signup`):
//!
//! ```ignore
//! teams::routes::organizations::provision_default_organization(
//! user.id, &mut db,
//! ).await?;
//! ```
//!
//! This covers the organization + membership pair atomically, but not
//! your own preceding user insert — if your signup handler doesn't wrap
//! that insert in a transaction (like `autumn generate auth`'s
//! scaffolded one), a failure here still leaves the account stranded
//! with no organization. For full 3-way atomicity, wrap your own user
//! insert in `db.tx(...)` and call
//! [`routes::organizations::provision_default_organization_on_conn`] on
//! the same `conn` instead — see `docs/generate-teams.md` for a worked
//! example.
//!
//! 2. **At login**, after your handler authenticates the user, resolve their
//! active organization/role and set it on the session — this is what
//! actually establishes the org session, on the user's first real login:
//!
//! ```ignore
//! let memberships = membership_repo.across_tenants().find_by_user_id(user.id).await?;
//! if let Some(active) = memberships.into_iter().min_by_key(|m| m.id) {
//! let role = teams::role::Role::parse(&active.role).unwrap_or(teams::role::Role::Member);
//! teams::role::establish_org_session(&session, user.id, &active.tenant_id, role).await;
//! }
//! ```
//!
//! 3. **At account deletion**, before (or as part of) your handler deletes
//! the account row, call
//! [`routes::organizations::remove_all_memberships`] — `Membership` has
//! no FK to your `users` table (see above), so nothing else notices the
//! account is gone. Skipping this step leaves a deleted user's team
//! access live on any device with a still-valid session cookie, since
//! [`role::require_role`] only re-checks the `Membership` row, not the
//! account itself:
//!
//! ```ignore
//! teams::routes::organizations::remove_all_memberships(user.id, &mut db).await?;
//! ```
//!
//! Gate any admin-only handler with
//! `teams::role::require_role(&session, &membership_repo, teams::role::Role::Admin).await?;`
//! (taking `membership_repo: teams::repositories::PgMembershipRepository` as
//! a handler parameter).
//!
//! See `examples/teams` in the Autumn repository for a fully-wired reference
//! application — it owns its whole app (including the `users` table), so it
//! inlines steps 1-2 above directly into its own `routes/auth.rs` rather than
//! exposing them as a separate seam, and step 3 is unnecessary there:
//! `examples/teams`'s own `memberships.user_id` is a real
//! `REFERENCES users (id) ON DELETE CASCADE` FK, since that app owns its
//! `users` table and can afford the coupling this generator can't.
pub mod mailers;
pub mod models;
pub mod repositories;
pub mod role;
pub mod routes;
pub mod schema;
"#;
fn render_schema_rs() -> String {
SCHEMA_RS.to_owned()
}
const SCHEMA_RS: &str = r"//! Diesel table definitions for the `teams` module (issue #1261). These
//! mirror `migrations/<timestamp>_create_teams/`.
//!
//! Deliberately NOT `joinable!`/FK-coupled to your app's own `users` table —
//! its shape is unknown at generate time. `user_id` / `invited_by_user_id`
//! are bare `Int8`; see `models.rs`'s doc comment on `Membership::tenant_id`
//! for why `tenant_id` is `Text` rather than a typed FK to `organizations`
//! (the `tenant_scoped` repository macro's generated queries filter on a
//! column literally named `tenant_id`).
diesel::table! {
organizations (id) {
id -> Int8,
name -> Text,
created_at -> Timestamp,
}
}
diesel::table! {
memberships (id) {
id -> Int8,
tenant_id -> Text,
user_id -> Int8,
role -> Text,
created_at -> Timestamp,
}
}
diesel::table! {
invitations (id) {
id -> Int8,
tenant_id -> Text,
email -> Text,
role -> Text,
token_hash -> Text,
status -> Text,
invited_by_user_id -> Int8,
expires_at -> Timestamp,
created_at -> Timestamp,
}
}
";
fn render_models_rs() -> String {
MODELS_RS.to_owned()
}
const MODELS_RS: &str = r#"//! `Organization`, `Membership`, `Invitation` models and their form structs
//! (issue #1261).
use serde::Deserialize;
use crate::teams::schema::{invitations, memberships, organizations};
// ── Organization ─────────────────────────────────────────────────────────
//
// The tenant itself. Not tenant-scoped: creating one, and listing the ones a
// user belongs to, both necessarily run before/across any single active
// tenant.
/// An organization (tenant).
#[autumn_web::model(table = "organizations")]
pub struct Organization {
#[id]
pub id: i64,
#[validate(length(min = 1, max = 200))]
pub name: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
// ── Membership ───────────────────────────────────────────────────────────
//
// The user <-> organization join row, carrying the closed `Role` (stored as
// `TEXT`, validated by `crate::teams::role::Role::parse` everywhere it's
// read). `tenant_scoped` fills `tenant_id` from the active-organization
// session context and filters every read by it — reusing #695, not a second
// isolation mechanism.
/// A user's role within a single organization.
///
/// `tenant_id` holds the owning `Organization.id` in its string form (the
/// `tenant_scoped` repository macro's generated queries filter on a column
/// literally named `tenant_id` — see `../../migrations/`); look the
/// `Organization` row itself up via `tenant_id.parse::<i64>()` when needed.
#[autumn_web::model(table = "memberships")]
pub struct Membership {
#[id]
pub id: i64,
#[default]
pub tenant_id: String,
pub user_id: i64,
pub role: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
// ── Invitation ───────────────────────────────────────────────────────────
/// A pending (or resolved) email invitation into an organization. See
/// [`Membership`]'s doc comment for why `tenant_id` is a `String`.
#[autumn_web::model(table = "invitations")]
pub struct Invitation {
#[id]
pub id: i64,
#[default]
pub tenant_id: String,
pub email: String,
pub role: String,
pub token_hash: String,
// Not `#[default]`: revoking/resending/accepting need to write this
// field through `UpdateInvitation`, and `#[default]` fields are excluded
// from the generated `Update*` struct as well as `New*`. The DB column's
// `DEFAULT 'pending'` is a redundant safety net; every insert site here
// also sets it explicitly.
pub status: String,
pub invited_by_user_id: i64,
pub expires_at: chrono::NaiveDateTime,
#[default]
pub created_at: chrono::NaiveDateTime,
}
// ── Forms ────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct NewOrganizationForm {
pub name: String,
}
#[derive(Deserialize)]
pub struct InviteForm {
pub email: String,
pub role: String,
}
#[derive(Deserialize)]
pub struct ChangeRoleForm {
pub role: String,
}
"#;
fn render_role_rs() -> String {
ROLE_RS.to_owned()
}
const ROLE_RS: &str = r#"//! The closed membership-role enum, the `require_role` guard, and
//! `establish_org_session` — the session-writing half of the auth
//! integration seam (issue #1261; see `crate::teams`'s module doc for the
//! other half, `provision_default_organization`).
//!
//! `Membership.role` is stored as `TEXT` (with a SQL `CHECK` constraint as a
//! defense-in-depth backstop), but every place that *reads* a role goes
//! through [`Role::parse`] — an unrecognized string can never silently pass
//! an authorization check.
//!
//! [`require_role`] resolves the caller's role by looking up their live
//! `Membership` row in the active organization — it only uses the session to
//! find *which user* is asking (`"user_id"`, set by
//! [`establish_org_session`]), the same identity signal
//! `#[secured("...")]`/`PolicyContext::has_role` rely on (issue #496), so this
//! is a hierarchy-aware guard layered on top of the existing session/Policy
//! plumbing, not a second authorization mechanism (issue #1261 AC2). Roles are
//! never trusted from the session's cached `"role"` string: that value can go
//! stale the instant another request revokes or changes the caller's
//! membership, so every check re-reads the database.
use autumn_web::prelude::*;
use crate::teams::repositories::{MembershipRepository, PgMembershipRepository};
/// A membership role, ordered `Member < Admin < Owner`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Member,
Admin,
Owner,
}
impl Role {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Admin => "admin",
Role::Member => "member",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Role> {
match s {
"owner" => Some(Role::Owner),
"admin" => Some(Role::Admin),
"member" => Some(Role::Member),
_ => None,
}
}
const fn rank(self) -> u8 {
match self {
Role::Member => 0,
Role::Admin => 1,
Role::Owner => 2,
}
}
/// Whether this role satisfies a `required` role or higher in the
/// hierarchy (e.g. an `Owner` satisfies `require_role(Role::Admin)`).
#[must_use]
pub const fn at_least(self, required: Role) -> bool {
self.rank() >= required.rank()
}
}
/// Require that the signed-in user's role in the active organization is
/// `required` or higher, e.g.
/// `require_role(&session, &membership_repo, Role::Admin).await?`.
///
/// Resolves `user_id` from the session (set by [`establish_org_session`]),
/// then re-reads that user's `Membership` row for the active organization
/// (`membership_repo` is `tenant_scoped`, so this is automatically filtered
/// to the caller's active tenant — see `repositories.rs`) rather than
/// trusting the session's cached `"role"` string, which can go stale as soon
/// as another request changes or revokes the caller's membership. Returns
/// the resolved role on success so callers that need it (e.g. to decide
/// whether to show owner-only controls) don't have to look it up twice.
///
/// # Errors
///
/// - `401 Unauthorized` when there's no signed-in user, or the signed-in user
/// has no membership in the active organization.
/// - `403 Forbidden` when the resolved role does not meet `required`.
pub async fn require_role(
session: &Session,
membership_repo: &PgMembershipRepository,
required: Role,
) -> AutumnResult<Role> {
let Some(user_id) = session
.get("user_id")
.await
.and_then(|s| s.parse::<i64>().ok())
else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let memberships = membership_repo.find_by_user_id(user_id).await?;
let Some(membership) = memberships.into_iter().next() else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(role) = Role::parse(&membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if role.at_least(required) {
Ok(role)
} else {
Err(AutumnError::forbidden_msg("insufficient permissions"))
}
}
/// Record the active organization + role on the session — e.g. after login,
/// after switching organizations, or after accepting an invitation. Not
/// called by
/// [`super::routes::organizations::provision_default_organization`] itself
/// (see that function's doc comment for why signup and "authenticated"
/// aren't always the same event) — the first login after signup is what
/// establishes the org session for a newly-provisioned organization.
///
/// `tenant_id` is the active `Organization.id` in its string form — the same
/// value the `tenant_scoped` `Membership`/`Invitation` repositories filter by
/// (see `models.rs`'s doc comment on `Membership::tenant_id`).
///
/// Deliberately does NOT call `session.rotate_id()` — that is your own
/// login/signup handler's responsibility (once per authentication event,
/// not once per "also set org context"). Call this only after your handler
/// has already established (and, for login, rotated) the base session.
pub async fn establish_org_session(session: &Session, user_id: i64, tenant_id: &str, role: Role) {
session.insert("user_id", user_id.to_string()).await;
session.insert("organization_id", tenant_id).await;
session.insert("role", role.as_str()).await;
}
// `require_role` needs a live `Membership` row (see above), so it is exercised
// end-to-end by your own integration tests against a real database rather
// than with a session-only unit test here.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hierarchy_owner_satisfies_admin_and_member() {
assert!(Role::Owner.at_least(Role::Owner));
assert!(Role::Owner.at_least(Role::Admin));
assert!(Role::Owner.at_least(Role::Member));
}
#[test]
fn hierarchy_admin_satisfies_member_but_not_owner() {
assert!(Role::Admin.at_least(Role::Admin));
assert!(Role::Admin.at_least(Role::Member));
assert!(!Role::Admin.at_least(Role::Owner));
}
#[test]
fn hierarchy_member_satisfies_only_member() {
assert!(Role::Member.at_least(Role::Member));
assert!(!Role::Member.at_least(Role::Admin));
assert!(!Role::Member.at_least(Role::Owner));
}
#[test]
fn parse_round_trips_known_roles() {
for role in [Role::Owner, Role::Admin, Role::Member] {
assert_eq!(Role::parse(role.as_str()), Some(role));
}
}
#[test]
fn parse_rejects_unknown_strings() {
assert_eq!(Role::parse("superadmin"), None);
assert_eq!(Role::parse(""), None);
assert_eq!(Role::parse("Owner"), None); // case-sensitive, no silent coercion
}
}
"#;
fn render_repositories_rs() -> String {
REPOSITORIES_RS.to_owned()
}
const REPOSITORIES_RS: &str = r#"//! Data-access repositories for `teams` (issue #1261).
//!
//! `Organization` is not tenant-scoped — it *is* the tenant, so creating one
//! and listing the ones a user belongs to both necessarily run outside any
//! single active tenant.
//!
//! `Membership` and `Invitation` are `tenant_scoped` by the active
//! organization (`organization_id`, resolved from the session — see
//! `crate::teams`'s module doc for the `[tenancy]` config this needs). Every
//! read/write against them is filtered/stamped by it automatically. The few
//! places that must legitimately cross organizations (resolving *all* of a
//! user's memberships to pick one at login; looking up an invitation by its
//! token before any organization is active) use the explicit, auditable
//! `across_tenants()` escape hatch from issue #695 rather than a second
//! isolation mechanism.
use crate::teams::models::{
Invitation, Membership, NewInvitation, NewMembership, NewOrganization, Organization,
UpdateInvitation, UpdateMembership, UpdateOrganization,
};
use crate::teams::schema::{invitations, memberships, organizations};
#[autumn_web::repository(Organization, table = "organizations")]
pub trait OrganizationRepository {}
#[autumn_web::repository(Membership, table = "memberships", tenant_scoped)]
pub trait MembershipRepository {
/// All of a user's memberships, across every organization. Always called
/// via `.across_tenants()` — resolving which organizations a user
/// belongs to has to run before any one of them is "the" active tenant.
fn find_by_user_id(user_id: i64) -> Vec<Membership>;
}
#[autumn_web::repository(Invitation, table = "invitations", tenant_scoped)]
pub trait InvitationRepository {
/// Look up an invitation by its token hash. Always called via
/// `.across_tenants()` — an invite accept link is followed before the
/// visitor has an active organization (they may not even be signed in
/// yet).
fn find_by_token_hash(token_hash: String) -> Vec<Invitation>;
}
"#;
fn render_mailers_mod_rs() -> String {
"pub mod invitation_mailer;\n".to_owned()
}
fn render_invitation_mailer_rs() -> String {
INVITATION_MAILER_RS.to_owned()
}
const INVITATION_MAILER_RS: &str = r#"//! The invite email, sent through the shipped Mail stack (`#[mailer]`).
//!
//! A scaffolded, overridable template (issue #1261 AC4): edit
//! [`InvitationMailer::invite`] to change copy/branding, or replace `.html`
//! with your own Maud partial.
use autumn_web::prelude::*;
pub struct InvitationMailer;
#[mailer]
impl InvitationMailer {
/// `accept_url` is the fully-qualified `/invite/{token}` link; the
/// token itself is never logged or persisted in the clear (see
/// `routes::invitations::create_invitation`).
pub fn invite(
&self,
to: String,
organization_name: String,
role: String,
accept_url: String,
) -> Mail {
Mail::builder()
.to(to)
.subject(format!("You're invited to join {organization_name}"))
.html(html! {
p {
"You've been invited to join " strong { (organization_name) }
" as " (role) "."
}
p {
a href=(accept_url) { "Accept the invitation" }
}
p class="text-sm text-gray-500" { "This invitation expires in 7 days." }
})
.text(format!(
"You've been invited to join {organization_name} as {role}.\n\n\
Accept: {accept_url}\n\n\
This invitation expires in 7 days."
))
.build()
.expect("static invite template should be valid")
}
}
#[mailer_preview]
impl InvitationMailer {
fn invite_preview() -> Mail {
InvitationMailer.invite(
"preview@example.com".to_owned(),
"Acme, Inc.".to_owned(),
"admin".to_owned(),
"https://example.com/invite/preview-token".to_owned(),
)
}
}
pub fn mail_previews() -> Vec<MailPreview> {
mail_previews![InvitationMailer]
}
"#;
fn render_routes_mod_rs() -> String {
ROUTES_MOD_RS.to_owned()
}
const ROUTES_MOD_RS: &str = r#"//! Route handlers for `teams` (issue #1261): organizations, invitations,
//! members.
pub mod invitations;
pub mod members;
pub mod organizations;
use autumn_web::prelude::*;
use autumn_web::security::CsrfToken;
/// A minimal, dependency-free HTML wrapper for this module's own pages.
///
/// Deliberately does NOT call into your app's own `crate::layout` — this
/// module is meant to compose into any app regardless of that function's
/// signature/branding (see `crate::teams`'s composition-boundary note).
/// Swap these handlers to render through your own layout once you've wired
/// the integration seam.
pub(super) fn minimal_page(title: &str, content: Markup) -> Markup {
html! {
(maud::DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
title { (title) }
}
body {
(content)
}
}
}
}
/// The CSRF token to embed in a form's hidden `_csrf` field, or `""` when
/// `CsrfLayer` isn't mounted (e.g. `[security.csrf] enabled = false`).
///
/// Handlers extract `Option<CsrfToken>` (not the bare, non-optional
/// `CsrfToken`) specifically so they degrade gracefully rather than 500ing
/// when CSRF is disabled — every generated form embeds this in a hidden
/// `_csrf` field, since `CsrfLayer` is on by default (including Autumn's
/// production-profile smart default) and would otherwise reject every POST
/// this module's forms submit. Mirrors `examples/teams`'s `layout.rs`
/// helper of the same name.
pub(super) fn csrf_value(csrf: &Option<CsrfToken>) -> &str {
csrf.as_ref().map_or("", CsrfToken::token)
}
"#;
fn render_routes_organizations_rs() -> String {
ROUTES_ORGANIZATIONS_RS.to_owned()
}
const ROUTES_ORGANIZATIONS_RS: &str = r#"//! Creating additional organizations, switching the active one, and
//! provisioning the personal organization a new signup gets (issue #1261).
use autumn_web::prelude::*;
use autumn_web::reexports::axum::response::Response;
use autumn_web::reexports::scoped_futures::ScopedFutureExt;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::teams::models::{Membership, Organization};
use crate::teams::repositories::{MembershipRepository, PgMembershipRepository};
use crate::teams::role::{Role, establish_org_session};
use crate::teams::schema::{invitations, memberships, organizations};
#[derive(diesel::Insertable)]
#[diesel(table_name = organizations)]
struct InsertOrganization {
name: String,
}
#[derive(diesel::Insertable)]
#[diesel(table_name = memberships)]
struct InsertMembership {
tenant_id: String,
user_id: i64,
role: String,
}
/// Create a personal organization for a brand-new user and make them its
/// `Owner` — call this as the last line of your own signup handler (issue
/// #1261 AC3; see `crate::teams`'s module doc for the full two-step
/// integration).
///
/// Deliberately does NOT call [`establish_org_session`] or otherwise touch
/// the session: signup and "the user is authenticated" are not the same
/// event for every app. `autumn generate auth`'s own scaffolded `signup`
/// creates the account but does not log it in — it's gated on email
/// confirmation, and only starts a session once the user actually confirms
/// and logs in. If this function established the org session here, calling
/// it from that handler (the most natural place to call it, and the one
/// this crate's own docs point at) would silently authenticate an
/// unconfirmed account, bypassing the confirmation gate entirely. Creating
/// the organization/membership rows is safe unconditionally — resolving
/// the active org and calling `establish_org_session` is the *login*
/// flow's job (step 2 of the two-step integration), which naturally picks
/// this membership up the first time the user actually logs in.
///
/// `db` is your own signup handler's `Db` extractor: the organization and
/// membership inserts run in one transaction on it, so a failure between
/// the two can never leave an orphaned organization with no members. This
/// can't extend to your own preceding user insert — a plain function call
/// invoked *after* that insert has no way to join a transaction it wasn't
/// called inside of (even sharing the same connection wouldn't help once
/// the earlier insert has already committed on its own). If your signup
/// handler creates the account outside its own transaction (e.g.
/// `autumn generate auth`'s scaffolded `signup`, which doesn't wrap its
/// user insert in `db.tx(...)`), a failure here still leaves that account
/// stranded with no organization. Closing that gap needs the account
/// insert to run inside a transaction too, with
/// [`provision_default_organization_on_conn`] called on the same `conn` —
/// see that function's doc comment and `docs/generate-teams.md` for the
/// full atomic pattern.
pub async fn provision_default_organization(user_id: i64, db: &mut Db) -> AutumnResult<()> {
db.tx(move |conn| provision_default_organization_on_conn(conn, user_id).scope_boxed())
.await
}
/// The building block behind [`provision_default_organization`]: the same
/// two inserts, run directly on an already-open `conn` instead of opening
/// its own transaction.
///
/// Call this instead of [`provision_default_organization`] from *inside*
/// your own signup handler's `db.tx(move |conn| { ... }.scope_boxed())`
/// block — alongside your own user insert, on the same `conn` — when a
/// failure partway through must roll back the account too, not just leave
/// it with no organization. This is what makes true 3-way atomicity
/// (account + organization + membership) possible despite
/// [`provision_default_organization`] itself only being able to cover the
/// latter two: a plain function call has no way to join a transaction it
/// wasn't invoked inside of. See `docs/generate-teams.md` for a full
/// worked example.
pub async fn provision_default_organization_on_conn(
conn: &mut autumn_web::db::PooledConnection,
user_id: i64,
) -> AutumnResult<()> {
let org: Organization = diesel::insert_into(organizations::table)
.values(&InsertOrganization {
name: format!("User {user_id}'s Organization"),
})
.returning(Organization::as_returning())
.get_result(conn)
.await?;
diesel::insert_into(memberships::table)
.values(&InsertMembership {
tenant_id: org.id.to_string(),
user_id,
role: Role::Owner.as_str().to_owned(),
})
.execute(conn)
.await?;
Ok(())
}
/// Remove every membership `user_id` holds, across every organization —
/// call this from your own account-deletion handler, before (or as part of)
/// deleting the account row itself (Codex review finding: `Membership`
/// deliberately has no FK to your `users` table — see `crate::teams`'s
/// module doc — so deleting a user through e.g. `autumn generate auth`'s
/// scaffolded `account_destroy` does not touch these rows on its own; a
/// stale session cookie on another device would otherwise keep
/// `require_role` passing indefinitely for a user whose account no longer
/// exists, since it only re-checks the live `Membership` row, not the
/// account itself).
///
/// For every organization where `user_id` is currently the sole `Owner`
/// (checked by first looking for another live `Owner` in that same
/// organization — Codex review finding: an org that already has a second
/// Owner must not have anyone else promoted into a redundant `Owner` role),
/// promotes another existing member (preferring an `Admin`) to `Owner`
/// first, before removing `user_id`'s own membership — bypassing this would
/// silently defeat the same last-owner protection `remove_member`/
/// `change_role` already enforce for interactive requests: an organization
/// stripped of its only Owner by account deletion would still have Admins/
/// Members, but no one left able to ever grant a replacement Owner
/// (`create_invitation`/`change_role` both require an existing Owner to
/// grant the `Owner` role).
///
/// If no other member exists in that organization at all, there is no one
/// to promote — the organization is left with zero members, so its pending
/// invitations are revoked too (Codex review finding): left live, one could
/// later be accepted and repopulate a headless organization no one will
/// ever be able to manage or promote a new Owner in.
///
/// Runs across every organization in one transaction: not tenant-scoped, so
/// raw queries against `db` rather than the generated `tenant_scoped`
/// repository, which can only ever see the caller's single active tenant.
///
/// This opens and commits its own transaction, independent of whatever your
/// account-deletion handler does around it (Codex review finding): called
/// *before* your account `DELETE`, a subsequent failure of that `DELETE`
/// leaves a still-live account permanently stripped of its memberships;
/// called *after*, a failure partway through this call itself leaves a
/// deleted account's memberships behind — the exact stale-access gap this
/// function exists to close. If your account-deletion handler already runs
/// inside its own `db.tx(...)` (or you're willing to add one), call
/// [`remove_all_memberships_on_conn`] on that same `conn` instead, so
/// cleanup and the account `DELETE` commit or roll back together:
///
/// ```ignore
/// use autumn_web::reexports::scoped_futures::ScopedFutureExt;
///
/// db.tx(move |conn| {
/// async move {
/// teams::routes::organizations::remove_all_memberships_on_conn(conn, user_id).await?;
/// diesel::delete(users::table.find(user_id)).execute(conn).await?;
/// Ok::<_, AutumnError>(())
/// }
/// .scope_boxed()
/// })
/// .await?;
/// ```
///
/// ```ignore
/// teams::routes::organizations::remove_all_memberships(user_id, &mut db).await?;
/// ```
pub async fn remove_all_memberships(user_id: i64, db: &mut Db) -> AutumnResult<()> {
db.tx(move |conn| remove_all_memberships_on_conn(conn, user_id).scope_boxed())
.await
}
/// The building block behind [`remove_all_memberships`]: the same cleanup,
/// run directly on an already-open `conn` instead of opening its own
/// transaction — see that function's doc comment for when to reach for this
/// instead, and `docs/generate-teams.md` for a full worked example.
pub async fn remove_all_memberships_on_conn(
conn: &mut autumn_web::db::PooledConnection,
user_id: i64,
) -> AutumnResult<()> {
// Deliberately NOT `for_update()` here: this is just a scan to discover
// which organizations this user needs handling in below, where the
// actual locks are taken (org row first, then this user's own
// membership row only after that). Locking these rows up front —
// before any org row — is what caused a real deadlock (Codex review
// finding): if two owners of the *same* organization delete their
// accounts concurrently, each would lock its own row here first, then
// each need the org lock *and* the other's row (via the other-owner
// check below) — a lock-order cycle Postgres has no choice but to
// break by aborting one deletion.
//
// Every organization this user is a member of at all — not just the
// ones they currently *own* — is scanned and processed one at a time
// below, each removed only after its own organization lock is held
// (Codex review finding): scanning only `role = 'owner'` here missed a
// real scenario — two users deleting their accounts concurrently,
// where one is merely an Admin in an organization solely owned by the
// other. That Admin's own scan would never even look at that
// organization (it doesn't own it *yet*), so nothing would stop this
// function's final step from unconditionally deleting the Admin's
// membership row there — including one a concurrent sibling
// transaction just promoted to Owner moments earlier — leaving the
// organization ownerless despite the successor logic below having
// already run for it.
// Ordered deterministically: if two users being deleted concurrently
// share two or more organizations, and each transaction locked them in
// a different order, one could lock org A and wait for org B while the
// other locks org B and waits for org A — a real deadlock Postgres can
// only resolve by aborting one deletion (Codex review finding). Every
// caller of this function scans in the same order, so whichever
// transaction gets to the first shared organization first always
// proceeds to the next one uncontested.
let member_tenant_ids: Vec<String> = memberships::table
.filter(memberships::user_id.eq(user_id))
.select(memberships::tenant_id)
.order(memberships::tenant_id.asc())
.load(conn)
.await?;
for tenant_id in &member_tenant_ids {
// Lock the organization row FIRST — before any membership row for
// this organization — as the shared serialization point with
// `accept_invitation` (see that function's own matching org-row
// lock). Without a lock shared across both functions, this
// successor lookup below could decide "no successor exists" a
// moment before a concurrent `accept_invitation` commits a
// brand-new membership for this same organization — this function
// would then delete the sole Owner anyway, leaving the newly
// joined member in an organization that can never regain an Owner
// (Codex review finding). Both functions lock organizations before
// memberships/invitations, in that same order, so whichever gets
// here first completes wholly before the other proceeds, instead
// of deadlocking.
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in membership")
})?;
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
// Re-fetch and lock this user's own membership row now that the
// organization lock is held — the unlocked scan above could be
// stale (e.g. a concurrent `change_role` may have already demoted
// this user in this organization, or — the scenario the widened
// scan above exists for — a concurrent sibling deletion may have
// just *promoted* this user to Owner here as its own successor).
let Some(own_membership) = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(tenant_id))
.filter(memberships::user_id.eq(user_id))
.select(Membership::as_select())
)
.first(conn)
.await
.optional()?
else {
continue;
};
// Only a *sole* Owner needs a successor — if this user isn't
// currently Owner here at all (the common case for a plain
// Admin/Member row), or another live Owner remains, ownership
// doesn't need transferring, and promoting anyone here would grant
// an unrequested second Owner (Codex review finding).
if own_membership.role == Role::Owner.as_str() {
let other_owner: Option<Membership> = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&own_membership.tenant_id))
.filter(memberships::user_id.ne(user_id))
.filter(memberships::role.eq(Role::Owner.as_str()))
.select(Membership::as_select())
)
.first(conn)
.await
.optional()?;
if other_owner.is_none() {
let mut successor: Option<Membership> = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&own_membership.tenant_id))
.filter(memberships::user_id.ne(user_id))
.filter(memberships::role.eq(Role::Admin.as_str()))
.select(Membership::as_select())
.order(memberships::id.asc())
)
.first(conn)
.await
.optional()?;
if successor.is_none() {
successor = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&own_membership.tenant_id))
.filter(memberships::user_id.ne(user_id))
.select(Membership::as_select())
.order(memberships::id.asc())
)
.first(conn)
.await
.optional()?;
}
if let Some(successor) = successor {
diesel::update(memberships::table.filter(memberships::id.eq(successor.id)))
.set(memberships::role.eq(Role::Owner.as_str()))
.execute(conn)
.await?;
} else {
// No other member exists at all: this organization is
// about to be left with zero memberships, permanently —
// revoke any pending invitations too, so a later accept
// can't repopulate a headless organization no one will
// ever be able to manage or promote a new Owner in
// (Codex review finding).
diesel::update(
invitations::table
.filter(invitations::tenant_id.eq(&own_membership.tenant_id))
.filter(invitations::status.eq("pending")),
)
.set(invitations::status.eq("revoked"))
.execute(conn)
.await?;
}
}
}
// Remove this user's own membership in *this* organization now,
// under its lock — rather than one blanket `DELETE ... WHERE
// user_id = user_id` after the loop, which would fall outside
// every organization's lock and could remove a row a concurrent
// sibling deletion just promoted to Owner moments after this loop
// last read it (the same finding the widened scan above closes).
diesel::delete(memberships::table.filter(memberships::id.eq(own_membership.id)))
.execute(conn)
.await?;
}
Ok(())
}
/// Create a new organization; the caller becomes its `Owner` and it becomes
/// the active organization (same rule as signup — issue #1261 AC3).
#[post("/organizations")]
pub async fn create_organization(
session: Session,
mut db: Db,
Form(form): Form<crate::teams::models::NewOrganizationForm>,
) -> AutumnResult<Response> {
let Some(user_id) = session.get("user_id").await.and_then(|s| s.parse().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 200 {
return Err(AutumnError::unprocessable_msg(
"Organization name must be between 1 and 200 characters",
));
}
// One transaction: without it, a failure between the org insert and the
// owner-membership insert would leave an orphaned `Organization` row no
// one is a member of.
let org: Organization = db
.tx(move |conn| {
async move {
let org: Organization = diesel::insert_into(organizations::table)
.values(&InsertOrganization { name })
.returning(Organization::as_returning())
.get_result(conn)
.await?;
diesel::insert_into(memberships::table)
.values(&InsertMembership {
tenant_id: org.id.to_string(),
user_id,
role: Role::Owner.as_str().to_owned(),
})
.execute(conn)
.await?;
Ok::<_, AutumnError>(org)
}
.scope_boxed()
})
.await?;
establish_org_session(&session, user_id, &org.id.to_string(), Role::Owner).await;
Ok(Redirect::to("/members").into_response())
}
/// Switch the active organization to one the caller already belongs to.
#[post("/organizations/{id}/switch")]
pub async fn switch_organization(
session: Session,
membership_repo: PgMembershipRepository,
Path(target_org_id): Path<i64>,
) -> AutumnResult<Response> {
let Some(user_id) = session.get("user_id").await.and_then(|s| s.parse().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
let target_tenant_id = target_org_id.to_string();
let memberships: Vec<Membership> = membership_repo
.across_tenants()
.find_by_user_id(user_id)
.await?;
let Some(membership) = memberships
.into_iter()
.find(|m| m.tenant_id == target_tenant_id)
else {
return Err(AutumnError::forbidden_msg(
"You are not a member of that organization",
));
};
let Some(role) = Role::parse(&membership.role) else {
return Err(AutumnError::internal_server_error_msg(
"Corrupt membership role",
));
};
establish_org_session(&session, user_id, &target_tenant_id, role).await;
Ok(Redirect::to("/members").into_response())
}
"#;
fn render_routes_invitations_rs() -> String {
ROUTES_INVITATIONS_RS.to_owned()
}
const ROUTES_INVITATIONS_RS: &str = r#"//! Email invitations: create, accept, revoke, resend (issue #1261 AC4-AC6).
//!
//! Accepting an invitation requires an authenticated session — unlike
//! `examples/teams` (which owns its whole app, `users` table included),
//! this module doesn't know your app's `User`/`users` shape (see
//! `crate::teams`'s composition-boundary note), so it cannot inline "create
//! an account and join" for a logged-out visitor. A logged-out visitor is
//! shown a message pointing at your app's `/login` and `/signup`; once
//! signed in they can revisit the same invitation link to accept.
//!
//! Accepting is idempotent (AC5): a second click never creates a second
//! `Membership` row or 500s. Expired/revoked/already-consumed tokens render
//! a clear error page (AC6), never a panic.
//!
//! The invitee-facing routes live under `/invite/...` — a *different* path
//! prefix than the admin-only `/invitations` routes below (`create`/`revoke`/
//! `resend`) — deliberately. `[tenancy] public_paths` matches by path
//! *prefix* (see `docs/generate-teams.md`), so an anonymous visitor following
//! an accept link needs `/invite` exempted from the tenancy gate, but the
//! admin routes must stay gated (they need the ambient tenant the gate
//! establishes). A single shared `/invitations` prefix would have exempted
//! the admin routes too — this split keeps the public surface exactly as
//! small as it needs to be instead of relying on a fragile allowlist.
use autumn_web::auth::{generate_raw_token, hash_api_token};
use autumn_web::prelude::*;
use autumn_web::reexports::axum::response::Response;
use autumn_web::reexports::scoped_futures::ScopedFutureExt;
use autumn_web::security::CsrfToken;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::teams::mailers::invitation_mailer::InvitationMailer;
use crate::teams::models::{InviteForm, Invitation, Membership, Organization};
use crate::teams::repositories::{
InvitationRepository, OrganizationRepository, PgInvitationRepository, PgMembershipRepository,
PgOrganizationRepository,
};
use crate::teams::role::{Role, establish_org_session, require_role};
use crate::teams::schema::{invitations, memberships, organizations};
use super::{csrf_value, minimal_page};
const INVITATION_TTL_DAYS: i64 = 7;
// Raw-insert struct for `resend_invitation`'s hand-rolled `db.tx`
// transaction (revoke old + create new in one atomic step) — the generated
// `tenant_scoped` repository's CRUD methods each acquire their own pooled
// connection, which can't share an outer transaction's connection. See also
// the `InsertMembership` struct inside `accept_invitation`'s own `db.tx`.
#[derive(diesel::Insertable)]
#[diesel(table_name = invitations)]
struct InsertInvitation {
tenant_id: String,
email: String,
role: String,
token_hash: String,
status: String,
invited_by_user_id: i64,
expires_at: chrono::NaiveDateTime,
}
// A minimal, read-only view onto your app's own `users` table — just enough
// to verify an accept-invitation caller's email address (see
// `accept_invitation` below). This module has no `User` model to import (see
// `crate::teams`'s module doc), so it leans on the same i64-primary-key,
// `users`-table-name convention it already assumes for
// `Membership::user_id`/`Invitation::invited_by_user_id`. If your app's
// `users` table's primary key or email column differs from this, adjust the
// two lines below to match.
//
// The table NAME itself is a placeholder, not introspected from your
// project: `autumn generate auth` accepts a custom resource name (e.g.
// `autumn generate auth Account` creates a table named `accounts`, not
// `users`), and `teams` has no way to know which name you used when you ran
// it — a different generator invocation entirely, with no shared state.
// Rename `users` below to your actual auth table if it isn't the default,
// or invitation acceptance will fail at runtime with a missing-relation
// database error (Codex review finding).
mod caller_email_lookup {
diesel::table! {
users (id) {
id -> BigInt,
email -> Text,
}
}
}
// ── Create ───────────────────────────────────────────────────────────────
/// Absolute base URL for links embedded in emails, matching the convention
/// `autumn generate auth`'s scaffolded email flows use.
fn app_base_url() -> String {
std::env::var("APP_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_owned())
}
/// Send an invitation into the *active* organization. Gated `Admin` or
/// higher (issue #1261 AC7); inviting someone as `Owner` itself requires the
/// caller to already be an `Owner` — otherwise an Admin could mint a fresh
/// Owner account of their own choosing.
#[post("/invitations")]
pub async fn create_invitation(
session: Session,
Tenant(tenant_id): Tenant,
mut db: Db,
org_repo: PgOrganizationRepository,
membership_repo: PgMembershipRepository,
mailer: Mailer,
Form(form): Form<InviteForm>,
) -> AutumnResult<Response> {
let caller_role = require_role(&session, &membership_repo, Role::Admin).await?;
let Some(inviter_id) = session.get("user_id").await.and_then(|s| s.parse().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
let email = form.email.trim().to_lowercase();
if !email.contains('@') || email.len() > 254 {
return Err(AutumnError::unprocessable_msg("Enter a valid email address"));
}
let Some(role) = Role::parse(&form.role) else {
return Err(AutumnError::unprocessable_msg("Unknown role"));
};
if role == Role::Owner && caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can invite someone as owner",
));
}
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in session")
})?;
let Some(organization) = org_repo.find_by_id(org_id).await? else {
return Err(AutumnError::not_found_msg("Organization not found"));
};
let raw_token = generate_raw_token();
let token_hash = hash_api_token(&raw_token);
let insert_email = email.clone();
let insert_role = role.as_str().to_owned();
db.tx(move |conn| {
async move {
// Lock the organization row FIRST — the same shared
// serialization point `accept_invitation`/
// `remove_all_memberships_on_conn` use — before issuing a new
// pending invitation. Without it, an in-flight account
// deletion's cleanup could decide "no successor exists",
// revoke every pending invitation, and delete the
// organization's last Owner, all while this uncoordinated
// transaction inserts a fresh pending invitation the cleanup
// never saw — accepting that token later repopulates the
// organization without an Owner, recreating the exact
// invariant cleanup exists to prevent (Codex review finding).
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
// Revalidate the inviter's own membership now that the
// organization lock is held — the pre-transaction `require_role`
// result could be stale: another request (a demotion, a
// removal, or this same user's account being deleted) may have
// committed while this request waited for the lock above
// (Codex review finding).
let inviter_membership: Option<Membership> = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&tenant_id))
.filter(memberships::user_id.eq(inviter_id))
.select(Membership::as_select())
)
.first(conn)
.await
.optional()?;
let Some(inviter_membership) = inviter_membership else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(current_caller_role) = Role::parse(&inviter_membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if !current_caller_role.at_least(Role::Admin) {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
}
if role == Role::Owner && current_caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can invite someone as owner",
));
}
// Reject rather than silently revoke-and-replace a still-pending
// invitation to this email — now that the organization lock
// above serializes concurrent `create_invitation` calls for
// this organization, a second request for an email that's
// already pending is a genuine conflict the caller should see,
// not a race to resolve by rewriting history underneath them.
// Revoking-and-replacing here also sent two invite emails out
// of release-lock order, so a reordered delivery could leave
// the invitee's newest message holding an already-revoked
// token (Codex review finding).
let existing_pending: Option<Invitation> = invitations::table
.filter(invitations::tenant_id.eq(&tenant_id))
.filter(invitations::email.eq(&insert_email))
.filter(invitations::status.eq("pending"))
.select(Invitation::as_select())
.first(conn)
.await
.optional()?;
if existing_pending.is_some() {
return Err(AutumnError::conflict_msg(
"An invitation to this email is already pending for this organization",
));
}
diesel::insert_into(invitations::table)
.values(&InsertInvitation {
tenant_id,
email: insert_email,
role: insert_role,
token_hash,
status: "pending".to_owned(),
invited_by_user_id: inviter_id,
expires_at: chrono::Utc::now().naive_utc()
+ chrono::Duration::days(INVITATION_TTL_DAYS),
})
.execute(conn)
.await?;
Ok::<_, AutumnError>(())
}
.scope_boxed()
})
.await
.map_err(|err| {
// The organization lock plus the explicit pending-email check above
// already serialize concurrent `create_invitation` calls for this
// organization, so this should be unreachable in practice.
// `idx_invitations_pending_email` (a partial unique index on
// `(tenant_id, email) WHERE status = 'pending'`, see render_migration_up)
// stays as defense in depth: any INSERT that still slips past the
// check above fails closed instead of leaving two live pending
// tokens for the same invitee.
if autumn_web::error::unique_violation_field(
&err,
&[(
"idx_invitations_pending_email",
"email",
"An invitation to this email is already pending",
)],
)
.is_some()
{
AutumnError::conflict_msg(
"An invitation to this email is already pending for this organization",
)
} else {
err
}
})?;
let accept_url = format!("{}/invite/{raw_token}", app_base_url());
// Sent synchronously (not `deliver_later_invite`): the success metric is
// "an invite email is delivered to the dev mailbox within the same
// request cycle" (issue #1261), which a fire-and-forget background send
// can't guarantee.
InvitationMailer
.send_invite(
&mailer,
email,
organization.name,
role.as_str().to_owned(),
accept_url,
)
.await?;
Ok(Redirect::to("/members").into_response())
}
// ── Accept (show) ───────────────────────────────────────────────────────
/// `Invitation::tenant_id` is a `String` (the `tenant_scoped` macro's
/// convention — see `models.rs`); parse it back to the `Organization.id` it
/// names when a lookup on the `Organization` row itself is needed.
fn parse_tenant_id(tenant_id: &str) -> AutumnResult<i64> {
tenant_id
.parse()
.map_err(|_| AutumnError::internal_server_error_msg("Corrupt tenant id"))
}
fn invitation_error_page(message: &str) -> Markup {
minimal_page(
"Invitation",
html! {
p { (message) }
},
)
}
async fn load_pending_invitation(
invitation_repo: &PgInvitationRepository,
raw_token: &str,
) -> AutumnResult<Option<Invitation>> {
let token_hash = hash_api_token(raw_token);
let mut matches = invitation_repo
.across_tenants()
.find_by_token_hash(token_hash)
.await?;
Ok(matches.pop())
}
fn invitation_status_error(invitation: &Invitation) -> Option<&'static str> {
match invitation.status.as_str() {
"accepted" => Some("This invitation has already been used."),
"revoked" => Some("This invitation was revoked."),
_ if invitation.expires_at <= chrono::Utc::now().naive_utc() => {
Some("This invitation has expired.")
}
_ => None,
}
}
#[get("/invite/{token}")]
pub async fn show_invitation(
session: Session,
invitation_repo: PgInvitationRepository,
org_repo: PgOrganizationRepository,
csrf: Option<CsrfToken>,
Path(raw_token): Path<String>,
) -> AutumnResult<Response> {
let Some(invitation) = load_pending_invitation(&invitation_repo, &raw_token).await? else {
return Ok((
StatusCode::NOT_FOUND,
invitation_error_page("This invitation link is invalid."),
)
.into_response());
};
if let Some(message) = invitation_status_error(&invitation) {
return Ok((StatusCode::GONE, invitation_error_page(message)).into_response());
}
let Some(organization) = org_repo.find_by_id(parse_tenant_id(&invitation.tenant_id)?).await?
else {
return Ok((
StatusCode::NOT_FOUND,
invitation_error_page("This invitation's organization no longer exists."),
)
.into_response());
};
let signed_in = session.get("user_id").await.is_some();
let page = if signed_in {
minimal_page(
"Accept invitation",
html! {
h1 { "Join " (organization.name) }
p { "You've been invited as " (invitation.role) "." }
form action={"/invite/" (raw_token) "/accept"} method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
button type="submit" { "Accept invitation" }
}
},
)
} else {
minimal_page(
"Accept invitation",
html! {
h1 { "Join " (organization.name) }
p {
"You've been invited as " (invitation.role)
". Log in or sign up with " strong { (invitation.email) }
", then come back to this link to accept."
}
p {
a href="/login" { "Log in" } " · " a href="/signup" { "Sign up" }
}
},
)
};
Ok(page.into_response())
}
// ── Accept (confirm) ────────────────────────────────────────────────────
/// Accept a pending invitation into the caller's currently-active session.
/// Requires an authenticated session — see this module's doc comment.
#[post("/invite/{token}/accept")]
pub async fn accept_invitation(
session: Session,
mut db: Db,
invitation_repo: PgInvitationRepository,
Path(raw_token): Path<String>,
) -> AutumnResult<Response> {
let Some(user_id) = session.get("user_id").await.and_then(|s| s.parse().ok()) else {
return Err(AutumnError::unauthorized_msg(
"log in or sign up first, then revisit this invitation link",
));
};
let Some(invitation) = load_pending_invitation(&invitation_repo, &raw_token).await? else {
return Ok((
StatusCode::NOT_FOUND,
invitation_error_page("This invitation link is invalid."),
)
.into_response());
};
// Security-critical: an authenticated caller may only redeem an invite
// addressed to their own email. Without this check, anyone who obtains a
// token not meant for them (a forwarded link, a mail-scanner fetch, a
// shared clipboard) while already signed in as a different account could
// join — at whatever role the invite grants — an organization they were
// never invited to.
use caller_email_lookup::users as caller_users;
let caller_email: String = caller_users::table
.filter(caller_users::id.eq(user_id))
.select(caller_users::email)
.first(&mut *db)
.await?;
if caller_email.trim().to_lowercase() != invitation.email.trim().to_lowercase() {
return Err(AutumnError::forbidden_msg(
"This invitation was sent to a different email address",
));
}
let invitation_id = invitation.id;
let tenant_id = invitation.tenant_id.clone();
let invitation_email = invitation.email.clone();
let role = invitation.role.clone();
// Raw queries (not the generated `tenant_scoped` repository) inside one
// transaction: the repository's CRUD methods each acquire their own
// pooled connection, which can't share this transaction's connection —
// and atomicity across the row-lock + insert + status update is exactly
// what makes double-click acceptance idempotent instead of racy.
let membership: Membership = db
.tx(move |conn| {
async move {
// Lock and revalidate the caller's own account row FIRST —
// before the organization lock below, and before anything
// else in this transaction. The email-match check earlier
// in this handler ran on a standalone, unlocked connection
// before this transaction even started, checking only that
// the account existed with a matching email at that moment
// — if the account is deleted, or its email is changed via
// your app's own email-change flow, in the gap between
// that check and this transaction actually acquiring this
// lock, nothing else would notice: `Membership.user_id` has
// no FK to your `users` table, so the insert below would
// otherwise happily create a membership — and this handler
// would establish a session — for an account that no
// longer exists, or no longer owns the invited address
// (Codex review finding — re-compares the live email, not
// just existence).
//
// This must be the FIRST lock taken, before organizations,
// and your own account-deletion handler must lock/verify
// its `users` row before calling
// `remove_all_memberships_on_conn` too (see
// `docs/generate-teams.md`'s worked example) — otherwise
// account deletion could finish its membership cleanup,
// which never touches this not-yet-existing membership,
// before this transaction gets a chance to insert it,
// leaving a live membership for a since-deleted account
// cleanup never saw. Locking the account row first, in the
// same order on both sides, closes that gap: whichever
// transaction reaches it first now runs to completion
// before the other proceeds at all.
let live_caller_email: Option<String> = ::autumn_web::maybe_for_update!(
caller_users::table
.filter(caller_users::id.eq(user_id))
.select(caller_users::email)
)
.first(conn)
.await
.optional()?;
let Some(live_caller_email) = live_caller_email else {
return Err(AutumnError::unauthorized_msg(
"log in or sign up first, then revisit this invitation link",
));
};
if live_caller_email.trim().to_lowercase() != invitation_email.trim().to_lowercase()
{
return Err(AutumnError::forbidden_msg(
"This invitation was sent to a different email address",
));
}
// Lock the organization row — as the shared serialization
// point with `remove_all_memberships_on_conn` (see its own
// matching org-row lock, and the account-row-first note
// above). Without a lock shared across both functions, an
// account deletion concurrently cleaning up this same
// organization could decide "no successor member exists" a
// moment before this accept commits a brand-new
// membership, then delete the sole Owner anyway — leaving
// this invitee joined to an organization that can never
// regain an Owner (Codex review finding). Both functions
// lock organizations before memberships/invitations, in
// that same order, so whichever gets here first completes
// wholly before the other proceeds, instead of
// deadlocking.
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in invitation")
})?;
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
// Lock the invitation row so two concurrent accepts serialize
// rather than race past the pending/expiry check together.
let invitation: Invitation = ::autumn_web::maybe_for_update!(
invitations::table.filter(invitations::id.eq(invitation_id))
)
.select(Invitation::as_select())
.first(conn)
.await?;
// Reject a revoked, or expired-while-still-pending, token
// up front — before the existing-membership shortcut below.
// This check must NOT run for an already-`accepted` token:
// that's the idempotent double-click case, whose
// `expires_at` (fixed at creation) may well have passed by
// now without that being a problem, since no new grant is
// happening. Checked before the `existing` lookup so a
// revoked/expired-pending invitation can never silently
// reuse someone's unrelated existing membership to switch
// their active organization and return success (Codex
// review finding).
if invitation.status == "revoked"
|| (invitation.status == "pending"
&& invitation.expires_at <= chrono::Utc::now().naive_utc())
{
return Err(AutumnError::gone_msg(
"This invitation is no longer available",
));
}
let existing: Option<Membership> = memberships::table
.filter(memberships::tenant_id.eq(&tenant_id))
.filter(memberships::user_id.eq(user_id))
.select(Membership::as_select())
.first(conn)
.await
.optional()?;
if let Some(existing) = existing {
// The user already belongs to this organization — either
// this exact token was already redeemed (idempotent
// double-click: `invitation.status` is already
// "accepted"), or they joined some other way while this
// invitation sat pending (and, per the check above, not
// expired). Either way there's no new membership to
// insert, but a still-pending invitation must be
// consumed here too — otherwise its token stays valid
// indefinitely and, if this membership is later removed,
// redeeming the lingering token again would silently
// regrant it.
if invitation.status == "pending" {
diesel::update(invitations::table.filter(invitations::id.eq(invitation_id)))
.set(invitations::status.eq("accepted"))
.execute(conn)
.await?;
}
return Ok::<_, AutumnError>(existing);
}
if invitation.status != "pending" {
// Already "accepted" (checked for "revoked"/expired
// above) but no existing membership: the token was
// consumed by an earlier accept whose membership has
// since been removed. Nothing left to (re)grant.
return Err(AutumnError::gone_msg(
"This invitation is no longer available",
));
}
#[derive(diesel::Insertable)]
#[diesel(table_name = memberships)]
struct InsertMembership {
tenant_id: String,
user_id: i64,
role: String,
}
let membership: Membership = diesel::insert_into(memberships::table)
.values(&InsertMembership {
tenant_id: tenant_id.clone(),
user_id,
role: role.clone(),
})
.returning(Membership::as_returning())
.get_result(conn)
.await?;
diesel::update(invitations::table.filter(invitations::id.eq(invitation_id)))
.set(invitations::status.eq("accepted"))
.execute(conn)
.await?;
Ok(membership)
}
.scope_boxed()
})
.await?;
let Some(resolved_role) = Role::parse(&membership.role) else {
return Err(AutumnError::internal_server_error_msg(
"Corrupt membership role",
));
};
establish_org_session(&session, user_id, &membership.tenant_id, resolved_role).await;
Ok(Redirect::to("/members").into_response())
}
// ── Revoke / resend ──────────────────────────────────────────────────────
/// Revoke a pending invitation. Gated `Admin` or higher.
#[post("/invitations/{id}/revoke")]
pub async fn revoke_invitation(
session: Session,
mut db: Db,
invitation_repo: PgInvitationRepository,
membership_repo: PgMembershipRepository,
Path(invitation_id): Path<i64>,
) -> AutumnResult<Response> {
require_role(&session, &membership_repo, Role::Admin).await?;
let Some(caller_id) = session.get("user_id").await.and_then(|s| s.parse::<i64>().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
// `find_by_id` is tenant-scoped, so this also proves `invitation_id`
// belongs to the caller's active organization before anything is written.
let Some(old) = invitation_repo.find_by_id(invitation_id).await? else {
return Err(AutumnError::not_found_msg("Invitation not found"));
};
let tenant_id = old.tenant_id.clone();
db.tx(move |conn| {
async move {
// Revalidate the caller's own membership inside the
// transaction, instead of trusting the pre-transaction
// `require_role` result — see `create_invitation`'s identical
// fix for the full rationale (Codex review finding).
let caller_membership: Option<Membership> = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&tenant_id))
.filter(memberships::user_id.eq(caller_id))
.select(Membership::as_select())
)
.first(conn)
.await
.optional()?;
let Some(caller_membership) = caller_membership else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(current_caller_role) = Role::parse(&caller_membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if !current_caller_role.at_least(Role::Admin) {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
}
// Lock and re-check status inside the transaction: a stale
// revoke form submitted after the invitee already accepted — or
// an accept that commits while this transaction waits for the
// row lock above — must not stomp the terminal `accepted` row
// back to `revoked`. That would leave the granted membership in
// place while a later revisit of the (already-consumed) link
// reports "revoked" instead of following the accepted-token
// idempotency path, corrupting the invitation's audit trail
// (Codex review finding).
let current: Invitation = ::autumn_web::maybe_for_update!(
invitations::table.filter(invitations::id.eq(invitation_id))
)
.select(Invitation::as_select())
.first(conn)
.await?;
if current.status != "pending" {
return Err(AutumnError::conflict_msg(
"This invitation is no longer pending",
));
}
diesel::update(invitations::table.filter(invitations::id.eq(invitation_id)))
.set(invitations::status.eq("revoked"))
.execute(conn)
.await?;
Ok::<_, AutumnError>(())
}
.scope_boxed()
})
.await?;
Ok(Redirect::to("/members").into_response())
}
/// Re-send a pending invitation: revoke the old token and mint a fresh one
/// with a new expiry (issue #1261 AC6 — "new token, old token invalidated").
/// The revoke-then-create pair runs in one transaction so a failure between
/// the two steps can never leave the organization with neither a valid old
/// nor a new invitation.
#[post("/invitations/{id}/resend")]
pub async fn resend_invitation(
session: Session,
mut db: Db,
org_repo: PgOrganizationRepository,
invitation_repo: PgInvitationRepository,
membership_repo: PgMembershipRepository,
mailer: Mailer,
Path(invitation_id): Path<i64>,
) -> AutumnResult<Response> {
require_role(&session, &membership_repo, Role::Admin).await?;
let Some(inviter_id) = session.get("user_id").await.and_then(|s| s.parse().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
// `find_by_id` is tenant-scoped, so this also proves `invitation_id`
// belongs to the caller's active organization before anything is written.
let Some(old) = invitation_repo.find_by_id(invitation_id).await? else {
return Err(AutumnError::not_found_msg("Invitation not found"));
};
let tenant_id = old.tenant_id.clone();
let email = old.email.clone();
let role = old.role.clone();
let raw_token = generate_raw_token();
let token_hash = hash_api_token(&raw_token);
let new: Invitation = db
.tx(move |conn| {
async move {
// Lock the organization row FIRST — the same shared
// serialization point `create_invitation`/
// `accept_invitation`/`remove_all_memberships_on_conn` use
// — before issuing the replacement invitation, for the same
// reason `create_invitation` does (see its own comment):
// this is another path that issues a fresh pending
// invitation and must not slip past an in-flight account
// deletion's cleanup (Codex review finding).
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in invitation")
})?;
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
// Revalidate the caller's own membership now that the
// organization lock is held — see `create_invitation`'s
// identical fix for the full rationale (Codex review
// finding).
let inviter_membership: Option<Membership> = ::autumn_web::maybe_for_update!(
memberships::table
.filter(memberships::tenant_id.eq(&tenant_id))
.filter(memberships::user_id.eq(inviter_id))
.select(Membership::as_select())
)
.first(conn)
.await
.optional()?;
let Some(inviter_membership) = inviter_membership else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(current_caller_role) = Role::parse(&inviter_membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if !current_caller_role.at_least(Role::Admin) {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
}
// Lock and re-check status inside the transaction: a
// double-clicked resend (or two concurrent ones) must not
// both pass a stale "it's pending" read from before the
// transaction and each mint their own replacement, which
// would leave two live pending invitations instead of the
// single refreshed one this endpoint promises.
let current: Invitation = ::autumn_web::maybe_for_update!(
invitations::table.filter(invitations::id.eq(invitation_id))
)
.select(Invitation::as_select())
.first(conn)
.await?;
if current.status != "pending" {
return Err(AutumnError::conflict_msg(
"This invitation is no longer pending",
));
}
// Same rule `create_invitation` enforces for a fresh Owner
// invitation: resending must not become a back door for an
// Admin to indefinitely renew an Owner grant they could
// never have created themselves (Codex review finding).
if current.role == Role::Owner.as_str() && current_caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can resend an invitation to join as owner",
));
}
diesel::update(invitations::table.filter(invitations::id.eq(invitation_id)))
.set(invitations::status.eq("revoked"))
.execute(conn)
.await?;
let new: Invitation = diesel::insert_into(invitations::table)
.values(&InsertInvitation {
tenant_id,
email,
role,
token_hash,
status: "pending".to_owned(),
invited_by_user_id: inviter_id,
expires_at: chrono::Utc::now().naive_utc()
+ chrono::Duration::days(INVITATION_TTL_DAYS),
})
.returning(Invitation::as_returning())
.get_result(conn)
.await?;
Ok::<_, AutumnError>(new)
}
.scope_boxed()
})
.await?;
let Some(organization) = org_repo
.find_by_id(parse_tenant_id(&new.tenant_id)?)
.await?
else {
return Err(AutumnError::not_found_msg("Organization not found"));
};
let accept_url = format!("{}/invite/{raw_token}", app_base_url());
InvitationMailer
.send_invite(&mailer, new.email, organization.name, new.role, accept_url)
.await?;
Ok(Redirect::to("/members").into_response())
}
"#;
fn render_routes_members_rs() -> String {
ROUTES_MEMBERS_RS.to_owned()
}
const ROUTES_MEMBERS_RS: &str = r#"//! Member-management surface: list members + pending invitations, change
//! role, remove member (issue #1261 AC7).
//!
//! Every mutating action is gated `require_role(&session, &membership_repo, Role::Admin)`,
//! but an `Admin` may only change or remove `Member`/`Admin` memberships —
//! never an `Owner`'s, no matter how many owners the organization has
//! (`Admin`+ can still invite/remove/change non-Owner roles regardless).
//! Without that, `Owner > Admin` wouldn't hold: an Admin could demote or
//! eject any owner as long as a second one existed. Only an `Owner` may
//! touch another `Owner`'s membership, and even an `Owner` can neither
//! demote nor remove the organization's *last* `Owner` — otherwise no one
//! could ever grant `Owner` again. Granting the `Owner` role itself
//! additionally requires the *caller* to already be an `Owner` — an `Admin`
//! gate alone would let any Admin promote themselves (or an ally) to
//! `Owner` and from there demote/remove the organization's real owners.
//!
//! Member rows are labeled by `user_id` only — this module doesn't know your
//! app's `User`/`users` shape (see `crate::teams`'s composition-boundary
//! note), so it can't join in an email/display name the way a
//! self-contained app can. Look `user_id` up against your own users table if
//! you want to show something friendlier.
use autumn_web::prelude::*;
use autumn_web::reexports::axum::response::Response;
use autumn_web::reexports::scoped_futures::ScopedFutureExt;
use autumn_web::security::CsrfToken;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::teams::models::{ChangeRoleForm, Invitation, Membership, Organization};
use crate::teams::repositories::{
InvitationRepository, MembershipRepository, PgInvitationRepository, PgMembershipRepository,
};
use crate::teams::role::{Role, require_role};
use crate::teams::schema::{memberships, organizations};
use super::{csrf_value, minimal_page};
/// Count how many `owner` members remain in the active organization. Used to
/// block demoting/removing the last one.
fn owner_count(memberships: &[Membership]) -> usize {
memberships
.iter()
.filter(|m| m.role == Role::Owner.as_str())
.count()
}
#[get("/members")]
pub async fn list_members(
session: Session,
membership_repo: PgMembershipRepository,
invitation_repo: PgInvitationRepository,
csrf: Option<CsrfToken>,
) -> AutumnResult<Response> {
// Any member (not just Admin+) can view the roster; only Admin+ sees the
// management controls below.
let caller_role = require_role(&session, &membership_repo, Role::Member).await?;
let memberships = membership_repo.find_all().await?;
let pending_invitations: Vec<Invitation> = invitation_repo
.find_all()
.await?
.into_iter()
.filter(|i| i.status == "pending")
.collect();
let can_manage = caller_role.at_least(Role::Admin);
let owners = owner_count(&memberships);
let page = minimal_page(
"Members",
html! {
h1 { "Members" }
@if can_manage {
form action="/invitations" method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
input name="email" type="email" required placeholder="teammate@example.com"
aria-label="Email to invite";
select name="role" aria-label="Role" {
option value="member" { "Member" }
option value="admin" { "Admin" }
option value="owner" { "Owner" }
}
button type="submit" { "Invite" }
}
}
ul {
@for membership in &memberships {
li {
span { "User " (membership.user_id) }
span { " (" (membership.role) ")" }
@if can_manage {
// Locked when the target is an Owner and either
// the caller isn't one too (only an Owner may
// touch another Owner's membership) or it's the
// organization's last Owner (never demotable/
// removable by anyone) — mirrors the server-side
// checks in `change_role`/`remove_member`.
@let locked = membership.role == Role::Owner.as_str()
&& (caller_role != Role::Owner || owners <= 1);
form action={"/members/" (membership.id) "/role"} method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
select name="role" aria-label="Change role" disabled[locked] {
option value="member" selected[membership.role == "member"] { "Member" }
option value="admin" selected[membership.role == "admin"] { "Admin" }
option value="owner" selected[membership.role == "owner"] { "Owner" }
}
button type="submit" disabled[locked] { "Update" }
}
form action={"/members/" (membership.id) "/remove"} method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
button type="submit" disabled[locked] { "Remove" }
}
}
}
}
}
@if can_manage {
h2 { "Pending invitations" }
ul {
@for invitation in &pending_invitations {
li {
span { (invitation.email) }
span { " (" (invitation.role) ")" }
form action={"/invitations/" (invitation.id) "/resend"} method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
button type="submit" { "Resend" }
}
form action={"/invitations/" (invitation.id) "/revoke"} method="post" {
input type="hidden" name="_csrf" value=(csrf_value(&csrf));
button type="submit" { "Revoke" }
}
}
}
@if pending_invitations.is_empty() {
li { "No pending invitations." }
}
}
}
},
);
Ok(page.into_response())
}
/// Change a member's role within the active organization. Gated `Admin` or
/// higher — but an `Admin` may only change a `Member`'s or `Admin`'s role,
/// never an existing `Owner`'s (granting `Owner`, same as touching one,
/// requires the caller to already be an `Owner`); refuses to demote the
/// last `Owner`.
#[post("/members/{id}/role")]
pub async fn change_role(
session: Session,
mut db: Db,
Tenant(tenant_id): Tenant,
membership_repo: PgMembershipRepository,
Path(membership_id): Path<i64>,
Form(form): Form<ChangeRoleForm>,
) -> AutumnResult<Response> {
require_role(&session, &membership_repo, Role::Admin).await?;
let Some(caller_id) = session.get("user_id").await.and_then(|s| s.parse::<i64>().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
let Some(new_role) = Role::parse(&form.role) else {
return Err(AutumnError::unprocessable_msg("Unknown role"));
};
// Raw query (not the `tenant_scoped` repository's plain `find_all()`)
// inside one locked transaction: reading the owner count and mutating
// the target must be atomic, or two concurrent requests each demoting/
// removing a *different* one of exactly two remaining owners could both
// read the same two-owner snapshot, both pass the last-owner check
// below, and leave the organization with none (Codex review finding —
// mirrors `examples/teams/src/routes/members.rs`'s identical fix).
db.tx(move |conn| {
async move {
// Lock the organization row FIRST — before any membership row
// below — matching `remove_all_memberships_on_conn`'s lock
// order. Without it, an in-flight account-deletion cleanup
// (which locks the organization, then the departing owner's
// own row, then a successor candidate's row, in that order)
// could deadlock against this transaction's own unordered
// `memberships` load: if this load happens to lock a
// successor candidate's row before the owner's row cleanup
// already holds, each transaction ends up waiting on a row
// the other holds, and Postgres aborts one with no retry
// (Codex review finding).
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in session")
})?;
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
let memberships: Vec<Membership> = ::autumn_web::maybe_for_update!(
memberships::table.filter(memberships::tenant_id.eq(&tenant_id))
)
.select(Membership::as_select())
.load(conn)
.await?;
// Revalidate the caller's own role from this same locked
// snapshot instead of the `require_role` result computed
// before the transaction — another request could have
// demoted or removed the caller while this one waited to
// acquire the lock above, and the pre-lock `caller_role`
// would otherwise still be trusted (Codex review finding).
let Some(caller_membership) = memberships.iter().find(|m| m.user_id == caller_id)
else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(caller_role) = Role::parse(&caller_membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if !caller_role.at_least(Role::Admin) {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
}
if new_role == Role::Owner && caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can grant the owner role",
));
}
let Some(target) = memberships.iter().find(|m| m.id == membership_id) else {
return Err(AutumnError::not_found_msg("Member not found"));
};
// The `Owner > Admin` hierarchy must hold regardless of how many
// owners exist: an Admin changing an Owner's role — even with
// other owners still in place — is a demotion of a higher role
// by a lower one.
if target.role == Role::Owner.as_str() && caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can change another owner's role",
));
}
if target.role == Role::Owner.as_str()
&& new_role != Role::Owner
&& owner_count(&memberships) <= 1
{
return Err(AutumnError::conflict_msg(
"Cannot demote the last owner of an organization",
));
}
diesel::update(memberships::table.filter(memberships::id.eq(membership_id)))
.set(memberships::role.eq(new_role.as_str()))
.execute(conn)
.await?;
Ok::<_, AutumnError>(())
}
.scope_boxed()
})
.await?;
Ok(Redirect::to("/members").into_response())
}
/// Remove a member from the active organization. Gated `Admin` or higher —
/// but an `Admin` may never remove an `Owner` (regardless of how many
/// owners exist); refuses to remove the last `Owner` even for a caller who
/// is themselves an `Owner`.
#[post("/members/{id}/remove")]
pub async fn remove_member(
session: Session,
mut db: Db,
Tenant(tenant_id): Tenant,
membership_repo: PgMembershipRepository,
Path(membership_id): Path<i64>,
) -> AutumnResult<Response> {
require_role(&session, &membership_repo, Role::Admin).await?;
let Some(caller_id) = session.get("user_id").await.and_then(|s| s.parse::<i64>().ok()) else {
return Err(AutumnError::unauthorized_msg("authentication required"));
};
// Same race as `change_role` (see its comment): the owner-count check
// and the removal must run inside one locked transaction, or two
// concurrent requests removing two different owners could both pass
// the last-owner check and leave the organization with none (Codex
// review finding).
db.tx(move |conn| {
async move {
// Lock the organization row FIRST — before any membership row
// below — matching `remove_all_memberships_on_conn`'s and
// `change_role`'s lock order (see `change_role`'s comment for
// the full deadlock rationale; Codex review finding).
let org_id: i64 = tenant_id.parse().map_err(|_| {
AutumnError::internal_server_error_msg("Corrupt organization id in session")
})?;
::autumn_web::maybe_for_update!(organizations::table.find(org_id))
.select(Organization::as_select())
.first(conn)
.await
.optional()?;
let memberships: Vec<Membership> = ::autumn_web::maybe_for_update!(
memberships::table.filter(memberships::tenant_id.eq(&tenant_id))
)
.select(Membership::as_select())
.load(conn)
.await?;
// Revalidate the caller's own role from this same locked
// snapshot instead of the `require_role` result computed
// before the transaction (Codex review finding — see
// `change_role`'s identical fix for the full rationale).
let Some(caller_membership) = memberships.iter().find(|m| m.user_id == caller_id)
else {
return Err(AutumnError::unauthorized_msg("no active organization"));
};
let Some(caller_role) = Role::parse(&caller_membership.role) else {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
};
if !caller_role.at_least(Role::Admin) {
return Err(AutumnError::forbidden_msg("insufficient permissions"));
}
let Some(target) = memberships.iter().find(|m| m.id == membership_id) else {
return Err(AutumnError::not_found_msg("Member not found"));
};
if target.role == Role::Owner.as_str() && caller_role != Role::Owner {
return Err(AutumnError::forbidden_msg(
"Only an owner can remove another owner",
));
}
if target.role == Role::Owner.as_str() && owner_count(&memberships) <= 1 {
return Err(AutumnError::conflict_msg(
"Cannot remove the last owner of an organization",
));
}
diesel::delete(memberships::table.filter(memberships::id.eq(membership_id)))
.execute(conn)
.await?;
Ok::<_, AutumnError>(())
}
.scope_boxed()
})
.await?;
Ok(Redirect::to("/members").into_response())
}
"#;
#[derive(Clone, Copy)]
struct TeamsDdl {
pk: &'static str,
int: &'static str,
ts: &'static str,
now: &'static str,
}
impl TeamsDdl {
const fn for_backend(backend: DatabaseBackend) -> Self {
match backend {
DatabaseBackend::Postgres => Self {
pk: "BIGSERIAL PRIMARY KEY",
int: "BIGINT",
ts: "TIMESTAMP",
now: "NOW()",
},
DatabaseBackend::Sqlite => Self {
pk: "INTEGER PRIMARY KEY AUTOINCREMENT",
int: "INTEGER",
ts: "TEXT",
now: "CURRENT_TIMESTAMP",
},
}
}
}
fn render_migration_up(backend: DatabaseBackend) -> String {
let d = TeamsDdl::for_backend(backend);
format!(
r"-- Organizations, memberships, and invitations for team membership (issue
-- #1261). Deliberately does NOT create (or reference via REFERENCES) a
-- `users` table — your app already has its own from `autumn generate auth`;
-- `memberships.user_id` / `invitations.invited_by_user_id` are bare integer
-- columns with no FK, so this migration has no ordering dependency on when
-- your users table was created.
-- An organization (tenant). Creating one makes the creator an `owner`
-- member (see `src/teams/routes/organizations.rs`).
CREATE TABLE organizations (
id {pk},
name TEXT NOT NULL,
created_at {ts:<9} NOT NULL DEFAULT {now}
);
-- The user <-> organization join row, carrying the closed `role` enum
-- (`owner` | `admin` | `member`, see `src/teams/role.rs`). `tenant_scoped`
-- on `#[repository(Membership, ...)]` filters every read/write by the
-- active organization at the SQL level (issue #695's row-level
-- multi-tenancy seam, not a second isolation mechanism) — but the macro's
-- generated queries unconditionally filter on a column literally named
-- `tenant_id` (`TEXT`), so this holds the organization's id in its string
-- form rather than a typed FK; application code parses it back to `i64`
-- where it needs to look up the `Organization` row itself. The UNIQUE
-- constraint is the idempotency backstop for a double-clicked invitation
-- accept: a second INSERT for the same (tenant_id, user_id) pair fails
-- closed instead of creating a duplicate membership.
CREATE TABLE memberships (
id {pk},
tenant_id TEXT NOT NULL,
user_id {int:<9} NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at {ts:<9} NOT NULL DEFAULT {now},
UNIQUE (tenant_id, user_id)
);
CREATE INDEX idx_memberships_tenant ON memberships (tenant_id);
CREATE INDEX idx_memberships_user ON memberships (user_id);
-- A single-use, expiring, cryptographically-random email invitation. Only
-- the SHA-256 hash of the token is stored (`hash_api_token`) — never the
-- raw token — so a database leak cannot be replayed as an accept link.
-- `status` starts `pending`; accepting sets it to `accepted`, revoking sets
-- it to `revoked`. Both are terminal: the accept handler checks
-- `status = 'pending'` AND an unexpired `expires_at` before creating a
-- membership, so an expired/revoked/already-accepted token renders a clear
-- error instead of a second membership row or a panic. The partial unique
-- index below is the concurrency backstop for `create_invitation`: two
-- concurrent requests for the same (tenant_id, email) each revoke-then-insert
-- in their own transaction, so a `SELECT ... WHERE status = 'pending'` alone
-- cannot serialize them (no prior row to lock when none exists yet) — the
-- second transaction's INSERT fails closed against this index instead of
-- leaving two live pending tokens for the same invitee.
CREATE TABLE invitations (
id {pk},
tenant_id TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
token_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')),
invited_by_user_id {int:<9} NOT NULL,
expires_at {ts:<9} NOT NULL,
created_at {ts:<9} NOT NULL DEFAULT {now}
);
CREATE INDEX idx_invitations_tenant ON invitations (tenant_id);
CREATE INDEX idx_invitations_email ON invitations (email);
CREATE UNIQUE INDEX idx_invitations_pending_email ON invitations (tenant_id, email) WHERE status = 'pending';
",
pk = d.pk,
int = d.int,
ts = d.ts,
now = d.now,
)
}
const MIGRATION_DOWN: &str = "DROP TABLE IF EXISTS invitations;\nDROP TABLE IF EXISTS memberships;\nDROP TABLE IF EXISTS organizations;\n";
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use std::fs;
use tempfile::TempDir;
fn default_main() -> &'static str {
r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
tmp
}
#[test]
fn plan_errors_when_not_in_project() {
let tmp = TempDir::new().unwrap();
let err = plan_teams(tmp.path(), "20260101000000", false).unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn generates_all_expected_files_with_content_markers() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let role = fs::read_to_string(tmp.path().join("src/teams/role.rs")).unwrap();
assert!(role.contains("pub enum Role"), "{role}");
assert!(role.contains("Owner"), "{role}");
assert!(role.contains("Admin"), "{role}");
assert!(role.contains("Member"), "{role}");
assert!(role.contains("pub async fn require_role"), "{role}");
assert!(
role.contains("pub async fn establish_org_session"),
"{role}"
);
let models = fs::read_to_string(tmp.path().join("src/teams/models.rs")).unwrap();
assert!(models.contains("pub struct Organization"), "{models}");
assert!(models.contains("pub struct Membership"), "{models}");
assert!(models.contains("pub struct Invitation"), "{models}");
let mailer =
fs::read_to_string(tmp.path().join("src/teams/mailers/invitation_mailer.rs")).unwrap();
assert!(mailer.contains("pub struct InvitationMailer"), "{mailer}");
assert!(mailer.contains("#[mailer]"), "{mailer}");
assert!(tmp.path().join("src/teams/mod.rs").exists());
assert!(tmp.path().join("src/teams/schema.rs").exists());
assert!(tmp.path().join("src/teams/repositories.rs").exists());
assert!(tmp.path().join("src/teams/mailers/mod.rs").exists());
assert!(tmp.path().join("src/teams/routes/mod.rs").exists());
assert!(
tmp.path()
.join("src/teams/routes/organizations.rs")
.exists()
);
assert!(tmp.path().join("src/teams/routes/invitations.rs").exists());
assert!(tmp.path().join("src/teams/routes/members.rs").exists());
let migration_dir = tmp.path().join("migrations/20260101000000_create_teams");
let up = fs::read_to_string(migration_dir.join("up.sql")).unwrap();
assert!(up.contains("CREATE TABLE organizations"), "{up}");
assert!(up.contains("CREATE TABLE memberships"), "{up}");
assert!(up.contains("CREATE TABLE invitations"), "{up}");
let down = fs::read_to_string(migration_dir.join("down.sql")).unwrap();
assert!(
down.contains("DROP TABLE IF EXISTS organizations"),
"{down}"
);
}
#[test]
fn generated_forms_embed_csrf_token() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let members = fs::read_to_string(tmp.path().join("src/teams/routes/members.rs")).unwrap();
let csrf_field_count = invitations
.matches("input type=\"hidden\" name=\"_csrf\" value=(csrf_value(&csrf));")
.count()
+ members
.matches("input type=\"hidden\" name=\"_csrf\" value=(csrf_value(&csrf));")
.count();
assert_eq!(
csrf_field_count,
6,
"expected one CSRF field per generated form (accept, invite, role, remove, \
resend, revoke): invitations.rs has {}, members.rs has {}",
invitations.matches("form action=").count(),
members.matches("form action=").count()
);
assert!(
invitations.contains("csrf: Option<CsrfToken>"),
"show_invitation must extract Option<CsrfToken>: {invitations}"
);
assert!(
members.contains("csrf: Option<CsrfToken>"),
"list_members must extract Option<CsrfToken>: {members}"
);
}
#[test]
fn dry_run_writes_nothing_and_prints_header() {
let tmp = project();
let plan = plan_teams(tmp.path(), "20260101000000", false).unwrap();
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join("src/teams").exists());
assert!(!tmp.path().join("migrations").exists());
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(!main.contains("mod teams;"), "{main}");
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(!cargo.contains("\"mail\""), "{cargo}");
}
#[test]
fn second_run_without_force_collides() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let err = plan_teams(tmp.path(), "20260102000000", false)
.unwrap()
.execute(Flags::default())
.unwrap_err();
assert!(matches!(err, GenerateError::Collisions(_)));
}
#[test]
fn force_overwrites_existing_files() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_teams(tmp.path(), "20260102000000", false)
.unwrap()
.execute(Flags {
dry_run: false,
force: true,
})
.unwrap();
assert!(
tmp.path()
.join("migrations/20260101000000_create_teams")
.exists()
);
assert!(
!tmp.path()
.join("migrations/20260102000000_create_teams")
.exists()
);
}
#[test]
fn force_reuses_existing_migration_up_and_down_sql() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let up_path = tmp
.path()
.join("migrations/20260101000000_create_teams/up.sql");
fs::write(&up_path, "-- stale\n").unwrap();
plan_teams(tmp.path(), "20260102000000", false)
.unwrap()
.execute(Flags {
dry_run: false,
force: true,
})
.unwrap();
let migrations_root = tmp.path().join("migrations");
let entries: Vec<_> = fs::read_dir(&migrations_root)
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(
entries.len(),
1,
"exactly one *_create_teams migration directory must exist after --force: {entries:?}"
);
let up = fs::read_to_string(&up_path).unwrap();
assert!(
up.contains("CREATE TABLE organizations"),
"the original directory's up.sql must be refreshed, not left stale: {up}"
);
}
#[test]
fn cargo_toml_gets_mail_feature() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("\"mail\""), "{cargo}");
}
#[test]
fn cargo_toml_gets_maud_feature_and_dependency_for_api_style_project() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\", default-features = false }\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("\"maud\""), "{cargo}");
assert!(cargo.contains("maud ="), "{cargo}");
}
const PRE_FORK_POSTGRES_MIGRATION_UP: &str = r"-- Organizations, memberships, and invitations for team membership (issue
-- #1261). Deliberately does NOT create (or reference via REFERENCES) a
-- `users` table — your app already has its own from `autumn generate auth`;
-- `memberships.user_id` / `invitations.invited_by_user_id` are bare BIGINT
-- with no FK, so this migration has no ordering dependency on when your
-- users table was created.
-- An organization (tenant). Creating one makes the creator an `owner`
-- member (see `src/teams/routes/organizations.rs`).
CREATE TABLE organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- The user <-> organization join row, carrying the closed `role` enum
-- (`owner` | `admin` | `member`, see `src/teams/role.rs`). `tenant_scoped`
-- on `#[repository(Membership, ...)]` filters every read/write by the
-- active organization at the SQL level (issue #695's row-level
-- multi-tenancy seam, not a second isolation mechanism) — but the macro's
-- generated queries unconditionally filter on a column literally named
-- `tenant_id` (`TEXT`), so this holds the organization's id in its string
-- form rather than a typed FK; application code parses it back to `i64`
-- where it needs to look up the `Organization` row itself. The UNIQUE
-- constraint is the idempotency backstop for a double-clicked invitation
-- accept: a second INSERT for the same (tenant_id, user_id) pair fails
-- closed instead of creating a duplicate membership.
CREATE TABLE memberships (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id BIGINT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, user_id)
);
CREATE INDEX idx_memberships_tenant ON memberships (tenant_id);
CREATE INDEX idx_memberships_user ON memberships (user_id);
-- A single-use, expiring, cryptographically-random email invitation. Only
-- the SHA-256 hash of the token is stored (`hash_api_token`) — never the
-- raw token — so a database leak cannot be replayed as an accept link.
-- `status` starts `pending`; accepting sets it to `accepted`, revoking sets
-- it to `revoked`. Both are terminal: the accept handler checks
-- `status = 'pending'` AND `expires_at > now()` before creating a
-- membership, so an expired/revoked/already-accepted token renders a clear
-- error instead of a second membership row or a panic. The partial unique
-- index below is the concurrency backstop for `create_invitation`: two
-- concurrent requests for the same (tenant_id, email) each revoke-then-insert
-- in their own transaction, so a `SELECT ... WHERE status = 'pending'` alone
-- cannot serialize them (no prior row to lock when none exists yet) — the
-- second transaction's INSERT fails closed against this index instead of
-- leaving two live pending tokens for the same invitee.
CREATE TABLE invitations (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
token_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')),
invited_by_user_id BIGINT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invitations_tenant ON invitations (tenant_id);
CREATE INDEX idx_invitations_email ON invitations (email);
CREATE UNIQUE INDEX idx_invitations_pending_email ON invitations (tenant_id, email) WHERE status = 'pending';
";
#[test]
fn plan_teams_emits_sqlite_ddl() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project();
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
plan_teams(tmp.path(), "20260101000000", false)
.expect("generate teams must scaffold on a SQLite app")
.execute(Flags::default())
.unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260101000000_create_teams/up.sql"),
)
.unwrap();
assert_eq!(
up.matches("id INTEGER PRIMARY KEY AUTOINCREMENT")
.count()
+ up.matches("id INTEGER PRIMARY KEY AUTOINCREMENT")
.count(),
3,
"all three tables need a SQLite auto-increment id: {up}"
);
assert!(
up.contains("user_id INTEGER NOT NULL"),
"FK columns must be INTEGER on SQLite: {up}"
);
assert!(
up.contains("created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"),
"created_at must default to CURRENT_TIMESTAMP on SQLite: {up}"
);
for leak in ["BIGSERIAL", "BIGINT", "NOW()", "TIMESTAMP "] {
assert!(
!up.contains(leak),
"SQLite up.sql leaked Postgres-only `{leak}`: {up}"
);
}
},
);
}
#[test]
fn teams_sqlite_migration_applies_and_round_trips() {
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
#[derive(QueryableByName)]
struct Row {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::Timestamp)]
created_at: chrono::NaiveDateTime,
}
let mut conn = diesel::SqliteConnection::establish(":memory:").expect("in-memory sqlite");
let up = render_migration_up(DatabaseBackend::Sqlite);
conn.batch_execute(&up)
.expect("the SQLite up.sql must be valid SQLite DDL");
let run = |conn: &mut diesel::SqliteConnection, sql: &str| {
diesel::sql_query(sql.to_owned()).execute(conn)
};
run(
&mut conn,
"INSERT INTO organizations (name) VALUES ('Acme')",
)
.expect("insert an organization");
let orgs: Vec<Row> = diesel::sql_query("SELECT id, created_at FROM organizations")
.load(&mut conn)
.expect("an organization row must load back through diesel's sql types");
assert_eq!(orgs.len(), 1);
assert_eq!(orgs[0].id, 1, "id must be auto-assigned");
assert!(
orgs[0].created_at.and_utc().timestamp() > 0,
"created_at must be defaulted to a parseable timestamp by SQLite"
);
run(
&mut conn,
"INSERT INTO memberships (tenant_id, user_id, role) VALUES ('1', 7, 'owner')",
)
.expect("insert a membership");
run(
&mut conn,
"INSERT INTO invitations (tenant_id, email, role, token_hash, invited_by_user_id, \
expires_at) VALUES ('1', 'a@b.test', 'member', 'h1', 7, '2030-01-01 00:00:00')",
)
.expect("insert an invitation");
assert_sqlite_constraints_bite(&mut conn);
conn.batch_execute(MIGRATION_DOWN)
.expect("down.sql must roll the SQLite migration back");
assert!(
run(&mut conn, "SELECT 1 FROM organizations").is_err(),
"down.sql must drop the tables"
);
}
fn assert_sqlite_constraints_bite(conn: &mut diesel::SqliteConnection) {
use diesel::prelude::*;
let run = |conn: &mut diesel::SqliteConnection, sql: &str| {
diesel::sql_query(sql.to_owned()).execute(conn)
};
assert!(
run(
conn,
"INSERT INTO memberships (tenant_id, user_id, role) VALUES ('1', 8, 'wizard')",
)
.is_err(),
"the role CHECK must reject an out-of-enum role on SQLite"
);
assert!(
run(
conn,
"INSERT INTO invitations (tenant_id, email, role, token_hash, status, \
invited_by_user_id, expires_at) VALUES ('1', 'c@b.test', 'member', 'h9', \
'mailed', 7, '2030-01-01 00:00:00')",
)
.is_err(),
"the status CHECK must reject an out-of-enum status on SQLite"
);
assert!(
run(
conn,
"INSERT INTO invitations (tenant_id, email, role, token_hash, \
invited_by_user_id, expires_at) VALUES ('2', 'd@b.test', 'member', 'h1', 7, \
'2030-01-01 00:00:00')",
)
.is_err(),
"the token_hash UNIQUE must reject a reused token, even in another tenant"
);
assert!(
run(
conn,
"INSERT INTO memberships (tenant_id, user_id, role) VALUES ('1', 7, 'member')",
)
.is_err(),
"the memberships UNIQUE must reject a duplicate (tenant_id, user_id)"
);
assert!(
run(
conn,
"INSERT INTO invitations (tenant_id, email, role, token_hash, \
invited_by_user_id, expires_at) VALUES ('1', 'a@b.test', 'member', 'h2', 7, \
'2030-01-01 00:00:00')",
)
.is_err(),
"the partial unique index must reject a second PENDING invite for one invitee"
);
run(
conn,
"UPDATE invitations SET status = 'revoked' WHERE token_hash = 'h1'",
)
.expect("revoke the first invitation");
run(
conn,
"INSERT INTO invitations (tenant_id, email, role, token_hash, invited_by_user_id, \
expires_at) VALUES ('1', 'a@b.test', 'member', 'h3', 7, '2030-01-01 00:00:00')",
)
.expect("once the first invite is revoked the partial index must let a new one in");
}
#[test]
fn teams_postgres_migration_sql_is_unchanged() {
fn statements(sql: &str) -> Vec<&str> {
sql.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect()
}
assert_eq!(
statements(&render_migration_up(DatabaseBackend::Postgres)),
statements(PRE_FORK_POSTGRES_MIGRATION_UP),
"the Postgres DDL must not drift when the SQLite dialect is added"
);
}
fn autumn_web_entry(cargo: &str) -> &str {
cargo
.lines()
.find(|line| line.trim_start().starts_with("autumn-web"))
.unwrap_or_else(|| panic!("no autumn-web dependency in:\n{cargo}"))
}
#[test]
fn plan_teams_writes_the_sqlite_dependency_set() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project();
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("returning_clauses_for_sqlite_3_35"),
"the generated inserts need RETURNING on SQLite: {cargo}"
);
assert!(cargo.contains("libsqlite3-sys"), "{cargo}");
assert!(
cargo.contains("sync-connection-wrapper"),
"diesel-async must ride the sync wrapper on SQLite: {cargo}"
);
assert!(
!cargo.contains("pq-sys"),
"a SQLite app has no reason to name libpq: {cargo}"
);
assert!(
!cargo.contains("\"postgres\""),
"no dependency may pin the postgres feature on a SQLite app: {cargo}"
);
assert!(
autumn_web_entry(&cargo).contains("sqlite"),
"a SQLite app needs autumn-web's `sqlite` feature: {cargo}"
);
},
);
}
#[test]
fn plan_teams_writes_the_postgres_dependency_set_by_default() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("pq-sys"), "{cargo}");
assert!(cargo.contains("\"postgres\""), "{cargo}");
assert!(!cargo.contains("libsqlite3-sys"), "{cargo}");
assert!(
!autumn_web_entry(&cargo).contains("sqlite"),
"a Postgres app must not get autumn-web's `sqlite` feature: {cargo}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260101000000_create_teams/up.sql"),
)
.unwrap();
assert!(up.contains("id BIGSERIAL PRIMARY KEY"), "{up}");
assert!(
up.contains("created_at TIMESTAMP NOT NULL DEFAULT NOW()"),
"{up}"
);
assert!(
!up.contains("AUTOINCREMENT") && !up.contains("CURRENT_TIMESTAMP"),
"a Postgres app must not get SQLite DDL: {up}"
);
},
);
}
#[test]
fn destroy_round_trips_on_a_sqlite_app() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project();
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(tmp.path().join("src/teams").exists());
plan_teams(tmp.path(), "20260101000000", true)
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/teams").exists());
assert!(
!tmp.path()
.join("migrations/20260101000000_create_teams")
.exists(),
"destroy must take the SQLite migration back out too"
);
},
);
}
#[test]
fn main_rs_gets_mod_and_routes_wired_idempotently() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("mod teams;"), "{main}");
for entry in teams_route_entries() {
assert!(main.contains(&entry), "missing {entry} in:\n{main}");
}
plan_teams(tmp.path(), "20260103000000", false)
.unwrap()
.execute(Flags {
dry_run: false,
force: true,
})
.unwrap();
let main_again = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(main_again.matches("mod teams;").count(), 1, "{main_again}");
for entry in teams_route_entries() {
assert_eq!(
main_again.matches(&entry).count(),
1,
"duplicated {entry} in:\n{main_again}"
);
}
}
#[test]
fn plan_warns_about_tenancy_config() {
let tmp = project();
let plan = plan_teams(tmp.path(), "20260101000000", false).unwrap();
assert!(
plan.warnings.iter().any(|w| w.contains("[tenancy]")),
"{:?}",
plan.warnings
);
}
#[test]
fn plan_warns_about_invite_public_path_not_invitations() {
let tmp = project();
let plan = plan_teams(tmp.path(), "20260101000000", false).unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("public_paths") && w.contains("/invite")),
"{:?}",
plan.warnings
);
}
#[test]
fn invitations_route_uses_invite_prefix_for_invitee_facing_routes() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains(r#"#[get("/invite/{token}")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invite/{token}/accept")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations/{id}/revoke")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations/{id}/resend")]"#),
"{invitations}"
);
assert!(
!invitations.contains(r#"#[get("/invitations/{token}")]"#),
"{invitations}"
);
assert!(
!invitations.contains(r#"#[post("/invitations/{token}/accept")]"#),
"{invitations}"
);
}
#[test]
fn invitations_route_sends_invite_mail_synchronously() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(invitations.contains(".send_invite("), "{invitations}");
assert!(
!invitations.contains(".deliver_later_invite("),
"{invitations}"
);
}
#[test]
fn resend_invitation_locks_and_rechecks_pending_status() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains("::autumn_web::maybe_for_update!("),
"{invitations}"
);
assert!(
invitations.contains("This invitation is no longer pending"),
"{invitations}"
);
}
#[test]
fn create_invitation_rejects_owner_grant_from_non_owner_caller() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains(
"let caller_role = require_role(&session, &membership_repo, Role::Admin).await?;"
),
"{invitations}"
);
assert!(
invitations.contains("role == Role::Owner && caller_role != Role::Owner"),
"{invitations}"
);
assert!(
invitations.contains("Only an owner can invite someone as owner"),
"{invitations}"
);
}
#[test]
fn change_role_rejects_owner_grant_from_non_owner_caller() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let members = fs::read_to_string(tmp.path().join("src/teams/routes/members.rs")).unwrap();
assert!(
members.contains("require_role(&session, &membership_repo, Role::Admin).await?;"),
"{members}"
);
assert!(
members.contains("new_role == Role::Owner && caller_role != Role::Owner"),
"{members}"
);
assert!(
members.contains("Only an owner can grant the owner role"),
"{members}"
);
}
#[test]
fn members_route_blocks_admin_from_touching_an_owner_membership() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let members = fs::read_to_string(tmp.path().join("src/teams/routes/members.rs")).unwrap();
assert!(
members.contains("Only an owner can change another owner's role"),
"{members}"
);
assert!(
members.contains("Only an owner can remove another owner"),
"{members}"
);
}
#[test]
fn accept_invitation_consumes_lingering_invite_for_existing_member() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains("if invitation.status == \"pending\""),
"{invitations}"
);
}
#[test]
fn create_invitation_rejects_rather_than_replaces_a_pending_invitation_for_same_email() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let create_start = invitations.find("pub async fn create_invitation(").unwrap();
let create_end = invitations
.find("// ── Accept (show) ")
.unwrap_or(invitations.len());
let body = &invitations[create_start..create_end];
assert!(
body.contains("if existing_pending.is_some()")
&& body.contains("AutumnError::conflict_msg("),
"{body}"
);
assert!(
!body.contains(".set(invitations::status.eq(\"revoked\"))"),
"create_invitation must reject rather than revoke a prior pending invitation: {body}"
);
}
#[test]
fn provision_default_organization_does_not_establish_session() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations
.contains("pub async fn provision_default_organization(user_id: i64, db: &mut Db)"),
"{organizations}"
);
let body_start = organizations
.find("pub async fn provision_default_organization")
.unwrap();
let body_end = organizations
.find("pub async fn create_organization")
.unwrap();
let body = &organizations[body_start..body_end];
assert!(
!body.contains("establish_org_session"),
"provision_default_organization must not establish a session: {body}"
);
}
#[test]
fn provision_default_organization_on_conn_exists_for_full_atomicity() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains("pub async fn provision_default_organization_on_conn("),
"{organizations}"
);
assert!(
organizations.contains("conn: &mut autumn_web::db::PooledConnection"),
"{organizations}"
);
}
#[test]
fn generate_then_destroy_round_trips_main_rs_and_cargo_toml() {
let tmp = project();
let main_path = tmp.path().join("src/main.rs");
let cargo_path = tmp.path().join("Cargo.toml");
let original_main = fs::read_to_string(&main_path).unwrap();
let original_cargo = fs::read_to_string(&cargo_path).unwrap();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_teams(tmp.path(), "20260101000000", true)
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/teams").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
assert_eq!(fs::read_to_string(&cargo_path).unwrap(), original_cargo);
}
#[test]
fn accept_invitation_rejects_revoked_or_expired_before_existing_member_shortcut() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let reject_pos = invitations
.find("invitation.status == \"revoked\"")
.unwrap_or_else(|| panic!("missing revoked/expired guard: {invitations}"));
let existing_pos = invitations
.find("let existing: Option<Membership> = memberships::table")
.unwrap_or_else(|| panic!("missing existing-membership lookup: {invitations}"));
assert!(
reject_pos < existing_pos,
"revoked/expired check must run before the existing-membership shortcut"
);
}
#[test]
fn migration_has_partial_unique_index_for_pending_invitation_email() {
for backend in [DatabaseBackend::Postgres, DatabaseBackend::Sqlite] {
let up = render_migration_up(backend);
assert!(
up.contains(
"CREATE UNIQUE INDEX idx_invitations_pending_email ON invitations (tenant_id, email) WHERE status = 'pending';"
),
"{backend:?}: {up}"
);
}
}
#[test]
fn create_invitation_translates_pending_email_unique_violation_to_conflict() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains("autumn_web::error::unique_violation_field")
&& invitations.contains("\"idx_invitations_pending_email\""),
"{invitations}"
);
}
#[test]
fn remove_all_memberships_exists_for_account_deletion_integration() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations
.contains("pub async fn remove_all_memberships(user_id: i64, db: &mut Db)"),
"{organizations}"
);
assert!(
organizations.contains(
"diesel::delete(memberships::table.filter(memberships::id.eq(own_membership.id)))"
),
"{organizations}"
);
}
#[test]
fn remove_all_memberships_promotes_a_successor_before_dropping_the_last_owner() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains(".filter(memberships::role.eq(Role::Owner.as_str()))"),
"{organizations}"
);
assert!(
organizations.contains(".filter(memberships::role.eq(Role::Admin.as_str()))"),
"{organizations}"
);
assert!(
organizations.contains(".set(memberships::role.eq(Role::Owner.as_str()))"),
"{organizations}"
);
let promote_pos = organizations
.find(".filter(memberships::role.eq(Role::Admin.as_str()))")
.unwrap();
let delete_pos = organizations
.find(
"diesel::delete(memberships::table.filter(memberships::id.eq(own_membership.id)))",
)
.unwrap();
assert!(promote_pos < delete_pos);
}
#[test]
fn remove_all_memberships_skips_promotion_when_another_owner_remains() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
let other_owner_pos = organizations
.find("let other_owner: Option<Membership> = ::autumn_web::maybe_for_update!(")
.unwrap_or_else(|| panic!("missing other-owner guard: {organizations}"));
assert!(
organizations.contains("if other_owner.is_none() {"),
"{organizations}"
);
let admin_lookup_pos = organizations
.find(".filter(memberships::role.eq(Role::Admin.as_str()))")
.unwrap();
assert!(
other_owner_pos < admin_lookup_pos,
"the other-owner check must run before any successor is looked up"
);
}
#[test]
fn remove_all_memberships_revokes_pending_invitations_when_no_successor_exists() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations
.contains("use crate::teams::schema::{invitations, memberships, organizations};"),
"{organizations}"
);
assert!(
organizations.contains(".filter(invitations::tenant_id.eq(&own_membership.tenant_id))")
&& organizations.contains(".filter(invitations::status.eq(\"pending\"))")
&& organizations.contains(".set(invitations::status.eq(\"revoked\"))"),
"{organizations}"
);
}
#[test]
fn remove_all_memberships_on_conn_exists_for_full_atomicity() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains("pub async fn remove_all_memberships_on_conn("),
"{organizations}"
);
assert!(
organizations.contains("conn: &mut autumn_web::db::PooledConnection"),
"{organizations}"
);
assert!(
organizations.contains(
"db.tx(move |conn| remove_all_memberships_on_conn(conn, user_id).scope_boxed())"
),
"{organizations}"
);
}
#[test]
fn plan_warns_about_custom_auth_resource_table_name() {
let tmp = project();
let plan = plan_teams(tmp.path(), "20260101000000", false).unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("caller_email_lookup")
&& w.contains("autumn generate auth Account")),
"{:?}",
plan.warnings
);
}
#[test]
fn accept_invitation_and_remove_all_memberships_lock_organizations_first() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let accept_org_lock_pos = invitations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap_or_else(|| panic!("missing org-row lock in accept_invitation: {invitations}"));
let accept_invitation_lock_pos = invitations
.find("let invitation: Invitation = ::autumn_web::maybe_for_update!(\n invitations::table.filter(invitations::id.eq(invitation_id))\n )")
.unwrap_or_else(|| panic!("missing invitation-row lock: {invitations}"));
assert!(
accept_org_lock_pos < accept_invitation_lock_pos,
"accept_invitation must lock organizations before invitations"
);
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
let cleanup_org_lock_pos = organizations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap_or_else(|| {
panic!("missing org-row lock in remove_all_memberships_on_conn: {organizations}")
});
let cleanup_other_owner_pos = organizations
.find("let other_owner: Option<Membership> = ::autumn_web::maybe_for_update!(")
.unwrap();
assert!(
cleanup_org_lock_pos < cleanup_other_owner_pos,
"remove_all_memberships_on_conn must lock organizations before checking for a successor"
);
}
#[test]
fn remove_all_memberships_on_conn_does_not_lock_the_initial_scan() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains(
"let member_tenant_ids: Vec<String> = memberships::table\n .filter(memberships::user_id.eq(user_id))\n .select(memberships::tenant_id)\n .order(memberships::tenant_id.asc())\n .load(conn)\n .await?;"
),
"{organizations}"
);
let org_lock_pos = organizations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap();
let own_row_relock_pos = organizations
.find("let Some(own_membership) = ::autumn_web::maybe_for_update!(")
.unwrap();
assert!(org_lock_pos < own_row_relock_pos);
}
#[test]
fn create_and_resend_invitation_lock_organizations_before_issuing_a_token() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let create_org_lock_pos = invitations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap_or_else(|| panic!("missing org-row lock in create_invitation: {invitations}"));
let create_insert_pos = invitations
.find("diesel::insert_into(invitations::table)")
.unwrap();
assert!(
create_org_lock_pos < create_insert_pos,
"create_invitation must lock organizations before inserting"
);
let resend_org_lock_pos = invitations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap_or_else(|| panic!("missing org-row lock in resend_invitation: {invitations}"));
let resend_status_check_pos = invitations
.find("if current.status != \"pending\"")
.unwrap();
assert!(
resend_org_lock_pos < resend_status_check_pos,
"resend_invitation must lock organizations before its own invitation-status recheck"
);
}
#[test]
fn generated_change_role_and_remove_member_lock_memberships_transactionally() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let members = fs::read_to_string(tmp.path().join("src/teams/routes/members.rs")).unwrap();
assert!(
!members.contains("membership_repo.update("),
"change_role must no longer use the non-transactional repository update: {members}"
);
assert!(
!members.contains("membership_repo.delete_by_id("),
"remove_member must no longer use the non-transactional repository delete: {members}"
);
assert_eq!(
members.matches("db.tx(move |conn| {").count(),
2,
"both change_role and remove_member must run inside a locked transaction: {members}"
);
assert_eq!(
members
.matches(
"::autumn_web::maybe_for_update!(\n memberships::table.filter(memberships::tenant_id.eq(&tenant_id))\n )"
)
.count(),
2,
"both handlers must lock every membership row in the active organization: {members}"
);
}
#[test]
fn change_role_and_remove_member_revalidate_caller_from_locked_snapshot() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let members = fs::read_to_string(tmp.path().join("src/teams/routes/members.rs")).unwrap();
assert_eq!(
members
.matches("memberships.iter().find(|m| m.user_id == caller_id)")
.count(),
2,
"both handlers must re-derive the caller's membership from the locked snapshot: {members}"
);
assert!(
members.matches("caller_role.at_least(Role::Admin)").count() >= 2,
"{members}"
);
}
#[test]
fn create_and_resend_invitation_revalidate_inviter_after_organization_lock() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
assert_eq!(
invitations
.matches(
"let inviter_membership: Option<Membership> = ::autumn_web::maybe_for_update!("
)
.count(),
2,
"both create_invitation and resend_invitation must re-lock and revalidate the \
inviter's membership: {invitations}"
);
let create_org_lock_pos = invitations
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap();
let create_revalidate_pos = invitations
.find("let inviter_membership: Option<Membership> = ::autumn_web::maybe_for_update!(")
.unwrap();
let create_owner_grant_check_pos = invitations
.find("if role == Role::Owner && current_caller_role != Role::Owner")
.unwrap_or_else(|| panic!("missing revalidated owner-grant check: {invitations}"));
assert!(create_org_lock_pos < create_revalidate_pos);
assert!(create_revalidate_pos < create_owner_grant_check_pos);
}
#[test]
fn accept_invitation_relocks_the_caller_account_row() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let accept_body = invitations
.split("pub async fn accept_invitation(")
.nth(1)
.and_then(|rest| rest.split("pub async fn revoke_invitation(").next())
.unwrap_or_else(|| panic!("missing accept_invitation: {invitations}"));
let account_relock_pos = accept_body
.find("let live_caller_email: Option<String> = ::autumn_web::maybe_for_update!(")
.unwrap_or_else(|| panic!("missing account-row relock: {accept_body}"));
let org_lock_pos = accept_body
.find("::autumn_web::maybe_for_update!(organizations::table.find(org_id))")
.unwrap();
let insert_pos = accept_body
.find("let membership: Membership = diesel::insert_into(memberships::table)")
.unwrap();
assert!(
account_relock_pos < org_lock_pos,
"account row must be locked FIRST, before the organization lock — the same order \
an account-deletion handler must use (see docs/generate-teams.md)"
);
assert!(
org_lock_pos < insert_pos,
"organization lock must precede the membership insert"
);
assert!(
invitations.contains(
"caller_users::table\n .filter(caller_users::id.eq(user_id))\n .select(caller_users::email)"
),
"{invitations}"
);
assert!(
invitations.contains(
"live_caller_email.trim().to_lowercase() != invitation_email.trim().to_lowercase()"
),
"{invitations}"
);
}
#[test]
fn remove_all_memberships_on_conn_scans_every_membership_not_just_owned_ones() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains(
"let member_tenant_ids: Vec<String> = memberships::table\n .filter(memberships::user_id.eq(user_id))\n .select(memberships::tenant_id)\n .order(memberships::tenant_id.asc())\n .load(conn)\n .await?;"
),
"the initial scan must not filter by role — a plain Admin/Member row must be \
included too: {organizations}"
);
assert!(
!organizations.contains(
"diesel::delete(memberships::table.filter(memberships::user_id.eq(user_id)))"
),
"{organizations}"
);
assert!(
organizations.contains(
"diesel::delete(memberships::table.filter(memberships::id.eq(own_membership.id)))"
),
"{organizations}"
);
}
#[test]
fn remove_all_memberships_on_conn_orders_the_scan_deterministically() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let organizations =
fs::read_to_string(tmp.path().join("src/teams/routes/organizations.rs")).unwrap();
assert!(
organizations.contains(".order(memberships::tenant_id.asc())"),
"{organizations}"
);
}
#[test]
fn resend_invitation_requires_owner_to_resend_an_owner_invitation() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let resend_body = invitations
.split("pub async fn resend_invitation(")
.nth(1)
.unwrap_or_else(|| panic!("missing resend_invitation: {invitations}"));
assert!(
resend_body.contains(
"if current.role == Role::Owner.as_str() && current_caller_role != Role::Owner"
),
"{resend_body}"
);
}
#[test]
fn revoke_invitation_revalidates_caller_transactionally() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let revoke_body = invitations
.split("pub async fn revoke_invitation(")
.nth(1)
.and_then(|rest| rest.split("pub async fn resend_invitation(").next())
.unwrap_or_else(|| panic!("missing revoke_invitation: {invitations}"));
assert!(revoke_body.contains("db.tx(move |conn| {"), "{revoke_body}");
assert!(
revoke_body.contains(
"let caller_membership: Option<Membership> = ::autumn_web::maybe_for_update!("
),
"{revoke_body}"
);
assert!(
!revoke_body.contains("invitation_repo\n .update("),
"revoke must no longer use the non-transactional repository update: {revoke_body}"
);
}
#[test]
fn revoke_invitation_rejects_a_non_pending_invitation() {
let tmp = project();
plan_teams(tmp.path(), "20260101000000", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let invitations =
fs::read_to_string(tmp.path().join("src/teams/routes/invitations.rs")).unwrap();
let revoke_body = invitations
.split("pub async fn revoke_invitation(")
.nth(1)
.and_then(|rest| rest.split("pub async fn resend_invitation(").next())
.unwrap_or_else(|| panic!("missing revoke_invitation: {invitations}"));
assert!(
revoke_body.contains(
"let current: Invitation = ::autumn_web::maybe_for_update!(\n invitations::table.filter(invitations::id.eq(invitation_id))\n )"
),
"{revoke_body}"
);
assert!(
revoke_body.contains("if current.status != \"pending\""),
"{revoke_body}"
);
}
}