laterite-admin 0.5.0

Laterite CMF admin surface: the Axum router, session middleware, and login shell
Documentation
//! The backend user screen's edit form: the per-user permission override editor.
//!
//! A user's roles set a base of permissions; this screen refines them per user
//! with a three-state control per permission: **allow** (`1`) forces it on,
//! **deny** (`-1`) forces it off, and **inherit** (absent) defers to the roles.
//! Overrides take precedence over the roles (see `laterite_auth::PermissionSet`).
//!
//! Two safeguards mirror the reference system:
//!
//! - A superuser holds every permission unconditionally, so the editor shows a
//!   note instead of the controls for them.
//! - An operator may only change permissions they themselves hold. Controls for
//!   permissions they lack are shown disabled, and a save never alters those, so
//!   the screen cannot be used to escalate beyond the editor's own access.

use std::collections::HashMap;

use askama::Template;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Form};
use laterite_auth::{AuthenticatedUser, PermissionSet};
use laterite_core::query::{bind_values, build as to_sql, text_cast};
use laterite_core::{t, AnyRowExt};
use sea_query::{Alias, Expr, Query};

use crate::{render, AdminError, AdminState, Permission, Shell};

/// Renders the edit form for a user, populated with their current overrides.
pub(crate) async fn edit_form(
    State(state): State<AdminState>,
    Extension(shell): Extension<Shell>,
    Extension(editor): Extension<AuthenticatedUser>,
    Path(id): Path<String>,
) -> Result<Response, AdminError> {
    // Scope the builder so it drops before the await, keeping the future `Send`.
    let (sql, values) = {
        let stmt = Query::select()
            .columns([
                Alias::new("username"),
                Alias::new("first_name"),
                Alias::new("last_name"),
                Alias::new("email"),
                Alias::new("is_superuser"),
                Alias::new("permissions"),
            ])
            .from(Alias::new("backend_users"))
            .and_where(
                Expr::col(Alias::new("id"))
                    .cast_as(Alias::new(text_cast(state.db.backend)))
                    .eq(id.clone()),
            )
            .to_owned();
        to_sql(state.db.backend, stmt)
    };
    let row = bind_values(sqlx::query(&sql), values)
        .fetch_optional(&state.db.pool)
        .await?
        .ok_or(AdminError::NotFound)?;
    let username = row.get_text("username").unwrap_or_default();
    let first_name = row.get_text("first_name").unwrap_or_default();
    let last_name = row.get_text_opt("last_name").unwrap_or_default();
    let email = row.get_text("email").unwrap_or_default();
    let is_superuser = row.get_bool("is_superuser").unwrap_or(false);
    let perms_json = row.get_text("permissions").unwrap_or_default();
    let overrides: HashMap<String, i64> = serde_json::from_str(&perms_json).unwrap_or_default();
    Ok(render(build(
        &state,
        shell,
        &editor.permissions,
        format!("{}/users/{id}/edit", state.admin_path),
        full_name(&first_name, last_name.as_deref()),
        username,
        email,
        is_superuser,
        &overrides,
    )))
}

/// Persists the changed overrides, then redirects to the list.
pub(crate) async fn update(
    State(state): State<AdminState>,
    Extension(editor): Extension<AuthenticatedUser>,
    Extension(session): Extension<crate::session::SessionHandle>,
    Path(id): Path<String>,
    Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Response, AdminError> {
    // Scope the builder so it drops before the await, keeping the future `Send`.
    let (sql, values) = {
        let stmt = Query::select()
            .columns([Alias::new("is_superuser"), Alias::new("permissions")])
            .from(Alias::new("backend_users"))
            .and_where(
                Expr::col(Alias::new("id"))
                    .cast_as(Alias::new(text_cast(state.db.backend)))
                    .eq(id.clone()),
            )
            .to_owned();
        to_sql(state.db.backend, stmt)
    };
    let row = bind_values(sqlx::query(&sql), values)
        .fetch_optional(&state.db.pool)
        .await?
        .ok_or(AdminError::NotFound)?;
    // A superuser has no editable overrides; nothing to save.
    if row.get_bool("is_superuser").unwrap_or(false) {
        return Ok(Redirect::to(&format!("{}/users", state.admin_path)).into_response());
    }
    let target_id = id.parse::<i64>().map_err(|_| AdminError::NotFound)?;

    let perms_json = row.get_text("permissions").unwrap_or_default();
    let mut overrides: HashMap<String, i64> = serde_json::from_str(&perms_json).unwrap_or_default();
    let submitted = parse_states(&pairs);

    // Only touch registered permissions the editor holds. A permission the editor
    // cannot grant is left exactly as it was, so the screen cannot escalate
    // access beyond the editor's own.
    for permission in state.permissions.iter() {
        if !editor.allows(&permission.code) {
            continue;
        }
        match submitted.get(&permission.code).copied() {
            Some(1) => {
                overrides.insert(permission.code.clone(), 1);
            }
            Some(-1) => {
                overrides.insert(permission.code.clone(), -1);
            }
            Some(_) => {
                overrides.remove(&permission.code);
            }
            None => {}
        }
    }

    state
        .auth
        .set_user_permissions(target_id, &overrides)
        .await?;
    let detail = serde_json::to_string(&overrides).unwrap_or_default();
    crate::audit::record(
        &state,
        &editor,
        "backend.user.permissions.update",
        Some("backend_user"),
        Some(id.as_str()),
        Some(detail.as_str()),
    )
    .await;
    session.push_flash(
        crate::session::FlashLevel::Success,
        t!("Permissions updated."),
    );
    Ok(Redirect::to(&format!("{}/users", state.admin_path)).into_response())
}

/// Pulls the submitted permission states out of the form. Each control posts one
/// `p:<code>` pair with value `1`, `0`, or `-1`.
fn parse_states(pairs: &[(String, String)]) -> HashMap<String, i64> {
    let mut states = HashMap::new();
    for (key, value) in pairs {
        if let Some(code) = key.strip_prefix("p:") {
            if let Ok(state) = value.parse::<i64>() {
                states.insert(code.to_string(), state);
            }
        }
    }
    states
}

fn full_name(first: &str, last: Option<&str>) -> String {
    match last {
        Some(last) if !last.is_empty() => format!("{first} {last}"),
        _ => first.to_string(),
    }
}

/// Groups the registered permissions, each with the user's current state and
/// whether the editor may change it (an editor can only change permissions they
/// themselves hold).
fn group_permissions(
    registry: &[Permission],
    overrides: &HashMap<String, i64>,
    editor: &PermissionSet,
    shell: &Shell,
) -> Vec<PermGroupView> {
    let mut groups: Vec<PermGroupView> = Vec::new();
    for permission in registry {
        let state = match overrides.get(&permission.code).copied() {
            Some(1) => 1,
            Some(-1) => -1,
            _ => 0,
        };
        let row = PermRowView {
            code: permission.code.clone(),
            label: shell.tt(&permission.label),
            state,
            changeable: editor.allows(&permission.code),
        };
        // Merge by the localized group heading (same source localizes identically).
        let gname = shell.tt(&permission.group);
        match groups.iter_mut().find(|g| g.name == gname) {
            Some(group) => group.rows.push(row),
            None => groups.push(PermGroupView {
                name: gname,
                rows: vec![row],
            }),
        }
    }
    groups
}

#[allow(clippy::too_many_arguments)]
fn build(
    state: &AdminState,
    shell: Shell,
    editor: &PermissionSet,
    action: String,
    full_name: String,
    username: String,
    email: String,
    is_superuser: bool,
    overrides: &HashMap<String, i64>,
) -> UsersFormTemplate {
    let groups = if is_superuser {
        Vec::new()
    } else {
        group_permissions(&state.permissions, overrides, editor, &shell)
    };
    UsersFormTemplate {
        shell,
        action,
        cancel_path: format!("{}/users", state.admin_path),
        full_name,
        username,
        email,
        is_superuser,
        groups,
    }
}

struct PermRowView {
    code: String,
    label: String,
    state: i32,
    changeable: bool,
}

struct PermGroupView {
    name: String,
    rows: Vec<PermRowView>,
}

#[derive(Template)]
#[template(path = "users_form.html")]
struct UsersFormTemplate {
    shell: Shell,
    action: String,
    cancel_path: String,
    full_name: String,
    username: String,
    email: String,
    is_superuser: bool,
    groups: Vec<PermGroupView>,
}

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

    fn registry() -> Vec<Permission> {
        vec![
            Permission {
                code: "backend.manage_users".to_string(),
                label: "Manage backend users".into(),
                group: "Backend".into(),
            },
            Permission {
                code: "acme.publish".to_string(),
                label: "Publish".into(),
                group: "Content".into(),
            },
        ]
    }

    #[test]
    fn parse_states_reads_prefixed_radio_values() {
        let pairs = vec![
            ("p:backend.manage_users".to_string(), "1".to_string()),
            ("p:acme.publish".to_string(), "-1".to_string()),
            ("other".to_string(), "ignored".to_string()),
        ];
        let states = parse_states(&pairs);
        assert_eq!(states.get("backend.manage_users"), Some(&1));
        assert_eq!(states.get("acme.publish"), Some(&-1));
        assert_eq!(states.get("other"), None);
    }

    #[test]
    fn grouping_marks_state_and_changeability() {
        // An editor who holds only backend.manage_users.
        let editor = PermissionSet::new(false, ["backend.manage_users".to_string()]);
        let overrides = HashMap::from([("acme.publish".to_string(), -1i64)]);
        let groups = group_permissions(&registry(), &overrides, &editor, &Shell::test());

        let backend = &groups[0].rows[0];
        assert_eq!(backend.state, 0);
        assert!(backend.changeable);

        let content = &groups[1].rows[0];
        assert_eq!(content.state, -1);
        // The editor does not hold acme.publish, so it is locked.
        assert!(!content.changeable);
    }
}