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 = concat!(
include_str!("../../assets/static/admin/tokens/colors.css"),
"\n",
include_str!("../../assets/static/admin/tokens/spacing.css"),
"\n",
include_str!("../../assets/static/admin/tokens/radius.css"),
"\n",
include_str!("../../assets/static/admin/tokens/shadows.css"),
"\n",
include_str!("../../assets/static/admin/tokens/typography.css"),
"\n",
include_str!("../../assets/static/admin/base/reset.css"),
"\n",
include_str!("../../assets/static/admin/base/base.css"),
"\n",
include_str!("../../assets/static/admin/base/typography.css"),
"\n",
include_str!("../../assets/static/admin/base/typography-i18n.css"),
"\n",
include_str!("../../assets/static/admin/base/utilities.css"),
"\n",
include_str!("../../assets/static/admin/layout/shell.css"),
"\n",
include_str!("../../assets/static/admin/layout/topbar.css"),
"\n",
include_str!("../../assets/static/admin/layout/sidebar.css"),
"\n",
include_str!("../../assets/static/admin/layout/footer.css"),
"\n",
include_str!("../../assets/static/admin/components/cards.css"),
"\n",
include_str!("../../assets/static/admin/components/buttons.css"),
"\n",
include_str!("../../assets/static/admin/components/forms.css"),
"\n",
include_str!("../../assets/static/admin/components/tables.css"),
"\n",
include_str!("../../assets/static/admin/components/filters.css"),
"\n",
include_str!("../../assets/static/admin/components/dropdowns.css"),
"\n",
include_str!("../../assets/static/admin/components/search_palette.css"),
"\n",
include_str!("../../assets/static/admin/components/pagination.css"),
"\n",
include_str!("../../assets/static/admin/components/pills.css"),
"\n",
include_str!("../../assets/static/admin/components/flashes.css"),
"\n",
include_str!("../../assets/static/admin/components/timeline.css"),
"\n",
include_str!("../../assets/static/admin/components/tabs.css"),
"\n",
include_str!("../../assets/static/admin/pages/auth.css"),
"\n",
include_str!("../../assets/static/admin/pages/dashboard.css"),
"\n",
include_str!("../../assets/static/admin/pages/db_browser.css"),
"\n",
include_str!("../../assets/static/admin/pages/permissions.css"),
"\n",
include_str!("../../assets/static/admin/pages/sessions.css"),
"\n",
include_str!("../../assets/static/admin/pages/errors.css"),
"\n",
include_str!("../../assets/static/admin/pages/list.css"),
"\n",
include_str!("../../assets/static/admin/layout/responsive.css"),
"\n",
include_str!("../../assets/static/admin/print/print.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_INTER: &[u8] = include_bytes!("../../assets/static/fonts/InterVariable.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");
const FONT_NOTO_THAI: &[u8] =
include_bytes!("../../assets/static/fonts/NotoSansThai-Variable.woff2");
const FONT_NOTO_DEVA: &[u8] =
include_bytes!("../../assets/static/fonts/NotoSansDevanagari-Variable.woff2");
const FONT_NOTO_JP: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansJP-Regular.woff2");
const FONT_NOTO_KR: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansKR-Regular.woff2");
const FONT_NOTO_SC: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansSC-Regular.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 READ_ONLY_EXACT_ALLOW: &[&str] = &[
"/admin/login",
"/admin/logout",
"/admin/reauth",
"/admin/forgot-password",
"/admin/mfa/verify",
"/admin/must-change-password",
"/admin/password_change",
];
const READ_ONLY_PREFIX_ALLOW: &[&str] = &[
"/admin/reset-password/",
"/admin/account/sessions/",
"/admin/account/mfa/",
];
fn is_saved_filter_path(path: &str) -> bool {
if let Some(rest) = path.strip_prefix("/admin/") {
if let Some((_, after)) = rest.split_once('/') {
return after == "saved_filters" || after.starts_with("saved_filters/");
}
}
false
}
pub(crate) fn is_mutating_method(method: &hyper::Method) -> bool {
matches!(
*method,
hyper::Method::POST | hyper::Method::PUT | hyper::Method::PATCH | hyper::Method::DELETE
)
}
pub(crate) fn is_read_only_writable_path(path: &str) -> bool {
if READ_ONLY_EXACT_ALLOW.contains(&path) {
return true;
}
if READ_ONLY_PREFIX_ALLOW
.iter()
.any(|prefix| path.starts_with(prefix))
{
return true;
}
is_saved_filter_path(path)
}
pub(crate) fn extract_admin_name(path: &str) -> Option<&str> {
let rest = path.strip_prefix("/admin/")?;
let slug = rest.split('/').next()?;
if slug.is_empty() || slug.starts_with('_') {
return None;
}
Some(slug)
}
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}"))
}
async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
let token = auth::session_token_from_cookie(cookie_header)?;
let identity = auth::identity_from_session(db, token.as_str())
.await
.ok()
.flatten()?;
if !identity.is_active {
return None;
}
Some(identity)
}
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 err_db = ctx.db.clone();
let router = router.middleware(move |req, next| {
let admin = err_admin.clone();
let templates = err_templates.clone();
let db = err_db.clone();
Box::pin(async move {
let is_admin_path = req.path().starts_with("/admin");
let cookie_header = if is_admin_path {
req.header("cookie").map(|s| s.to_string())
} else {
None
};
let result = next.run(req).await;
match result {
Ok(resp) => Ok(resp),
Err(err) if is_admin_path => {
let identity = match cookie_header.as_deref() {
Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
None => None,
};
Ok(render::render_admin_error_response(
&admin,
&templates,
identity.as_ref(),
err.status(),
err.client_message().to_string(),
))
}
Err(err) => Err(err),
}
})
});
let ro_flag = ctx.admin.is_read_only();
let ro_models = std::sync::Arc::new(ctx.admin.read_only_models.clone());
let router = router.middleware(move |req, next| {
let ro_models = ro_models.clone();
Box::pin(async move {
if req.path().starts_with("/admin")
&& is_mutating_method(req.method())
&& !is_read_only_writable_path(req.path())
{
if ro_flag {
return Err(Error::Forbidden(
"This admin is currently in read-only mode. \
Project-data mutations are disabled until the operator \
turns read-only off."
.into(),
));
}
if !ro_models.is_empty() {
if let Some(slug) = extract_admin_name(req.path()) {
if ro_models.contains(slug) {
return Err(Error::Forbidden(format!(
"Model `{slug}` is frozen (read-only). \
Mutations on this model are disabled."
)));
}
}
}
}
next.run(req).await
})
});
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 router = router.get("/static/fonts/InterVariable.woff2", |_req| async move {
Ok(font_response(FONT_INTER))
});
let router = router.get(
"/static/fonts/NotoSansThai-Variable.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_THAI)) },
);
let router = router.get(
"/static/fonts/NotoSansDevanagari-Variable.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_DEVA)) },
);
let router = router.get(
"/static/fonts/NotoSansJP-Regular.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_JP)) },
);
let router = router.get(
"/static/fonts/NotoSansKR-Regular.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_KR)) },
);
let router = router.get(
"/static/fonts/NotoSansSC-Regular.woff2",
|_req| async move { Ok(font_response(FONT_NOTO_SC)) },
);
let c = ctx.clone();
let router = router.get("/admin/healthz", move |_req| {
let c = c.clone();
async move { super::healthz::healthz(&c.db).await }
});
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/db", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Developer).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => super::db_browser::show_db_browser(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/notifications", 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::show_notifications(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/notifications/mark_all_read", 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::do_mark_all_notifications_read(&c, ident, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/feature_flags", 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_feature_flags(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/feature_flags", 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::do_create_feature_flag(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/feature_flags/:key/toggle", move |req| {
let c = c.clone();
async move {
let key = req
.param("key")
.ok_or_else(|| Error::BadRequest("missing flag key".into()))?
.to_string();
match role_guard(&c, &req, Role::Administrator).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_toggle_feature_flag(&c, ident, &key, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/health", 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_health(&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 router = router.post("/admin/users/:id/sessions/:session_id/revoke", 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 user_id = parse_id(req.param("id"))?;
let session_id = parse_id(req.param("session_id"))?;
super::admin_recovery_handlers::do_admin_revoke_one_session(
&c, ident, user_id, session_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);
let viewing_session_id = match req
.header("cookie")
.and_then(crate::auth::session_token_from_cookie)
{
Some(token) => crate::auth::current_session_id(&ac.db, &token)
.await
.ok()
.flatten(),
None => None,
};
super::builtin::show_user_view(
&ac,
ident,
id,
handlers::csrf_token(&req),
tab,
page,
viewing_session_id,
)
.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/uploads/:filename", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Staff).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => {
let filename = req
.param("filename")
.map(str::to_string)
.unwrap_or_default();
handlers::serve_upload(&c, ident, &filename, req).await
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/_lookup/: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::lookup_model(&c, ident, &name, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/_search", 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::search_models(&c, ident, req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/docs", 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::show_docs_index(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/docs/:slug", move |req| {
let c = c.clone();
async move {
let slug = req
.param("slug")
.ok_or_else(|| Error::BadRequest("missing doc slug".into()))?
.to_string();
match role_guard(&c, &req, Role::Staff).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::show_doc_page(&c, ident, &slug, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/apis/openapi.json", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Staff).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(_) => {
let spec = super::openapi::build_spec(&c.admin);
super::json_api::json_response(spec)
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/apis/sdk.ts", move |req| {
let c = c.clone();
async move {
match role_guard(&c, &req, Role::Staff).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(_) => {
let body = super::sdk_gen::build_typescript(&c.admin);
Ok(crate::http::Response::ok(body)
.with_header("content-type", "text/typescript; charset=utf-8")
.with_header(
"content-disposition",
"attachment; filename=\"rustio-sdk.ts\"",
))
}
}
}
});
let c = ctx.clone();
let router = router.get("/admin/apis", 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::show_apis_index(&c, ident, &req).await,
}
}
});
let c = ctx.clone();
let router = router.get("/admin/apis/playground", 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::show_apis_playground(&c, ident, &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/export.csv", 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::export_model_csv(&c, ident, &name, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/import.csv", 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) => handlers::import_model_csv(&c, ident, &name, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/saved_filters", 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::do_save_filter(&c, ident, &name, req).await,
}
}
});
let c = ctx.clone();
let router = router.post("/admin/:admin_name/saved_filters/:id/delete", move |req| {
let c = c.clone();
async move {
let name = model_name_from_req(&req)?;
let id = parse_id(req.param("id"))?;
let perm = perm_for(&c, &name, "view")?;
match perm_guard(&c, &req, &perm).await? {
Guard::Redirect(r) => Ok(r),
Guard::Allow(ident) => handlers::do_delete_filter(&c, ident, &name, id, 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", 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_json(&c, ident, &name, id, &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, req, 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:?}"
);
}
}
#[test]
fn read_only_allows_auth_flow_exact_paths() {
for path in [
"/admin/login",
"/admin/logout",
"/admin/reauth",
"/admin/forgot-password",
"/admin/mfa/verify",
"/admin/must-change-password",
"/admin/password_change",
] {
assert!(
super::is_read_only_writable_path(path),
"auth path {path:?} must be writable in read-only mode"
);
}
}
#[test]
fn read_only_allows_prefix_paths() {
for path in [
"/admin/reset-password/abc123",
"/admin/reset-password/abc123/whatever",
"/admin/account/sessions/42/revoke",
"/admin/account/sessions/revoke-all",
"/admin/account/mfa/enroll",
"/admin/account/mfa/disable",
] {
assert!(
super::is_read_only_writable_path(path),
"prefix-allowlisted path {path:?} must be writable"
);
}
}
#[test]
fn read_only_blocks_project_data_mutations() {
for path in [
"/admin/posts/new",
"/admin/posts/42/edit",
"/admin/posts/42/delete",
"/admin/posts/bulk_delete",
"/admin/posts/bulk/archive",
"/admin/users/new",
"/admin/users/42/edit",
"/admin/users/42/reset-password",
"/admin/users/42/lock",
"/admin/users/42/sessions/99/revoke",
"/admin/groups/new",
"/admin/groups/42/delete",
] {
assert!(
!super::is_read_only_writable_path(path),
"data-mutation path {path:?} must be blocked in read-only mode"
);
}
}
#[test]
fn read_only_blocks_random_paths_outside_admin_surface() {
for path in ["/", "/login", "/static/admin.css", "/api/v1/posts"] {
assert!(
!super::is_read_only_writable_path(path),
"non-admin path {path:?} must not be writable"
);
}
}
#[test]
fn extract_admin_name_parses_slug_segment() {
assert_eq!(super::extract_admin_name("/admin/posts"), Some("posts"));
assert_eq!(super::extract_admin_name("/admin/posts/"), Some("posts"));
assert_eq!(
super::extract_admin_name("/admin/posts/42/edit"),
Some("posts")
);
assert_eq!(
super::extract_admin_name("/admin/users/42/sessions/99/revoke"),
Some("users")
);
}
#[test]
fn extract_admin_name_rejects_root_reserved_and_non_admin() {
assert_eq!(super::extract_admin_name("/admin/"), None);
assert_eq!(super::extract_admin_name("/admin"), None);
assert_eq!(super::extract_admin_name("/admin/_search"), None);
assert_eq!(super::extract_admin_name("/admin/_lookup/posts"), None);
assert_eq!(super::extract_admin_name("/login"), None);
assert_eq!(super::extract_admin_name("/static/admin.css"), None);
}
#[test]
fn read_only_model_builder_and_accessor_round_trip() {
let admin = super::super::types::Admin::new()
.read_only_model("archive_posts")
.read_only_model("legacy_invoices");
assert!(admin.is_model_read_only("archive_posts"));
assert!(admin.is_model_read_only("legacy_invoices"));
assert!(!admin.is_model_read_only("posts"));
assert!(!admin.is_read_only());
}
#[test]
fn is_mutating_method_recognises_write_verbs() {
use hyper::Method;
for m in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
assert!(super::is_mutating_method(&m), "{m} must be mutating");
}
for m in [Method::GET, Method::HEAD, Method::OPTIONS] {
assert!(!super::is_mutating_method(&m), "{m} must not be mutating");
}
}
}