use std::sync::Arc;
use crate::auth::{self, Identity, Role};
use crate::error::{Error, Result};
use crate::http::{Request, Response};
use crate::orm::Db;
use crate::router::Router;
use crate::templates::Templates;
const ADMIN_CSS: &str = include_str!("../../assets/static/admin.css");
const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
const FONT_NOTO_NASKH_AR: &[u8] =
include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
use super::handlers::{self, AdminCtx};
use super::render;
use super::types::Admin;
enum Guard {
Allow(Identity),
Redirect(Response),
}
const MUST_CHANGE_WHITELIST: &[&str] = &[
"/admin/must-change-password",
"/admin/logout",
"/admin/account/sessions",
];
fn is_must_change_whitelisted_path(path: &str) -> bool {
MUST_CHANGE_WHITELIST.contains(&path)
}
const MFA_ENROLL_WHITELIST: &[&str] = &[
"/admin/account/mfa/enroll",
"/admin/logout",
"/admin/account/sessions",
];
fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
MFA_ENROLL_WHITELIST.contains(&path)
}
const MFA_VERIFY_WHITELIST: &[&str] = &[
"/admin/mfa/verify",
"/admin/logout",
"/admin/account/sessions",
];
fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
MFA_VERIFY_WHITELIST.contains(&path)
}
fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
use crate::auth::MfaPolicy;
match policy {
MfaPolicy::Disabled | MfaPolicy::Optional => false,
MfaPolicy::Required => true,
MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
}
}
async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
let cookie = match req.header("cookie") {
Some(c) => c,
None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
};
let token = match auth::session_token_from_cookie(cookie) {
Some(t) => t,
None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
};
let ident = match auth::identity_from_session(&ctx.db, &token).await? {
Some(i) => i,
None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
};
if !ident.is_active {
return Ok(Guard::Redirect(Response::redirect("/admin/login")));
}
if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
return Ok(Guard::Redirect(Response::redirect(
"/admin/must-change-password",
)));
}
let policy = ctx.admin.active_mfa_policy();
if mfa_required_for_role(policy, ident.role)
&& !ident.mfa_enabled
&& !is_mfa_enroll_whitelisted_path(req.path())
{
return Ok(Guard::Redirect(Response::redirect(
"/admin/account/mfa/enroll",
)));
}
use crate::auth::SessionTrust;
if ident.mfa_enabled
&& ident.trust_level != SessionTrust::MfaVerified
&& !is_mfa_verify_whitelisted_path(req.path())
{
return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
}
Ok(Guard::Allow(ident))
}
async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
match login_guard(ctx, req).await? {
Guard::Redirect(r) => Ok(Guard::Redirect(r)),
Guard::Allow(ident) => {
if ident.role.includes(min) {
Ok(Guard::Allow(ident))
} else {
let body = render::render_forbidden_body(
&ctx.admin,
&ctx.templates,
&ident,
handlers::csrf_token(req),
None,
Some(min.label()),
)?;
Ok(Guard::Redirect(
Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
))
}
}
}
}
async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
match role_guard(ctx, req, Role::Staff).await? {
Guard::Redirect(r) => Ok(Guard::Redirect(r)),
Guard::Allow(ident) => {
if ident.role.bypasses_group_checks() {
return Ok(Guard::Allow(ident));
}
if auth::check_permission(&ctx.db, &ident, perm).await? {
Ok(Guard::Allow(ident))
} else {
let body = render::render_forbidden_body(
&ctx.admin,
&ctx.templates,
&ident,
handlers::csrf_token(req),
Some(perm.to_string()),
None,
)?;
Ok(Guard::Redirect(
Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
))
}
}
}
}
#[cfg(test)]
fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
if !ident.is_active {
return false;
}
if ident.role.bypasses_group_checks() {
return true;
}
perm_held
}
fn parse_id(raw: Option<&str>) -> Result<i64> {
raw.and_then(|s| s.parse().ok())
.ok_or_else(|| Error::BadRequest("invalid id".into()))
}
fn model_name_from_req(req: &Request) -> Result<String> {
req.param("admin_name")
.map(|s| s.to_string())
.ok_or_else(|| Error::BadRequest("missing model".into()))
}
fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
let entry = ctx
.admin
.find(admin_name)
.ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
let singular = entry.singular_name.to_ascii_lowercase();
Ok(format!("{admin_name}.{action}_{singular}"))
}
fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
Err(
"rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
was registered via Admin::mailer(...).\n\n\
The framework's default LogMailer writes recovery emails to log::info! instead \
of sending them, which is unsuitable for production. Recovery routes are NOT \
registered with this configuration.\n\n\
To resolve, choose one:\n\
(a) register a real mailer before calling register_admin_routes:\n\
Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
(b) opt the policy out of strict mode (the framework default — dev / CI / \
testing baseline):\n\
RecoveryPolicy::strict_mailer_required(false)\n\n\
See DESIGN_RECOVERY.md §12.1 for the contract."
.to_string(),
)
} else {
Ok(())
}
}
pub fn register_admin_routes(
router: Router,
admin: Admin,
db: Db,
templates: Arc<Templates>,
) -> Router {
if let Err(msg) = strict_mailer_guard_check(&admin) {
panic!("{msg}");
}
let ctx = Arc::new(AdminCtx::new(
Arc::new(admin),
db.clone(),
templates.clone(),
));
let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
admin: ctx.admin.clone(),
db,
templates,
});
let err_admin = ctx.admin.clone();
let err_templates = ctx.templates.clone();
let router = router.middleware(move |req, next| {
let admin = err_admin.clone();
let templates = err_templates.clone();
Box::pin(async move {
let is_admin_path = req.path().starts_with("/admin");
let result = next.run(req).await;
match result {
Ok(resp) => Ok(resp),
Err(err) if is_admin_path => Ok(render::render_admin_error_response(
&admin,
&templates,
None,
err.status(),
err.client_message().to_string(),
)),
Err(err) => Err(err),
}
})
});
let router = router.get("/static/admin.css", |_req| async move {
Ok(Response::new(
hyper::StatusCode::OK,
bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
)
.with_header("content-type", "text/css; charset=utf-8")
.with_header("cache-control", "no-cache, must-revalidate"))
});
let router = router.get("/static/admin.js", |_req| async move {
Ok(Response::new(
hyper::StatusCode::OK,
bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
)
.with_header("content-type", "application/javascript; charset=utf-8")
.with_header("cache-control", "no-cache, must-revalidate"))
});
fn font_response(bytes: &'static [u8]) -> Response {
Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
.with_header("content-type", "font/woff2")
.with_header("cache-control", "public, max-age=31536000, immutable")
}
let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
Ok(font_response(FONT_GEIST))
});
let router = router.get(
"/static/fonts/GeistMono-Variable.woff2",
|_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
);
let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
Ok(font_response(FONT_TAJAWAL_REG))
});
let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
Ok(font_response(FONT_TAJAWAL_MED))
});
let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
Ok(font_response(FONT_TAJAWAL_BOLD))
});
let router = router.get(
"/static/fonts/NotoNaskhArabic-Variable.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
);
let c = ctx.clone();
let router = router.get("/admin/login", move |req| {
let c = c.clone();
async move { handlers::show_login(&c, req).await }
});
let c = ctx.clone();
let router = router.post("/admin/login", move |req| {
let c = c.clone();
async move { handlers::do_login(&c, req).await }
});
let c = ctx.clone();
let router = router.post("/admin/logout", move |req| {
let c = c.clone();
async move { handlers::do_logout(&c, req).await }
});
let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
&ctx.admin,
));
let c = ctx.clone();
let router = router.get("/admin/forgot-password", move |req| {
let c = c.clone();
async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
});
let c = ctx.clone();
let rs = recovery_state.clone();
let router = router.post("/admin/forgot-password", move |req| {
let c = c.clone();
let rs = rs.clone();
async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
});
let c = ctx.clone();
let router = router.get("/admin/forgot-password/sent", move |req| {
let c = c.clone();
async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
});
let c = ctx.clone();
let router = router.get("/admin/reset-password/:token", move |req| {
let c = c.clone();
async move {
let token = req
.param("token")
.ok_or_else(|| Error::BadRequest("missing token".into()))?
.to_string();
super::recovery_handlers::show_reset_password(&c, &req, &token).await
}
});
let c = ctx.clone();
let rs = recovery_state.clone();
let router = router.post("/admin/reset-password/:token", move |req| {
let c = c.clone();
let rs = rs.clone();
async move {
let token = req
.param("token")
.ok_or_else(|| Error::BadRequest("missing token".into()))?
.to_string();
super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
}
});
let c = ctx.clone();
let router = router.get("/admin", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Staff).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/history", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/account/sessions", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/sessions/revoke-others", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/sessions/revoke-all", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::do_revoke_session(&c, ident, req, id).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/password_change", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/password_change", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/reauth", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/reauth", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::admin_recovery_handlers::do_reauth(&c, ident, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/must-change-password", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/must-change-password", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/mfa/verify", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/mfa/verify", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/account/mfa/enroll", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/mfa/enroll", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/account/mfa/disable", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/account/mfa/disable", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::User).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/users", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/users/new", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/users/new", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/users/:id/edit", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/users/:id/edit", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::do_user_edit(&ac, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/users/:id/delete", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
.await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/users/:id/delete", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::do_user_delete(&ac, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/users/:id/reset-password", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
.await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/users/:id/reset-password", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
.await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/users/:id/lock", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/users/:id/lock", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/users/:id/unlock", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/users/:id/unlock", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
.await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
.await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/users/:id", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
let q = req.query();
let tab = q.get("tab").map(|s| s.to_string());
let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
super::builtin::show_user_view(
&ac,
ident,
id,
handlers::csrf_token(&req),
tab,
page,
)
.await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/groups", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/groups/new", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/groups/new", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/groups/:id/edit", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
.await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/groups/:id/edit", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::do_group_edit(&ac, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.get("/admin/groups/:id/delete", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
.await
}
}
}
});
let c = ctx.clone();
let ac = auth_ctx.clone();
let router = router.post("/admin/groups/:id/delete", move |req| {
let c = c.clone();
let ac = ac.clone();
async move {
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
super::builtin::do_group_delete(&ac, ident, id, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/:admin_name", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "view")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/:admin_name/new", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "add")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/new", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "add")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/:admin_name/:id/edit", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "change")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::show_edit_form(&c, ident, &name, id, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/:id/edit", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "change")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::do_update(&c, ident, &name, id, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/:admin_name/:id/history", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "view")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::show_object_history(&c, ident, &name, id, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/:admin_name/:id/delete", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "delete")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::show_delete_confirm(&c, ident, &name, id, &req).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/:id/delete", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "delete")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let id = parse_id(req.param("id"))?;
handlers::do_delete(&c, ident, &name, id).await
}
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let perm = perm_for(&c, &name, "delete")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
}
}
});
let c = ctx.clone();
router.post("/admin/:admin_name/bulk/:action", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let action = req
.param("action")
.ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
.to_string();
let perm = perm_for(&c, &name, "change")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_identity(role: Role, is_active: bool) -> Identity {
Identity {
user_id: 42,
email: "test@example.com".into(),
role,
is_active,
is_demo: false,
demo_label: None,
must_change_password: false,
mfa_enabled: false,
trust_level: crate::auth::SessionTrust::Authenticated,
}
}
#[test]
fn role_guard_decision_admin_meets_staff_floor() {
let id = make_identity(Role::Administrator, true);
assert!(id.role.includes(Role::Staff));
}
#[test]
fn role_guard_decision_user_does_not_meet_staff() {
let id = make_identity(Role::User, true);
assert!(!id.role.includes(Role::Staff));
}
#[test]
fn role_guard_decision_administrator_does_not_meet_developer() {
let id = make_identity(Role::Administrator, true);
assert!(!id.role.includes(Role::Developer));
}
#[test]
fn role_guard_decision_developer_meets_everything() {
let id = make_identity(Role::Developer, true);
for &min in &[
Role::User,
Role::Staff,
Role::Supervisor,
Role::Administrator,
Role::Developer,
] {
assert!(id.role.includes(min), "Developer should meet {min:?}");
}
}
#[test]
fn perm_guard_admin_short_circuits_without_perm() {
let id = make_identity(Role::Administrator, true);
assert!(perm_guard_verdict(&id, false));
}
#[test]
fn perm_guard_developer_short_circuits_without_perm() {
let id = make_identity(Role::Developer, true);
assert!(perm_guard_verdict(&id, false));
}
#[test]
fn perm_guard_staff_with_perm_passes() {
let id = make_identity(Role::Staff, true);
assert!(perm_guard_verdict(&id, true));
}
#[test]
fn perm_guard_staff_without_perm_denies() {
let id = make_identity(Role::Staff, true);
assert!(!perm_guard_verdict(&id, false));
}
#[test]
fn perm_guard_inactive_admin_denies_even_with_bypass() {
let id = make_identity(Role::Administrator, false);
assert!(!perm_guard_verdict(&id, true));
}
#[test]
fn perm_guard_supervisor_without_perm_denies() {
let id = make_identity(Role::Supervisor, true);
assert!(!perm_guard_verdict(&id, false));
}
#[test]
fn strict_mailer_guard_passes_for_default_admin() {
let admin = super::super::types::Admin::new();
assert!(strict_mailer_guard_check(&admin).is_ok());
}
#[test]
fn strict_mailer_guard_fails_when_required_but_default_mailer() {
use crate::auth::DefaultRecoveryPolicy;
let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
));
let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
assert!(
err.contains("strict_mailer_required"),
"error message must name the policy method: {err}"
);
assert!(
err.contains("Admin::mailer"),
"error message must direct the operator to the fix: {err}"
);
}
#[test]
fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
use crate::auth::DefaultRecoveryPolicy;
use crate::email::LogMailer;
let admin = super::super::types::Admin::new()
.recovery_policy(std::sync::Arc::new(
DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
))
.mailer(std::sync::Arc::new(LogMailer));
assert!(strict_mailer_guard_check(&admin).is_ok());
}
#[test]
fn strict_mailer_guard_passes_when_strict_mode_disabled() {
let admin = super::super::types::Admin::new();
assert!(strict_mailer_guard_check(&admin).is_ok());
}
#[test]
fn whitelist_accepts_the_three_locked_paths() {
assert!(super::is_must_change_whitelisted_path(
"/admin/must-change-password"
));
assert!(super::is_must_change_whitelisted_path("/admin/logout"));
assert!(super::is_must_change_whitelisted_path(
"/admin/account/sessions"
));
}
#[test]
fn whitelist_rejects_subpaths_of_account_sessions() {
assert!(!super::is_must_change_whitelisted_path(
"/admin/account/sessions/revoke"
));
assert!(!super::is_must_change_whitelisted_path(
"/admin/account/sessions/revoke-others"
));
assert!(!super::is_must_change_whitelisted_path(
"/admin/account/sessions/"
));
}
#[test]
fn whitelist_rejects_other_admin_paths() {
for path in [
"/admin",
"/admin/",
"/admin/users",
"/admin/users/42",
"/admin/login",
"/admin/password_change",
"/admin/forgot-password",
"/admin/reauth",
"/admin/must-change-password/", ] {
assert!(
!super::is_must_change_whitelisted_path(path),
"expected reject for {path:?}"
);
}
}
#[test]
fn whitelist_rejects_paths_outside_admin_surface() {
for path in ["/", "/login", "/static/admin.css", "/api"] {
assert!(
!super::is_must_change_whitelisted_path(path),
"expected reject for {path:?}"
);
}
}
}