mod audit;
mod decommission;
mod hosts;
mod migrate;
mod operators;
mod provisioning;
use crate::core::Column as _;
use crate::sql::FetcherPool;
use crate::storage::BoxedStorage;
use axum::body::Body;
use axum::extract::{Form, Multipart, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode, Uri};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post};
use axum::{Extension, Router};
use cookie::time::Duration as CookieDuration;
use cookie::{Cookie, SameSite};
use serde::Deserialize;
use std::sync::Arc;
use tera::{Context, Tera};
pub use super::session::{self, SessionPayload, SessionSecret, SessionSecretError};
use super::session::{COOKIE_NAME, SESSION_TTL_SECS};
use super::auth;
use super::branding::{self, BrandAssetKind};
#[allow(unused_imports)] use super::pools::TenantPools;
const RUSTANGO_PNG: &[u8] = include_bytes!("../static/rustango.png");
pub const ACTION_TARGET: &str = "rustango::tenancy::operator_console::action";
#[derive(Clone)]
struct ConsoleState {
registry: crate::sql::Pool,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
provisioner: Option<Arc<dyn crate::tenancy::provision::TenantProvisioner>>,
session_secret: Arc<SessionSecret>,
tera: Arc<Tera>,
brand_storage: BoxedStorage,
op_brand: Arc<OpBrand>,
tenant_session_secret: Option<Arc<SessionSecret>>,
tenant_handoff_url: String,
}
#[derive(Debug, Clone)]
struct OpBrand {
name: String,
tagline: Option<String>,
logo_url: String,
primary_color: Option<String>,
theme_mode: String,
}
impl OpBrand {
fn defaults() -> Self {
Self {
name: "Rustango".to_owned(),
tagline: None,
logo_url: "/__static__/rustango.png".to_owned(),
primary_color: None,
theme_mode: "auto".to_owned(),
}
}
fn from_env() -> Self {
let mut out = Self::defaults();
#[cfg(feature = "config")]
match crate::config::Settings::load_from_env() {
Ok(s) => Self::apply_brand_settings(&mut out, &s.brand),
Err(e) if e.is_missing_config() => {}
Err(e) => warn_config_error(&e),
}
Self::apply_env_overrides(&mut out);
out
}
#[cfg(feature = "config")]
fn apply_brand_settings(out: &mut Self, b: &crate::config::BrandSettings) {
if let Some(n) = b.name.as_deref().filter(|s| !s.is_empty()) {
out.name = n.to_owned();
}
if let Some(t) = b.tagline.as_deref().filter(|s| !s.is_empty()) {
out.tagline = Some(t.to_owned());
}
if let Some(u) = b.logo_url.as_deref().filter(|s| !s.is_empty()) {
out.logo_url = u.to_owned();
}
if let Some(hex) = b
.primary_color
.as_deref()
.and_then(branding::validate_hex_color)
{
out.primary_color = Some(hex);
}
if let Some(mode) = b
.theme_mode
.as_deref()
.and_then(branding::validate_theme_mode)
{
out.theme_mode = mode.to_owned();
}
}
fn apply_env_overrides(out: &mut Self) {
if let Ok(v) = std::env::var("RUSTANGO_OPERATOR_BRAND_NAME") {
if !v.is_empty() {
out.name = v;
}
}
if let Ok(v) = std::env::var("RUSTANGO_OPERATOR_TAGLINE") {
if !v.is_empty() {
out.tagline = Some(v);
}
}
if let Ok(v) = std::env::var("RUSTANGO_OPERATOR_LOGO_URL") {
if !v.is_empty() {
out.logo_url = v;
}
}
if let Some(hex) = std::env::var("RUSTANGO_OPERATOR_PRIMARY_COLOR")
.ok()
.as_deref()
.and_then(branding::validate_hex_color)
{
out.primary_color = Some(hex);
}
if let Some(mode) = std::env::var("RUSTANGO_OPERATOR_THEME_MODE")
.ok()
.as_deref()
.and_then(branding::validate_theme_mode)
{
out.theme_mode = mode.to_owned();
}
}
}
#[must_use]
pub fn router_with_provisioning(
registry: impl Into<crate::sql::Pool>,
pools: Arc<dyn crate::tenancy::TenantPoolInvalidator>,
provisioner: Arc<dyn crate::tenancy::provision::TenantProvisioner>,
secret: SessionSecret,
) -> Router {
router_inner(
registry.into(),
Some(pools),
Some(provisioner),
secret,
branding::default_brand_storage(),
None,
default_tenant_handoff_url(),
)
}
#[must_use]
pub fn router(registry: impl Into<crate::sql::Pool>, secret: SessionSecret) -> Router {
router_inner(
registry.into(),
None,
None,
secret,
branding::default_brand_storage(),
None,
default_tenant_handoff_url(),
)
}
#[must_use]
pub fn router_with_pools(
registry: impl Into<crate::sql::Pool>,
pools: Arc<dyn crate::tenancy::TenantPoolInvalidator>,
secret: SessionSecret,
) -> Router {
router_inner(
registry.into(),
Some(pools),
None,
secret,
branding::default_brand_storage(),
None,
default_tenant_handoff_url(),
)
}
#[must_use]
pub fn router_with_impersonation(
registry: impl Into<crate::sql::Pool>,
pools: Arc<dyn crate::tenancy::TenantPoolInvalidator>,
secret: SessionSecret,
brand_storage: BoxedStorage,
tenant_session_secret: SessionSecret,
tenant_handoff_url: String,
) -> Router {
router_inner(
registry.into(),
Some(pools),
None,
secret,
brand_storage,
Some(tenant_session_secret),
tenant_handoff_url,
)
}
#[must_use]
pub fn router_with_brand_storage(
registry: impl Into<crate::sql::Pool>,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
secret: SessionSecret,
brand_storage: BoxedStorage,
) -> Router {
router_inner(
registry.into(),
pools,
None,
secret,
brand_storage,
None,
default_tenant_handoff_url(),
)
}
fn server_error(context: &str, e: &dyn std::fmt::Display) -> Response<Body> {
let body = crate::error::server_error_body(context, e);
(StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
}
fn default_tenant_handoff_url() -> String {
super::routes::RouteConfig::default().impersonation_handoff_url
}
#[must_use]
pub fn router_full(
registry: impl Into<crate::sql::Pool>,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
provisioner: Option<Arc<dyn crate::tenancy::provision::TenantProvisioner>>,
secret: SessionSecret,
brand_storage: BoxedStorage,
tenant_session_secret: Option<SessionSecret>,
tenant_handoff_url: String,
) -> Router {
router_inner(
registry.into(),
pools,
provisioner,
secret,
brand_storage,
tenant_session_secret,
tenant_handoff_url,
)
}
pub(crate) fn router_full_unlogged(
registry: crate::sql::Pool,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
provisioner: Option<Arc<dyn crate::tenancy::provision::TenantProvisioner>>,
secret: SessionSecret,
brand_storage: BoxedStorage,
tenant_session_secret: Option<SessionSecret>,
tenant_handoff_url: String,
) -> Router {
router_unlogged(
registry,
pools,
provisioner,
secret,
brand_storage,
tenant_session_secret,
tenant_handoff_url,
)
}
fn router_inner(
registry: crate::sql::Pool,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
provisioner: Option<Arc<dyn crate::tenancy::provision::TenantProvisioner>>,
secret: SessionSecret,
brand_storage: BoxedStorage,
tenant_session_secret: Option<SessionSecret>,
tenant_handoff_url: String,
) -> Router {
use crate::access_log::AccessLogRouterExt as _;
router_unlogged(
registry,
pools,
provisioner,
secret,
brand_storage,
tenant_session_secret,
tenant_handoff_url,
)
.access_log(access_log_layer())
}
pub(crate) fn access_log_layer() -> crate::access_log::AccessLogLayer {
crate::access_log::AccessLogLayer::new()
.redact_additional("next")
.redact_additional("token")
}
fn router_unlogged(
registry: crate::sql::Pool,
pools: Option<Arc<dyn crate::tenancy::TenantPoolInvalidator>>,
provisioner: Option<Arc<dyn crate::tenancy::provision::TenantProvisioner>>,
secret: SessionSecret,
brand_storage: BoxedStorage,
tenant_session_secret: Option<SessionSecret>,
tenant_handoff_url: String,
) -> Router {
let mut tera = crate::template_extensions::html_tera();
tera.add_raw_templates([
(
"_theme_tokens.html",
include_str!("../../styles/theme_tokens.html"),
),
(
"_op_styles.html",
include_str!("../templates/_op_styles.html"),
),
(
"_op_pager.html",
include_str!("../templates/_op_pager.html"),
),
(
"_theme_toggle.html",
include_str!("../../admin/templates/_theme_toggle.html"),
),
(
"op_layout.html",
include_str!("../templates/op_layout.html"),
),
("op_login.html", include_str!("../templates/op_login.html")),
(
"op_welcome.html",
include_str!("../templates/op_welcome.html"),
),
(
"op_operators.html",
include_str!("../templates/op_operators.html"),
),
("op_orgs.html", include_str!("../templates/op_orgs.html")),
(
"op_orgs_edit.html",
include_str!("../templates/op_orgs_edit.html"),
),
(
"op_org_hosts.html",
include_str!("../templates/op_org_hosts.html"),
),
(
"op_change_password.html",
include_str!("../templates/op_change_password.html"),
),
(
"op_sso_shared.html",
include_str!("../templates/op_sso_shared.html"),
),
(
"op_orgs_new.html",
include_str!("../templates/op_orgs_new.html"),
),
(
"op_provision_run.html",
include_str!("../templates/op_provision_run.html"),
),
(
"op_provision_runs.html",
include_str!("../templates/op_provision_runs.html"),
),
("op_audit.html", include_str!("../templates/op_audit.html")),
])
.expect("operator-console templates parse");
let edit_enabled = pools.is_some();
let impersonation_enabled = tenant_session_secret.is_some() && pools.is_some();
let provisioning_enabled = provisioner.is_some();
let state = ConsoleState {
registry,
pools,
provisioner,
session_secret: Arc::new(secret),
tera: Arc::new(tera),
brand_storage,
op_brand: Arc::new(OpBrand::from_env()),
tenant_session_secret: tenant_session_secret.map(Arc::new),
tenant_handoff_url,
};
let public = Router::new()
.route("/login", get(login_form).post(login_submit))
.route("/logout", post(logout));
let assets = Router::new()
.route("/__static__/rustango.png", get(static_rustango_png))
.route("/__brand__/{slug}/{filename}", get(serve_brand_asset));
let mut private = Router::new()
.route("/", get(welcome))
.route("/operators", get(operators::operators_list))
.route("/orgs", get(orgs_list))
.route("/audit", get(audit::audit_list))
.route(
"/change-password",
get(change_password_form).post(change_password_submit),
);
#[cfg(feature = "admin-sso")]
{
private = private.route("/sso-shared", get(sso_shared_list));
if edit_enabled {
private = private
.route("/sso-shared", post(sso_shared_create))
.route("/sso-shared/{id}/delete", post(sso_shared_delete))
.route(
"/sso-shared/{id}/email-link",
post(sso_shared_set_email_link),
);
}
}
if edit_enabled {
private = private
.route("/operators", post(operators::operator_create))
.route(
"/orgs/prewarm",
get(orgs_post_only_redirect).post(prewarm_pools),
)
.route(
"/operators/{id}/active",
get(op_post_only_redirect).post(operators::operator_set_active),
)
.route(
"/operators/{id}/reset-password",
get(op_post_only_redirect).post(operators::operator_reset_password),
)
.route(
"/orgs/{slug}/edit",
get(org_edit_form).post(org_edit_submit),
)
.route(
"/orgs/{slug}/edit/branding",
get(org_post_only_redirect).post(org_edit_branding),
)
.route(
"/orgs/{slug}/deactivate",
get(org_post_only_redirect).post(decommission::deactivate),
)
.route(
"/orgs/{slug}/purge",
get(org_post_only_redirect).post(decommission::purge),
)
.route(
"/orgs/{slug}/test-connection",
post(provisioning::test_tenant_connection),
)
.route("/orgs/{slug}/hosts", get(hosts::org_hosts_view))
.route(
"/orgs/{slug}/hosts/add",
get(org_post_only_redirect).post(hosts::org_hosts_add),
)
.route(
"/orgs/{slug}/hosts/remove",
get(org_post_only_redirect).post(hosts::org_hosts_remove),
)
.route(
"/orgs/{slug}/hosts/toggle",
get(org_post_only_redirect).post(hosts::org_hosts_toggle),
);
}
if provisioning_enabled {
private = private
.route(
"/orgs/new",
get(provisioning::org_new_form).post(provisioning::org_new_submit),
)
.route("/orgs/test-connection", post(provisioning::test_connection))
.route(
"/orgs/migrate",
get(op_post_only_redirect).post(migrate::migrate_all),
)
.route(
"/orgs/{slug}/migrate",
get(org_post_only_redirect).post(migrate::migrate_one),
)
.route("/orgs/provision", get(provisioning::provision_runs_index))
.route(
"/orgs/provision/{run_id}",
get(provisioning::provision_run_view),
)
.route(
"/orgs/provision/{run_id}/stream",
get(provisioning::provision_run_stream),
);
}
if impersonation_enabled {
private = private.route(
"/orgs/{slug}/impersonate",
get(org_post_only_redirect).post(org_impersonate),
);
}
let private = private.route_layer(middleware::from_fn_with_state(
state.clone(),
require_session,
));
public
.merge(private)
.route_layer(middleware::from_fn(
crate::admin::csrf_context::csrf_context,
))
.route_layer(crate::forms::csrf::layer())
.merge(assets)
.with_state(state)
}
pub(super) const PAGE_SIZE: usize = 50;
pub(super) struct Paged {
pub(super) offset: i64,
pub(super) limit: i64,
number: usize,
total: usize,
num_pages: usize,
has_previous: bool,
has_next: bool,
marks: Vec<serde_json::Value>,
}
impl Paged {
pub(super) fn from_total(total: i64, requested: Option<i64>) -> Self {
let total = usize::try_from(total).unwrap_or(0);
let paginator = crate::pagination::Paginator::new(total, PAGE_SIZE);
let page = paginator.get_page(requested.unwrap_or(1));
let marks = paginator
.get_elided_page_range(page.number, 3, 2)
.into_iter()
.map(|mark| match mark {
crate::pagination::PageMark::Number(n) => serde_json::json!({"number": n}),
crate::pagination::PageMark::Ellipsis => serde_json::json!({"ellipsis": true}),
})
.collect();
Self {
offset: i64::try_from(page.offset()).unwrap_or(i64::MAX),
limit: i64::try_from(page.limit()).unwrap_or(i64::MAX),
number: page.number,
total,
num_pages: paginator.num_pages(),
has_previous: page.has_previous(),
has_next: page.has_next(),
marks,
}
}
pub(super) async fn of_model(
pool: &crate::sql::Pool,
model: &'static crate::core::ModelSchema,
requested: Option<i64>,
) -> Result<Self, crate::sql::ExecError> {
let total = count_where(pool, model, crate::core::WhereExpr::default()).await?;
Ok(Self::from_total(total, requested))
}
pub(super) fn inject(&self, ctx: &mut Context, base: &str, suffix: &str) {
ctx.insert("page", &self.number);
ctx.insert("total_pages", &self.num_pages);
ctx.insert("total", &self.total);
ctx.insert("has_prev", &self.has_previous);
ctx.insert("has_next", &self.has_next);
ctx.insert("page_marks", &self.marks);
ctx.insert("pager_base", base);
ctx.insert("query_suffix", suffix);
}
}
#[cfg(feature = "template_views")]
fn nav_section(path: &str) -> &'static str {
match path.split('/').nth(1).unwrap_or("") {
"orgs" => "orgs",
"operators" => "operators",
"audit" => "audit",
"sso-shared" => "sso",
"change-password" => "change_password",
_ => "home",
}
}
pub(super) async fn count_where(
pool: &crate::sql::Pool,
model: &'static crate::core::ModelSchema,
where_clause: crate::core::WhereExpr,
) -> Result<i64, crate::sql::ExecError> {
let count = crate::core::CountQuery::new(model, where_clause);
crate::sql::count_rows_pool(pool, &count).await
}
#[derive(Deserialize)]
pub(super) struct PageQuery {
#[serde(default)]
pub(super) page: Option<i64>,
}
#[derive(Deserialize)]
pub(super) struct ListQuery {
#[serde(default)]
pub(super) page: Option<i64>,
#[serde(default)]
pub(super) error: Option<String>,
#[serde(default)]
pub(super) notice: Option<String>,
}
fn render(state: &ConsoleState, template: &str, ctx: &Context) -> Response<Body> {
let mut ctx = ctx.clone();
ctx.insert(
"csp_nonce",
&crate::csp_nonce::current().unwrap_or_default(),
);
if let Some(token) = crate::admin::session::current_csrf_token() {
ctx.insert("csrf_input", &crate::forms::csrf::csrf_input_html(&token));
ctx.insert("csrf_token", &token);
}
match state.tera.render(template, &ctx) {
Ok(html) => Html(html).into_response(),
Err(e) => {
let mut detail = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(s) = source {
detail.push_str(": ");
detail.push_str(&s.to_string());
source = s.source();
}
server_error(
"operator_console::render",
&format!("could not render {template}: {detail}"),
)
}
}
}
fn inject_op_brand(ctx: &mut Context, brand: &OpBrand) {
ctx.insert("sso_console", &cfg!(feature = "admin-sso"));
ctx.insert("brand_name", &brand.name);
ctx.insert("brand_tagline", &brand.tagline);
ctx.insert("brand_logo_url", &brand.logo_url);
ctx.insert("theme_mode", &brand.theme_mode);
ctx.insert(
"brand_css",
&branding::build_op_brand_css(brand.primary_color.as_deref()),
);
}
async fn require_session(
State(state): State<ConsoleState>,
headers: HeaderMap,
uri: Uri,
mut req: axum::http::Request<Body>,
next: Next,
) -> Response<Body> {
let payload = crate::cookies::cookie_from_headers(&headers, COOKIE_NAME)
.and_then(|v| session::decode(&state.session_secret, v).ok());
let method = req.method().clone();
let raw_next = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
let safe_next = sanitize_next_for_method(&method, raw_next);
let Some(payload) = payload else {
return redirect_to_login(&safe_next).into_response();
};
match auth::Operator::objects()
.where_(auth::Operator::id.eq(payload.oid))
.fetch(&state.registry)
.await
{
Ok(rows) => {
let Some(op) = rows.into_iter().next().filter(|o| o.active) else {
return redirect_to_login(&safe_next).into_response();
};
if !session::session_survives(
&state.session_secret,
&payload.pwf,
payload.iat,
&op.password_hash,
op.password_changed_at,
op.sessions_revoked_at,
) {
return redirect_to_login(&safe_next).into_response();
}
#[cfg(feature = "template_views")]
{
let mut chrome = Context::new();
inject_op_brand(&mut chrome, &state.op_brand);
chrome.insert("operator_username", &op.username);
chrome.insert("section", &nav_section(uri.path()));
chrome.insert("edit_enabled", &state.pools.is_some());
chrome.insert("provisioning_enabled", &state.provisioner.is_some());
req.extensions_mut()
.insert(crate::template_views::ExtraContext(chrome));
}
req.extensions_mut().insert(op);
next.run(req).await
}
Err(e) => {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e, "registry lookup");
(StatusCode::INTERNAL_SERVER_ERROR, "registry lookup failed").into_response()
}
}
}
async fn org_post_only_redirect(
axum::extract::Path(slug): axum::extract::Path<String>,
) -> Redirect {
Redirect::to(&format!("/orgs/{slug}/edit"))
}
async fn op_post_only_redirect() -> Redirect {
Redirect::to("/operators")
}
async fn orgs_post_only_redirect() -> Redirect {
Redirect::to("/orgs")
}
async fn prewarm_pools(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
) -> Response<Body> {
let Some(pools) = state.pools.as_ref() else {
return orgs_notice("pool management is not available on this console", true);
};
let report = match pools.prewarm().await {
Ok(r) => r,
Err(e) => return orgs_notice(&format!("pre-warm failed: {e}"), true),
};
let mut detail = serde_json::Map::new();
detail.insert("action".into(), serde_json::json!("pools.prewarm"));
detail.insert("warmed".into(), serde_json::json!(report.warmed));
detail.insert("failed".into(), serde_json::json!(report.failed));
emit_registry_audit(
&state.registry,
"rustango_orgs",
"*",
op.id.get().copied().unwrap_or(0),
"prewarm",
detail,
)
.await;
let mut msg = format!(
"warmed {} of {} active database-mode tenant(s)",
report.warmed, report.total_active
);
if report.failed > 0 {
msg.push_str(&format!(" — {} failed, see the logs", report.failed));
}
if report.skipped_cap > 0 {
msg.push_str(&format!(
" — {} skipped, the pool cache is at its cap",
report.skipped_cap
));
}
orgs_notice(&msg, report.failed > 0)
}
fn orgs_notice(msg: &str, is_error: bool) -> Response<Body> {
let key = if is_error { "error" } else { "notice" };
Redirect::to(&format!("/orgs?{key}={}", urlencoding_lite(msg))).into_response()
}
fn sanitize_next_for_method(method: &axum::http::Method, path: &str) -> String {
if method == axum::http::Method::GET || method == axum::http::Method::HEAD {
return path.to_owned();
}
let path_only = path.split('?').next().unwrap_or(path);
if let Some(rest) = path_only.strip_prefix("/orgs/") {
if let Some(slug_end) = rest.find('/') {
let slug = &rest[..slug_end];
return format!("/orgs/{slug}/edit");
}
}
"/".to_owned()
}
fn redirect_to_login(next_path: &str) -> Response<Body> {
let next = if next_path == "/login" || next_path.starts_with("/logout") {
"/".to_string()
} else {
next_path.to_string()
};
let location = format!("/login?next={}", urlencoding_lite(&next));
Redirect::to(&location).into_response()
}
#[derive(Deserialize)]
struct LoginQuery {
#[serde(default)]
next: Option<String>,
#[serde(default)]
error: Option<String>,
}
async fn login_form(
State(state): State<ConsoleState>,
Query(q): Query<LoginQuery>,
) -> Response<Body> {
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("next", &q.next.unwrap_or_else(|| "/".into()));
ctx.insert("error", &q.error);
render(&state, "op_login.html", &ctx)
}
#[derive(Deserialize)]
struct LoginSubmit {
username: String,
password: String,
#[serde(default)]
next: Option<String>,
}
async fn login_submit(
State(state): State<ConsoleState>,
ip: crate::login_throttle::ClientIp,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap,
Form(form): Form<LoginSubmit>,
) -> Response<Body> {
use crate::login_throttle::{LoginRefused, LoginScope};
use crate::signals::auth::{
meta_from_parts, send_user_logged_in, send_user_login_failed, AuthFailureReason,
UserLoggedInContext, UserLoginFailedContext,
};
let meta = meta_from_parts(&extensions, &headers, Some("/login"));
let next = sanitize_next(form.next.as_deref());
let mut attempt = match crate::login_throttle::shared()
.begin(&LoginScope::Operator, &ip, &form.username)
.await
{
Ok(a) => a,
Err(refused) => return refused.into_response(),
};
let found = match auth::find_operator(&state.registry, &form.username).await {
Ok(op) => op,
Err(e) => {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e);
return (StatusCode::INTERNAL_SERVER_ERROR, "login failed").into_response();
}
};
if let Some(op) = &found {
if let Err(refused) = attempt.resolve(&op.username).await {
return refused.into_response();
}
}
let principal =
match auth::check_operator_password(&state.registry, found, &form.password).await {
Ok(Some(op)) => op,
Ok(None) => {
attempt.failed().await;
send_user_login_failed(UserLoginFailedContext {
source: "operator",
attempted_username: Some(form.username.clone()),
reason: AuthFailureReason::InvalidCredentials,
request: meta,
})
.await;
return Redirect::to(&format!(
"/login?error=Invalid+credentials&next={}",
urlencoding_lite(&next)
))
.into_response();
}
Err(super::TenancyError::Busy) => return LoginRefused::Busy.into_response(),
Err(e) => {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e);
return (StatusCode::INTERNAL_SERVER_ERROR, "login failed").into_response();
}
};
let oid = principal.id.get().copied().unwrap_or_default();
attempt.succeeded().await;
let mut payload = SessionPayload::new(
oid,
SESSION_TTL_SECS,
session::PasswordFingerprint::of(&state.session_secret, &principal.password_hash),
);
payload.iat = crate::session::issued_at(principal.sessions_revoked_at);
let cookie_value = session::encode(&state.session_secret, &payload);
let cookie = Cookie::build((COOKIE_NAME, cookie_value))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(crate::session::secure_cookies())
.max_age(CookieDuration::seconds(SESSION_TTL_SECS))
.build();
let mut resp = Redirect::to(&next).into_response();
resp.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&cookie.to_string()).expect("cookie is ascii"),
);
send_user_logged_in(UserLoggedInContext {
source: "operator",
user_id: oid,
username: form.username.clone(),
is_superuser: true,
request: meta,
})
.await;
resp
}
async fn logout(
State(state): State<ConsoleState>,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap,
) -> Response<Body> {
use crate::signals::auth::{meta_from_parts, send_user_logged_out, UserLoggedOutContext};
let oid = decode_operator_session(&headers, &state.session_secret);
let meta = meta_from_parts(&extensions, &headers, Some("/logout"));
let revoked = match live_operator_session(&state, &headers).await {
Ok(Some((op, iat))) => crate::session::revoke_sessions::<auth::Operator>(
&state.registry,
op.id.get().copied().unwrap_or_default(),
op.sessions_revoked_at,
iat,
)
.await
.map(drop),
Ok(None) => Ok(()),
Err(e) => Err(e),
};
if let Err(e) = revoked {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e, "logout revoke");
return (StatusCode::INTERNAL_SERVER_ERROR, "logout failed").into_response();
}
let clear = Cookie::build((COOKIE_NAME, ""))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(crate::session::secure_cookies())
.max_age(CookieDuration::seconds(0))
.build();
let mut resp = Redirect::to("/login").into_response();
resp.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&clear.to_string()).expect("cookie is ascii"),
);
send_user_logged_out(UserLoggedOutContext {
source: "operator",
user_id: oid,
username: None,
request: meta,
})
.await;
resp
}
async fn live_operator_session(
state: &ConsoleState,
headers: &axum::http::HeaderMap,
) -> Result<Option<(auth::Operator, i64)>, crate::sql::ExecError> {
let Some(payload) = crate::cookies::cookie_from_headers(headers, COOKIE_NAME)
.and_then(|val| session::decode(&state.session_secret, val).ok())
else {
return Ok(None);
};
let op = auth::Operator::objects()
.where_(auth::Operator::id.eq(payload.oid))
.fetch(&state.registry)
.await?
.into_iter()
.next();
Ok(op
.filter(|op| {
session::session_survives(
&state.session_secret,
&payload.pwf,
payload.iat,
&op.password_hash,
op.password_changed_at,
op.sessions_revoked_at,
)
})
.map(|op| (op, payload.iat)))
}
fn decode_operator_session(headers: &axum::http::HeaderMap, secret: &SessionSecret) -> Option<i64> {
let val = crate::cookies::cookie_from_headers(headers, COOKIE_NAME)?;
session::decode(secret, val).ok().map(|p| p.oid)
}
async fn change_password_form(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response<Body> {
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "change_password");
ctx.insert("operator_username", &op.username);
ctx.insert("error", ¶ms.get("error"));
ctx.insert("success", ¶ms.get("ok"));
render(&state, "op_change_password.html", &ctx)
}
#[derive(Debug, serde::Deserialize)]
struct OpChangePasswordForm {
current_password: String,
new_password: String,
#[serde(default)]
confirm_password: String,
}
async fn change_password_submit(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
ip: crate::login_throttle::ClientIp,
Form(form): Form<OpChangePasswordForm>,
) -> Response<Body> {
let redir = |query: &str| -> Response<Body> {
Redirect::to(&format!("/change-password?{query}")).into_response()
};
let redir_err = |msg: &str| redir(&format!("error={}", crate::url_codec::url_encode(msg)));
if form.current_password.is_empty() || form.new_password.is_empty() {
return redir_err("All fields are required.");
}
if !form.confirm_password.is_empty() && form.confirm_password != form.new_password {
return redir_err("New password and confirmation did not match.");
}
if form.new_password == form.current_password {
return redir_err("New password must differ from the current password.");
}
if let Err(e) = crate::password_validators::check_builtin_form_password(&form.new_password) {
return redir_err(&e.message);
}
let op_id = op.id.get().copied().unwrap_or(0);
if op_id <= 0 {
return redir_err("Session is missing an operator id; please log in again.");
}
let mut op_row: auth::Operator = match auth::Operator::objects()
.where_(auth::Operator::id.eq(op_id))
.fetch(&state.registry)
.await
{
Ok(rows) => match rows.into_iter().next() {
Some(r) => r,
None => {
return redir_err("Your account no longer exists; please log in again.");
}
},
Err(e) => {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e, "change-password lookup");
return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed").into_response();
}
};
let verify = async {
match super::password::verify_async(&form.current_password, &op_row.password_hash).await {
Ok(ok) => Ok(ok),
Err(super::TenancyError::Busy) => Err(crate::login_throttle::LoginRefused::Busy),
Err(_) => Ok(false),
}
};
let ok = match crate::login_throttle::shared()
.verify_current_password(
&crate::login_throttle::LoginScope::Operator,
&ip,
&op_row.username,
verify,
)
.await
{
Ok(ok) => ok,
Err(refused) => return refused.into_response(),
};
if !ok {
return redir_err("Current password did not match.");
}
let new_hash = match super::password::hash_async(&form.new_password).await {
Ok(h) => h,
Err(super::TenancyError::Busy) => {
return crate::login_throttle::LoginRefused::Busy.into_response()
}
Err(e) => return redir_err(&format!("hash failed: {e}")),
};
op_row.password_hash = new_hash;
op_row.password_changed_at = Some(chrono::Utc::now());
if let Err(e) = op_row.save_pool(&state.registry).await {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e, "change-password update");
return (StatusCode::INTERNAL_SERVER_ERROR, "update failed").into_response();
}
redir("ok=Password+updated")
}
async fn welcome(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
) -> Response<Body> {
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "home");
ctx.insert("operator_username", &op.username);
render(&state, "op_welcome.html", &ctx)
}
async fn orgs_list(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Query(q): Query<ListQuery>,
) -> Response<Body> {
use crate::core::Model as _;
let paged = match Paged::of_model(&state.registry, super::Org::SCHEMA, q.page).await {
Ok(p) => p,
Err(e) => return server_error("operator_console", &e),
};
let rows: Vec<super::Org> = match super::Org::objects()
.order_by(&[("slug", false)])
.limit(paged.limit)
.offset(paged.offset)
.fetch(&state.registry)
.await
{
Ok(r) => r,
Err(e) => return server_error("operator_console", &e),
};
let view: Vec<_> = rows
.into_iter()
.map(|o| {
serde_json::json!({
"slug": o.slug,
"display_name": o.display_name,
"storage_mode": o.storage_mode,
"backend_kind": o.backend_kind,
"host_pattern": o.host_pattern,
"active": o.active,
"created_at": o.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
})
})
.collect();
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "orgs");
ctx.insert("operator_username", &op.username);
ctx.insert("orgs", &view);
ctx.insert("edit_enabled", &state.pools.is_some());
ctx.insert("provisioning_enabled", &state.provisioner.is_some());
ctx.insert("error", &q.error);
ctx.insert("notice", &q.notice);
paged.inject(&mut ctx, "/orgs", "");
render(&state, "op_orgs.html", &ctx)
}
#[cfg(feature = "admin-sso")]
#[derive(serde::Deserialize)]
struct SharedSsoForm {
slug: String,
label: String,
kind: String,
issuer_url: Option<String>,
client_id: String,
client_secret: String,
scopes: Option<String>,
sort_order: Option<i32>,
enabled: Option<String>,
allow_email_link: Option<String>,
}
#[cfg(feature = "admin-sso")]
async fn sso_shared_list(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
) -> Response<Body> {
let mut rows: Vec<super::sso::SharedSsoProvider> =
match super::sso::SharedSsoProvider::objects()
.fetch(&state.registry)
.await
{
Ok(r) => r,
Err(e) => return server_error("operator_console", &e),
};
rows.sort_by_key(|p| p.sort_order);
let view: Vec<_> = rows
.into_iter()
.map(|p| {
serde_json::json!({
"id": p.id.get().copied().unwrap_or_default(),
"slug": p.slug,
"label": p.label,
"kind": p.kind,
"issuer_url": p.issuer_url,
"client_id": p.client_id,
"enabled": p.enabled,
"allow_email_link": p.allow_email_link,
"sort_order": p.sort_order,
})
})
.collect();
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "sso");
ctx.insert("operator_username", &op.username);
ctx.insert("providers", &view);
ctx.insert("edit_enabled", &state.pools.is_some());
render(&state, "op_sso_shared.html", &ctx)
}
#[cfg(feature = "admin-sso")]
async fn sso_shared_create(
State(state): State<ConsoleState>,
Extension(_op): Extension<auth::Operator>,
Form(form): Form<SharedSsoForm>,
) -> Response<Body> {
let mut row = super::sso::SharedSsoProvider {
id: crate::sql::Auto::Unset,
slug: form.slug.trim().to_owned(),
label: form.label,
kind: form.kind.trim().to_owned(),
issuer_url: form.issuer_url.filter(|s| !s.trim().is_empty()),
client_id: form.client_id,
client_secret: crate::casts::Cast::new(form.client_secret),
enabled: form.enabled.as_deref() == Some("on"),
sort_order: form.sort_order.unwrap_or(0),
scopes: form.scopes.filter(|s| !s.trim().is_empty()),
allow_email_link: form.allow_email_link.as_deref() == Some("on"),
created_at: crate::sql::Auto::Unset,
updated_at: crate::sql::Auto::Unset,
};
if let Err(e) = row.insert_pool(&state.registry).await {
return server_error("create failed", &e);
}
Redirect::to("/sso-shared").into_response()
}
#[cfg(feature = "admin-sso")]
#[derive(serde::Deserialize)]
struct EmailLinkForm {
allow_email_link: String,
}
#[cfg(feature = "admin-sso")]
async fn sso_shared_set_email_link(
State(state): State<ConsoleState>,
Extension(_op): Extension<auth::Operator>,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<EmailLinkForm>,
) -> Response<Body> {
use crate::sql::UpdaterPool as _;
let allow = match form.allow_email_link.as_str() {
"on" => true,
"off" => false,
_ => {
return (
StatusCode::BAD_REQUEST,
"allow_email_link must be on or off",
)
.into_response()
}
};
match super::sso::SharedSsoProvider::objects()
.filter("id", id)
.update()
.set("allow_email_link", allow)
.execute_pool(&state.registry)
.await
{
Ok(1) => Redirect::to("/sso-shared").into_response(),
Ok(_) => (StatusCode::NOT_FOUND, "no such shared provider").into_response(),
Err(e) => server_error("update failed", &e),
}
}
#[cfg(feature = "admin-sso")]
async fn sso_shared_delete(
State(state): State<ConsoleState>,
Extension(_op): Extension<auth::Operator>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Response<Body> {
let row = super::sso::SharedSsoProvider::objects()
.filter("id", id)
.fetch(&state.registry)
.await
.ok()
.and_then(|v| v.into_iter().next());
if let Some(r) = row {
let _ = r.delete_pool(&state.registry).await;
}
Redirect::to("/sso-shared").into_response()
}
const LOCKED_ORG_FIELDS: &[&str] = &[
"id",
"slug",
"storage_mode",
"backend_kind",
"schema_name",
"created_at",
"logo_path",
"favicon_path",
];
const DATABASE_URL_FIELD: &str = "database_url";
#[derive(Deserialize, Default)]
struct OrgEditQuery {
#[serde(default)]
error: Option<String>,
#[serde(default)]
notice: Option<String>,
}
async fn org_edit_form(
State(state): State<ConsoleState>,
axum::extract::Path(slug): axum::extract::Path<String>,
Extension(op): Extension<auth::Operator>,
Query(q): Query<OrgEditQuery>,
) -> Response<Body> {
use crate::admin::render;
use crate::core::Model as _;
let rows: Vec<super::Org> = match super::Org::objects()
.where_(super::Org::slug.eq(slug.clone()))
.fetch(&state.registry)
.await
{
Ok(r) => r,
Err(e) => return server_error("operator_console", &e),
};
let Some(org_row) = rows.into_iter().next() else {
return (StatusCode::NOT_FOUND, format!("org `{slug}` not found")).into_response();
};
let row_json = serde_json::to_value(&org_row).unwrap_or_else(|_| serde_json::json!({}));
let mut editable_rows: Vec<serde_json::Value> = Vec::new();
let mut locked_rows: Vec<serde_json::Value> = Vec::new();
for field in super::Org::SCHEMA.scalar_fields() {
let prefill = render::render_value_for_input_json(&row_json, field);
if LOCKED_ORG_FIELDS.contains(&field.name) {
locked_rows.push(serde_json::json!({
"name": field.name,
"value": prefill,
}));
continue;
}
let (prefill_for_input, helper) = if field.name == DATABASE_URL_FIELD {
let hint = if prefill.starts_with("env:") || prefill.starts_with("vault:") {
Some(format!("current: {prefill}"))
} else if !prefill.is_empty() {
Some("current: <literal connection URL — masked>".to_owned())
} else {
None
};
(
String::new(),
Some(format!(
"{} — leave blank to keep current; new value evicts the cached pool",
hint.unwrap_or_else(|| "no value set".to_owned())
)),
)
} else {
(prefill, None::<String>)
};
let input_html = render::render_input(field, &prefill_for_input, false);
editable_rows.push(serde_json::json!({
"name": field.name,
"input": input_html,
"helper": helper,
}));
}
let logo_path: Option<String> = org_row.logo_path.clone();
let favicon_path: Option<String> = org_row.favicon_path.clone();
let logo_url = branding::brand_asset_url(&slug, logo_path.as_deref(), &state.brand_storage);
let favicon_url =
branding::brand_asset_url(&slug, favicon_path.as_deref(), &state.brand_storage);
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "orgs");
ctx.insert("operator_username", &op.username);
ctx.insert("slug", &slug);
ctx.insert("provisioning_enabled", &state.provisioner.is_some());
ctx.insert("editable_rows", &editable_rows);
ctx.insert("locked_rows", &locked_rows);
ctx.insert("logo_url", &logo_url);
ctx.insert("favicon_url", &favicon_url);
ctx.insert("error", &q.error);
ctx.insert("notice", &q.notice);
ctx.insert(
"impersonate_enabled",
&state.tenant_session_secret.is_some(),
);
render(&state, "op_orgs_edit.html", &ctx)
}
async fn org_edit_submit(
State(state): State<ConsoleState>,
axum::extract::Path(slug): axum::extract::Path<String>,
Extension(op): Extension<auth::Operator>,
Form(mut form): Form<std::collections::HashMap<String, String>>,
) -> Response<Body> {
use crate::core::Model as _;
let pools = state
.pools
.as_ref()
.expect("edit routes only mounted when pools is Some");
let database_url_supplied = form
.get(DATABASE_URL_FIELD)
.is_some_and(|s| !s.trim().is_empty());
if !database_url_supplied {
form.remove(DATABASE_URL_FIELD);
}
let mut skip: Vec<&str> = LOCKED_ORG_FIELDS.to_vec();
if !database_url_supplied {
skip.push(DATABASE_URL_FIELD);
}
let collected = match crate::forms::collect_values(super::Org::SCHEMA, &form, &skip) {
Ok(v) => v,
Err(e) => return redirect_with_error(&slug, &e.to_string()),
};
if collected.is_empty() {
return redirect_with_error(&slug, "no editable fields supplied");
}
let applied =
match crate::tenancy::org_edit::apply_values(&state.registry, &slug, collected).await {
Ok(a) => a,
Err(e) => return redirect_with_error(&slug, &e.to_string()),
};
let database_url_changed = applied.database_url_rotated;
if database_url_changed {
pools.invalidate(&slug).await;
}
let operator_id = op.id.get().copied().unwrap_or(0);
let mut detail = serde_json::Map::new();
detail.insert(
"action".into(),
serde_json::Value::String("org.edit".into()),
);
let touched_cols: Vec<&str> = applied
.touched
.iter()
.copied()
.filter(|c| *c != DATABASE_URL_FIELD)
.collect();
detail.insert("fields".into(), serde_json::json!(touched_cols));
if database_url_changed {
detail.insert("database_url_rotated".into(), serde_json::json!(true));
}
emit_op_audit(&state.registry, &slug, operator_id, "edit", detail).await;
let notice = if database_url_changed {
format!(
"updated `{slug}` (this server switched now; others within {} s)",
crate::tenancy::resolver::CACHE_TTL.as_secs()
)
} else {
format!("updated `{slug}`")
};
Redirect::to(&format!(
"/orgs/{}/edit?notice={}",
urlencoding_lite(&slug),
urlencoding_lite(¬ice),
))
.into_response()
}
fn redirect_with_error(slug: &str, msg: &str) -> Response<Body> {
Redirect::to(&format!(
"/orgs/{}/edit?error={}",
urlencoding_lite(slug),
urlencoding_lite(msg),
))
.into_response()
}
async fn emit_op_audit(
registry: &crate::sql::Pool,
slug: &str,
operator_id: i64,
verb: &str,
extra: serde_json::Map<String, serde_json::Value>,
) {
let mut changes = extra;
changes.insert(
"tenant_slug".into(),
serde_json::Value::String(slug.to_owned()),
);
emit_registry_audit(registry, "rustango_orgs", slug, operator_id, verb, changes).await;
}
async fn emit_registry_audit(
registry: &crate::sql::Pool,
entity_table: &'static str,
entity_pk: &str,
operator_id: i64,
verb: &str,
extra: serde_json::Map<String, serde_json::Value>,
) {
let mut changes = extra;
changes.insert("operator_id".into(), serde_json::json!(operator_id));
let fields: Vec<&str> = changes
.keys()
.filter(|k| k.as_str() != "operator_id")
.map(String::as_str)
.collect();
tracing::info!(
target: ACTION_TARGET,
event = "operator_action",
action = verb,
operator_id,
entity = entity_table,
entity_id = entity_pk,
fields = fields.join(","),
"operator action",
);
let entry = crate::audit::PendingEntry {
entity_table,
entity_pk: entity_pk.to_owned(),
operation: crate::audit::AuditOp::Action,
source: crate::audit::AuditSource::Custom(format!("operator:{operator_id}:{verb}")),
changes: serde_json::Value::Object(changes),
};
if let Err(e) = crate::audit::emit_one_pool(registry, &entry).await {
tracing::error!(
target: ACTION_TARGET,
event = "operator_action_unrecorded",
action = verb,
operator_id,
entity = entity_table,
entity_id = entity_pk,
error = %e,
"operator action was NOT written to the audit log",
);
}
}
async fn org_edit_branding(
State(state): State<ConsoleState>,
axum::extract::Path(slug): axum::extract::Path<String>,
Extension(op): Extension<auth::Operator>,
mut mp: Multipart,
) -> Response<Body> {
let mut updates: Vec<(BrandAssetKind, &'static str, String)> = Vec::new();
while let Ok(Some(field)) = mp.next_field().await {
let name = field.name().map(str::to_owned);
let kind = match name.as_deref() {
Some("logo") => BrandAssetKind::Logo,
Some("favicon") => BrandAssetKind::Favicon,
_ => continue,
};
let content_type = field.content_type().map(str::to_owned);
let bytes = match field.bytes().await {
Ok(b) if b.is_empty() => continue,
Ok(b) => b.to_vec(),
Err(e) => return redirect_with_error(&slug, &format!("multipart: {e}")),
};
match branding::save_brand_asset(
&slug,
kind,
&bytes,
content_type.as_deref(),
&state.brand_storage,
)
.await
{
Ok(filename) => {
let column = match kind {
BrandAssetKind::Logo => "logo_path",
BrandAssetKind::Favicon => "favicon_path",
};
updates.push((kind, column, filename));
}
Err(branding::BrandError::TooLarge { actual, max }) => {
return redirect_with_error(
&slug,
&format!("file too large: {actual} bytes (max {max})"),
);
}
Err(branding::BrandError::UnsupportedContentType(ct)) => {
return redirect_with_error(
&slug,
&format!("unsupported file type `{ct}` — use PNG/JPEG/WebP/ICO"),
);
}
Err(e) => return redirect_with_error(&slug, &format!("upload failed: {e}")),
}
}
if updates.is_empty() {
return redirect_with_error(&slug, "no file chosen");
}
use crate::core::Model as _;
let assignments: Vec<crate::core::Assignment> = updates
.iter()
.map(|(_, col, v)| crate::core::Assignment {
column: *col,
value: crate::core::SqlValue::String(v.clone()).into(),
})
.collect();
let update_q = crate::core::UpdateQuery {
model: super::Org::SCHEMA,
set: assignments,
where_clause: crate::core::WhereExpr::and_predicates(vec![crate::core::Filter {
column: "slug",
op: crate::core::Op::Eq,
value: crate::core::SqlValue::String(slug.clone()),
}]),
};
if let Err(e) = crate::sql::update_pool(&state.registry, &update_q).await {
return redirect_with_error(&slug, &format!("update failed: {e}"));
}
for (kind, _, kept) in &updates {
if let Err(e) = branding::prune_brand_asset(&slug, *kind, kept, &state.brand_storage).await
{
tracing::warn!(slug = %slug, error = %e, "stale brand file not deleted");
}
}
super::invalidate_org_cache();
let operator_id = op.id.get().copied().unwrap_or(0);
let assets: Vec<String> = updates
.iter()
.map(|(_, col, _)| match *col {
"logo_path" => "logo".to_owned(),
"favicon_path" => "favicon".to_owned(),
other => other.to_owned(),
})
.collect();
let mut detail = serde_json::Map::new();
detail.insert(
"action".into(),
serde_json::Value::String("org.branding.upload".into()),
);
detail.insert("assets".into(), serde_json::json!(assets));
emit_op_audit(&state.registry, &slug, operator_id, "branding", detail).await;
let notice = format!("uploaded {} brand asset(s) for `{slug}`", updates.len());
Redirect::to(&format!(
"/orgs/{}/edit?notice={}",
urlencoding_lite(&slug),
urlencoding_lite(¬ice),
))
.into_response()
}
async fn serve_brand_asset(
State(state): State<ConsoleState>,
axum::extract::Path((slug, filename)): axum::extract::Path<(String, String)>,
) -> Response<Body> {
match branding::load_brand_asset(&slug, &filename, &state.brand_storage).await {
Ok((bytes, ct)) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, ct)
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(Body::from(bytes))
.expect("response builds"),
Err(
branding::BrandError::NotFound
| branding::BrandError::InvalidSlug
| branding::BrandError::InvalidFilename,
) => (StatusCode::NOT_FOUND, "not found").into_response(),
Err(e) => server_error("operator_console::brand_asset", &e),
}
}
async fn static_rustango_png() -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/png")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(RUSTANGO_PNG))
.expect("response builds")
}
use crate::url_codec::url_encode as urlencoding_lite;
fn sanitize_next(next: Option<&str>) -> String {
next.and_then(crate::auth_decorators::safe_next)
.unwrap_or_else(|| "/".to_owned())
}
async fn org_impersonate(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
headers: HeaderMap,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> Response<Body> {
let Some(tenant_secret) = state.tenant_session_secret.clone() else {
return (StatusCode::SERVICE_UNAVAILABLE, "impersonation disabled").into_response();
};
let orgs: Vec<super::Org> = match super::Org::objects()
.where_(super::Org::slug.eq(slug.clone()))
.fetch(&state.registry)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(target: "rustango::tenancy::operator_console", error = %e, "org lookup failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "registry lookup failed").into_response();
}
};
let Some(org) = orgs.into_iter().next() else {
return (StatusCode::NOT_FOUND, format!("no tenant `{slug}`")).into_response();
};
if !org.active {
return (
StatusCode::CONFLICT,
format!("tenant `{slug}` is inactive — refusing impersonation"),
)
.into_response();
}
let operator_id = op.id.get().copied().unwrap_or(0);
use super::impersonation_handoff as handoff;
let mut payload = handoff::HandoffPayload::new(
operator_id,
slug.clone(),
handoff::HANDOFF_TTL_SECS,
handoff::PasswordFingerprint::of(&tenant_secret, &op.password_hash),
);
payload.iat = crate::session::issued_at(op.sessions_revoked_at);
let token = handoff::mint(&tenant_secret, &payload);
let mut detail = serde_json::Map::new();
detail.insert(
"action".into(),
serde_json::Value::String("impersonate.start".into()),
);
emit_op_audit(&state.registry, &slug, operator_id, "impersonating", detail).await;
let scheme = std::env::var("RUSTANGO_TENANT_SCHEME").unwrap_or_else(|_| "http".into());
let prefix = handoff_prefix(org.path_prefix.as_deref());
let host = if let Some(pat) = org.host_pattern.as_deref().filter(|s| !s.is_empty()) {
pat.to_owned()
} else {
let apex = std::env::var("RUSTANGO_APEX_DOMAIN").unwrap_or_else(|_| "localhost".into());
if prefix.is_empty() {
format!("{}.{}", slug, apex)
} else {
apex
}
};
let port_suffix = handoff_port_suffix(
org.port,
std::env::var("RUSTANGO_TENANT_PORT").ok(),
headers.get(header::HOST).and_then(|v| v.to_str().ok()),
);
let handoff_path = format!("{prefix}{}", state.tenant_handoff_url.trim_end_matches('/'));
let handoff = format!("{scheme}://{host}{port_suffix}{handoff_path}");
let redirect_to = format!("{handoff}?token={token}");
let mut resp = Redirect::to(&redirect_to).into_response();
resp.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
tracing::info!(
target: "rustango::tenancy::operator_console",
slug = %slug,
operator_id,
ttl_secs = handoff::HANDOFF_TTL_SECS,
handoff = %handoff,
"minted impersonation handoff token",
);
resp
}
fn handoff_prefix(path_prefix: Option<&str>) -> &str {
path_prefix
.map(|p| p.trim_end_matches('/'))
.filter(|p| crate::tenancy::provision::validate_path_prefix(p).is_ok())
.unwrap_or("")
}
fn handoff_port_suffix(
org_port: Option<i32>,
env_port: Option<String>,
host: Option<&str>,
) -> String {
let usable = |p: &str| !p.is_empty() && p != "80" && p != "443";
if let Some(p) = org_port {
let p = p.to_string();
return if usable(&p) {
format!(":{p}")
} else {
String::new()
};
}
env_port
.filter(|p| usable(p))
.or_else(|| {
host.and_then(|h| h.rsplit_once(':'))
.map(|(_, p)| p.to_owned())
.filter(|p| usable(p))
})
.map(|p| format!(":{p}"))
.unwrap_or_default()
}
#[cfg(feature = "config")]
fn warn_config_error(e: &crate::config::ConfigError) {
tracing::warn!(
target: "rustango::tenancy::operator_console",
at = %e.location(),
"the config does not load; console branding uses defaults"
);
}
#[cfg(all(test, feature = "config", feature = "runtime"))]
mod config_warn_tests {
#[test]
fn a_broken_config_logs_its_line_not_its_text() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("config")).unwrap();
std::fs::write(
dir.path().join("config/default.toml"),
"[database]\nurl = \"postgres://app:s3cret@db/app\n",
)
.unwrap();
let err = crate::config::Settings::load_from(&dir.path().join("config"), "dev")
.expect_err("unterminated string");
let out = crate::testkit::CaptureWriter::default();
let sub = tracing_subscriber::fmt()
.with_writer(out.clone())
.with_ansi(false)
.finish();
tracing::subscriber::with_default(sub, || super::warn_config_error(&err));
let logged = out.contents();
assert!(!logged.contains("s3cret"), "{logged}");
assert!(logged.contains("line 2"), "{logged}");
}
}
#[cfg(test)]
mod handoff_port_tests {
use super::{handoff_port_suffix, handoff_prefix};
#[test]
fn only_a_valid_path_prefix_reaches_the_handoff_url() {
assert_eq!(handoff_prefix(Some("/acme/")), "/acme");
assert_eq!(handoff_prefix(None), "");
for bad in ["//evil.example", "/a/b", "acme", "/a?b", "/@evil"] {
assert_eq!(handoff_prefix(Some(bad)), "", "`{bad}`");
}
}
#[test]
fn a_port_routed_org_lands_on_its_own_port() {
let env = Some("9000".to_owned());
assert_eq!(
handoff_port_suffix(Some(8443), env.clone(), Some("ops:8080")),
":8443"
);
assert_eq!(handoff_port_suffix(None, env, Some("ops:8080")), ":9000");
assert_eq!(handoff_port_suffix(None, None, Some("ops:8080")), ":8080");
assert_eq!(handoff_port_suffix(Some(443), None, Some("ops:8080")), "");
}
}
#[cfg(test)]
mod sanitize_next_tests {
use super::sanitize_next;
#[test]
fn a_backslash_the_browser_rewrites_is_refused() {
for hostile in [
"/\\evil.example/x",
"/\\\\evil.example/x",
"\\/evil.example/x",
"/%5Cevil.example/x",
] {
assert_eq!(
sanitize_next(Some(hostile)),
"/",
"`{hostile}` must not reach Location",
);
}
}
#[test]
fn the_classic_shapes_are_still_refused() {
for hostile in [
"//evil.example/x",
"https://evil.example",
"javascript:1",
"/\x09/evil.example/x",
"/\x0d/evil.example/x",
"/\x0a/evil.example/x",
] {
assert_eq!(sanitize_next(Some(hostile)), "/", "{hostile}");
}
assert_eq!(sanitize_next(None), "/");
}
#[test]
fn an_ordinary_path_still_survives() {
for ok in ["/orgs", "/orgs/acme/edit", "/orgs?page=2&q=a"] {
assert_eq!(sanitize_next(Some(ok)), ok, "{ok} should survive");
}
}
}
#[cfg(test)]
mod redirect_to_login_tests {
use super::redirect_to_login;
#[test]
fn next_round_trips_through_the_location() {
let resp = redirect_to_login("/orgs/acme edit?tab=1&x=2");
let loc = resp.headers()[axum::http::header::LOCATION]
.to_str()
.unwrap();
assert_eq!(loc, "/login?next=%2Forgs%2Facme%20edit%3Ftab%3D1%26x%3D2");
let next = loc.strip_prefix("/login?next=").unwrap();
assert_eq!(
crate::url_codec::url_decode(next),
"/orgs/acme edit?tab=1&x=2"
);
}
}
#[cfg(test)]
mod sanitize_next_method_tests {
use super::sanitize_next_for_method;
use axum::http::Method;
#[test]
fn get_passes_through_unchanged() {
assert_eq!(
sanitize_next_for_method(&Method::GET, "/orgs/acme/edit"),
"/orgs/acme/edit"
);
assert_eq!(
sanitize_next_for_method(&Method::HEAD, "/anywhere"),
"/anywhere"
);
}
#[test]
fn post_to_branding_rewrites_to_parent_edit() {
assert_eq!(
sanitize_next_for_method(&Method::POST, "/orgs/acme/edit/branding"),
"/orgs/acme/edit"
);
}
#[test]
fn post_to_impersonate_rewrites_to_parent_edit() {
assert_eq!(
sanitize_next_for_method(&Method::POST, "/orgs/acme/impersonate"),
"/orgs/acme/edit"
);
}
#[test]
fn post_to_edit_rewrites_to_get_edit_form() {
assert_eq!(
sanitize_next_for_method(&Method::POST, "/orgs/acme/edit"),
"/orgs/acme/edit"
);
}
#[test]
fn unknown_post_path_falls_back_to_root() {
assert_eq!(
sanitize_next_for_method(&Method::POST, "/some/random/post"),
"/"
);
}
#[test]
fn query_string_dropped_from_rewrite_target() {
assert_eq!(
sanitize_next_for_method(&Method::POST, "/orgs/acme/impersonate?return=foo"),
"/orgs/acme/edit"
);
}
}
#[cfg(test)]
mod opbrand_tests {
use super::OpBrand;
#[test]
fn defaults_match_documented_values() {
let b = OpBrand::defaults();
assert_eq!(b.name, "Rustango");
assert_eq!(b.theme_mode, "auto");
assert!(b.tagline.is_none());
assert!(b.primary_color.is_none());
assert_eq!(b.logo_url, "/__static__/rustango.png");
}
#[cfg(feature = "config")]
#[test]
fn apply_brand_settings_overrides_defaults() {
let mut b = OpBrand::defaults();
let mut s = crate::config::BrandSettings::default();
s.name = Some("Acme Operator".into());
s.tagline = Some("(prod)".into());
s.primary_color = Some("#ff8800".into());
s.theme_mode = Some("dark".into());
OpBrand::apply_brand_settings(&mut b, &s);
assert_eq!(b.name, "Acme Operator");
assert_eq!(b.tagline.as_deref(), Some("(prod)"));
assert_eq!(b.primary_color.as_deref(), Some("#ff8800"));
assert_eq!(b.theme_mode, "dark");
}
#[cfg(feature = "config")]
#[test]
fn apply_brand_settings_empty_strings_skip() {
let mut b = OpBrand::defaults();
let original = b.name.clone();
let mut s = crate::config::BrandSettings::default();
s.name = Some(String::new());
OpBrand::apply_brand_settings(&mut b, &s);
assert_eq!(b.name, original);
}
#[cfg(feature = "config")]
#[test]
fn apply_brand_settings_rejects_bad_hex() {
let mut b = OpBrand::defaults();
let mut s = crate::config::BrandSettings::default();
s.primary_color = Some("not-a-color".into());
OpBrand::apply_brand_settings(&mut b, &s);
assert!(b.primary_color.is_none());
}
#[cfg(feature = "config")]
#[test]
fn apply_brand_settings_rejects_bad_theme_mode() {
let mut b = OpBrand::defaults();
let original = b.theme_mode.clone();
let mut s = crate::config::BrandSettings::default();
s.theme_mode = Some("midnight".into());
OpBrand::apply_brand_settings(&mut b, &s);
assert_eq!(b.theme_mode, original);
}
}