zeyn 0.0.3

Rust web framework for data heavy applications, written by AI agents and reviewed by people
Documentation
//! Endpoints for personal data protection: revealing masked values and reasons for opening
//! screens with personal data.

use super::{AppState, Call, CallKind, Failure, cookie, encode, percent_decode, with_cookie};
use crate::action::{Access, Error};
use crate::audit::{FieldRef, RecordRef};
use crate::ctx::Ctx;
use crate::i18n::Text;
use crate::model::{FieldError, Rule, ValidationErrors};
use crate::render::escape;
use crate::screen::ScreenMeta;
use axum::Router;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::post;
use serde::Deserialize;
use serde_json::{Value as Json, json};
use std::sync::Arc;

pub(super) fn routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
    router
        .route("/_zeyn/reveal", post(reveal))
        .route("/_zeyn/reason", post(give_reason))
        .route("/_zeyn/import", post(import).layer(axum::extract::DefaultBodyLimit::max(IMPORT_BODY_LIMIT)))
}

/// Largest import request: the CSV text, or an XLSX file in base64.
#[cfg(feature = "xlsx")]
const IMPORT_BODY_LIMIT: usize = crate::xlsx::MAX_XLSX_BYTES.div_ceil(3) * 4 + 64 * 1024;
#[cfg(not(feature = "xlsx"))]
const IMPORT_BODY_LIMIT: usize = crate::import::MAX_CSV_BYTES + 64 * 1024;

const REASON_COOKIE: &str = "zeyn_reason";
const REASON_SECONDS: u32 = 900;

/// A reason is 3 to 300 characters of text, without control characters.
fn valid_reason(reason: &str) -> bool {
    let n = reason.chars().count();
    (3..=300).contains(&n) && !reason.chars().any(char::is_control)
}

/// Reads the reason cookie (`<screen>.<reason>`) into the request's trail. The server only uses it
/// for that screen.
pub(super) fn read_reason(ctx: &Ctx, headers: &HeaderMap) {
    let Some((screen, reason)) = cookie(headers, REASON_COOKIE).and_then(|v| v.split_once('.')) else { return };
    let reason = percent_decode(reason).trim().to_string();
    if valid_reason(&reason) {
        let screen = screen.to_string();
        ctx.update_trail(|t| t.given_reason = Some((screen, reason)));
    }
}

/// Refuses screens that ask for a reason until the user has given one; records it otherwise.
pub(super) fn check_reason(ctx: &Ctx, meta: &ScreenMeta) -> Result<(), Failure> {
    if !meta.reason {
        return Ok(());
    }
    match ctx.trail().given_reason {
        Some((screen, reason)) if screen == meta.name => {
            ctx.update_trail(|t| t.reason = Some(reason));
            Ok(())
        }
        _ => Err(Failure::Status(
            StatusCode::PRECONDITION_REQUIRED,
            "reason_required",
            Text::key("zeyn.privacy.reason_required"),
        )),
    }
}

/// Records the opening of a screen marked `sensitive` as a read, with its params.
pub(super) fn note_screen_read(ctx: &Ctx, meta: &ScreenMeta, params: &Json) {
    if meta.sensitive || meta.reason {
        let id = (!params.is_null()).then(|| params.to_string());
        ctx.note_read(RecordRef { model: meta.name, id });
    }
}

/// Page that asks for a reason before a screen's data is shown.
pub(super) fn reason_page(st: &AppState, ctx: &Ctx, screen: &str, csrf: &str) -> Response {
    let text = |k: &str| escape(&ctx.t(&Text::key(format!("zeyn.privacy.{k}"))));
    let body = format!(
        r#"<section class="z-screen z-reason" aria-labelledby="z-reason-title"><h1 id="z-reason-title">{title}</h1><p id="z-reason-help">{explain}</p><form class="z-form" data-zeyn-reason="{screen}" novalidate><div class="z-field"><label for="z-reason-text">{label}<span class="z-required" aria-hidden="true">*</span></label><textarea id="z-reason-text" name="reason" required aria-required="true" minlength="3" maxlength="300" rows="3" aria-describedby="z-reason-help z-reason-error"></textarea><p class="z-field-error" id="z-reason-error" aria-live="polite"></p></div><div class="z-form-actions"><button type="submit" class="z-button z-primary">{go}</button></div></form></section>"#,
        title = text("reason_title"),
        explain = text("reason_explain"),
        label = text("reason_label"),
        go = text("reason_continue"),
        screen = escape(screen),
    );
    let mut r =
        Html(st.shell(ctx, csrf, &ctx.t(&Text::key("zeyn.privacy.reason_title")), &body, Some(screen))).into_response();
    *r.status_mut() = StatusCode::PRECONDITION_REQUIRED;
    r
}

#[derive(Deserialize)]
struct ImportCall {
    screen: String,
    #[serde(default)]
    params: Json,
    node: crate::ui::NodePath,
    #[serde(default)]
    csv: String,
    /// An XLSX file in base64, instead of `csv`.
    #[serde(default)]
    xlsx: Option<String>,
    #[serde(default)]
    commit: bool,
}

impl ImportCall {
    /// The uploaded file. An XLSX file needs the `xlsx` feature; bad base64 reads as a broken file.
    fn file(&mut self) -> Result<crate::import::ImportFile, Failure> {
        match self.xlsx.take() {
            None => Ok(crate::import::ImportFile::Csv(std::mem::take(&mut self.csv))),
            #[cfg(feature = "xlsx")]
            Some(b64) => Ok(crate::import::ImportFile::Xlsx(super::base64_decode(&b64).unwrap_or_default())),
            #[cfg(not(feature = "xlsx"))]
            Some(_) => Err(Failure::App(Error::NotFound)),
        }
    }
}

/// Previews (`commit: false`) or imports a CSV or XLSX file through an import component. Both
/// need the import action's permission; the file is read and validated again on commit.
async fn import(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(mut call): axum::Json<ImportCall>,
) -> Response {
    let ctx = st.licensed(st.ctx(&headers)).await;
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let file = call.file();
    let result = async {
        let (_, entry, screen) = st.open(&ctx, &call.screen, call.params).await?;
        let source = screen.node(&call.node).and_then(|n| n.import()).ok_or(Failure::App(Error::NotFound))?;
        let action = source.action();
        if !ctx.identity().can(&action.access) {
            return Err(Failure::App(Error::Forbidden));
        }
        let file = file?;
        if !call.commit {
            return source.preview(&ctx, &file).map_err(Failure::App);
        }
        let info = Call { kind: CallKind::Import, screen: entry.meta.name, action: Some(action), via_agent: false };
        st.intercept(&ctx, &info, || st.timed(source.commit(ctx.clone(), file))).await
    }
    .await;
    match result {
        Ok(body) => axum::Json(body).into_response(),
        Err(f) => st.error_response(&ctx, f),
    }
}

#[derive(Deserialize)]
struct ReasonCall {
    screen: String,
    reason: String,
}

/// Stores the user's reason for opening a screen for 15 minutes, in an `HttpOnly` cookie. The
/// reason is written to the audit record of every call to that screen.
async fn give_reason(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(call): axum::Json<ReasonCall>,
) -> Response {
    let ctx = st.ctx(&headers);
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let Some((name, (_, entry))) = st.screens.get_key_value(call.screen.as_str()) else {
        return st.error_response(&ctx, Failure::App(Error::NotFound));
    };
    if !entry.meta.reason {
        return st.error_response(&ctx, Failure::App(Error::NotFound));
    }
    if !ctx.identity().can(&entry.meta.access) {
        return st.error_response(&ctx, Failure::App(Error::Forbidden));
    }
    let reason = call.reason.trim();
    if !valid_reason(reason) {
        let mut errors = ValidationErrors::default();
        errors.errors.push(FieldError {
            field: "reason",
            rule: Rule::Custom,
            message: Text::key("zeyn.privacy.reason_invalid"),
        });
        return st.error_response(&ctx, Failure::App(Error::Validation(errors)));
    }
    let secure = if st.secure_cookies { "; Secure" } else { "" };
    let c = format!(
        "{REASON_COOKIE}={name}.{}; Path={}; Max-Age={REASON_SECONDS}; HttpOnly; SameSite=Strict{secure}",
        encode(reason),
        st.cookie_path()
    );
    with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c))
}

#[derive(Deserialize)]
struct RevealCall {
    screen: String,
    #[serde(default)]
    params: Json,
    node: crate::ui::NodePath,
    field: String,
    /// Row key, for grids.
    key: Option<String>,
}

/// Sends the raw value of one masked field to a user with its unmask permission. Every attempt is
/// audited, including refused ones.
async fn reveal(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(call): axum::Json<RevealCall>,
) -> Response {
    let ctx = st.licensed(st.ctx(&headers)).await;
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let screen_name: &'static str = match st.screens.get_key_value(call.screen.as_str()) {
        Some((name, _)) => *name,
        None => return st.error_response(&ctx, Failure::App(Error::NotFound)),
    };
    let info = Call { kind: CallKind::Reveal, screen: screen_name, action: None, via_agent: false };
    let (state, user) = (&*st, &ctx);
    let result = st
        .intercept(&ctx, &info, move || async move {
            let (st, ctx) = (state, user);
            let (_, _, screen) = st.open(ctx, screen_name, call.params).await?;
            let node = screen.node(&call.node).ok_or(Failure::App(Error::NotFound))?;
            let found = match node.grid() {
                Some(grid) => {
                    if !ctx.identity().can(&grid.action().access) {
                        return Err(Failure::App(Error::Forbidden));
                    }
                    let key = call.key.clone().ok_or(Failure::App(Error::NotFound))?;
                    st.timed(grid.reveal(ctx.clone(), key, call.field.clone())).await?
                }
                None => node.reveal(&call.field),
            };
            let found = found.ok_or(Failure::App(Error::NotFound))?;
            let permission = found.meta.mask.map(|m| m.permission).ok_or(Failure::App(Error::NotFound))?;
            if !ctx.identity().can(&Access::Permission(permission)) {
                return Err(Failure::App(Error::Forbidden));
            }
            ctx.update_trail(|t| {
                t.revealed.push(FieldRef {
                    model: found.record.model,
                    id: found.record.id.clone(),
                    field: found.meta.name,
                })
            });
            Ok(found.value)
        })
        .await;
    match result {
        Ok(value) => {
            let mut r = axum::Json(json!({ "value": value })).into_response();
            r.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
            r
        }
        Err(f) => st.error_response(&ctx, f),
    }
}