Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/enterprise.rs

//! **The owner's enterprise list, relayed** (2026-10-05): the account admin's
//! audit log and its export, the operator's look the account admin approves
//! (with a fresh step-up of their own passkey), the names held for review,
//! and — on the operator's side — the aggregates, the look asked for, the
//! name filter's lists.
//!
//! **Nothing is decided here** (the thin layer, owner 2026-10-04): every call
//! is gunnar's `Enterprise` service, signed AS THE PERSON ([`Actor`]); gunnar
//! says who an account's admin is, whether a step-up is fresh, what a name
//! is, and what the operator may see. This module turns JSON into the
//! request and the answer into JSON.

use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::Json;
use base64::Engine as _;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

fn refused(code: &str, message: impl Into<String>) -> ApiError {
    ApiError(Fail {
        code: code.to_owned(),
        message: message.into(),
    })
}

/// The account a request names, or the person's own.
fn account_of(actor: &Actor, asked: Option<&str>) -> Result<String, ApiError> {
    match asked.map(str::trim).filter(|a| !a.is_empty()) {
        Some(account) => Ok(account.to_owned()),
        None => actor.principal.clone().ok_or_else(|| {
            refused("invalidargument", "Name the account: this session acts for no principal of its own.")
        }),
    }
}

/// The audit log's filter, as the page sends it (a query string).
#[derive(Debug, Default, Deserialize)]
pub struct AuditAsk {
    pub account: Option<String>,
    /// Comma-separated kinds or families.
    pub kinds: Option<String>,
    pub actor: Option<String>,
    pub text: Option<String>,
    pub since: Option<i64>,
    pub until: Option<i64>,
    pub before: Option<u64>,
    pub size: Option<u32>,
    /// `csv` or `json` (the export).
    pub format: Option<String>,
}

impl AuditAsk {
    fn filter(&self) -> pb::AuditFilter {
        pb::AuditFilter {
            kinds: self
                .kinds
                .as_deref()
                .unwrap_or("")
                .split(',')
                .map(|k| k.trim().to_owned())
                .filter(|k| !k.is_empty())
                .collect(),
            actor: self.actor.clone().unwrap_or_default(),
            since_unix_ms: self.since.unwrap_or(0),
            until_unix_ms: self.until.unwrap_or(0),
            text: self.text.clone().unwrap_or_default(),
        }
    }
}

fn entry_out(e: pb::AuditEntry) -> Value {
    json!({
        "seq": e.seq,
        "at": e.at_unix_ms,
        "kind": e.kind,
        "actor": e.actor,
        "role": e.actor_role,
        "target": e.target,
        "detail": e.detail,
        "outcome": e.outcome,
        "hash": e.hash,
    })
}

/// `GET /api/account/audit` — **one page of the account's audit log**,
/// newest first, with whether its chain holds. gunnar admits the account's
/// admins alone (the operator included: refused).
pub async fn audit(actor: Actor, Query(ask): Query<AuditAsk>) -> Result<Json<Value>, ApiError> {
    let account = account_of(&actor, ask.account.as_deref())?;
    let answer = actor
        .gunnar
        .enterprise()
        .audit_log(pb::AuditLogRequest {
            account: account.clone(),
            filter: Some(ask.filter()),
            page_size: ask.size.unwrap_or(50),
            before_seq: ask.before,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let chain = answer.chain.map(|c| {
        json!({
            "holds": c.holds,
            "entries": c.entries,
            "tip": c.tip_hash,
            "anchor": c.anchor_seq,
            "broken_at": c.broken_at_seq,
            "what": c.what,
        })
    });
    Ok(Json(json!({
        "account": answer.account,
        "entries": answer.entries.into_iter().map(entry_out).collect::<Vec<_>>(),
        "next_before": answer.next_before_seq,
        "chain": chain,
        "retention_days": answer.retention_days,
        "written": answer.written,
    })))
}

/// `GET /api/account/audit/export?format=csv|json` — **the whole log (as
/// filtered) as a file**, gunnar's bytes, gunnar's name for it.
pub async fn export(actor: Actor, Query(ask): Query<AuditAsk>) -> Result<Response, ApiError> {
    let account = account_of(&actor, ask.account.as_deref())?;
    let format = match ask.format.as_deref().unwrap_or("csv") {
        "json" => pb::AuditExportFormat::Json,
        _ => pb::AuditExportFormat::Csv,
    };
    let file = actor
        .gunnar
        .enterprise()
        .export_audit_log(pb::ExportAuditLogRequest {
            account,
            filter: Some(ask.filter()),
            format: format as i32,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let name: String = file
        .file_name
        .chars()
        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
        .collect();
    Ok((
        [
            (header::CONTENT_TYPE, file.content_type),
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
        ],
        file.body,
    )
        .into_response())
}

fn look_out(r: pb::OperatorAccess) -> Value {
    let state = match pb::OperatorAccessState::try_from(r.state).unwrap_or(pb::OperatorAccessState::Unspecified) {
        pb::OperatorAccessState::Pending => "pending",
        pb::OperatorAccessState::Approved => "approved",
        pb::OperatorAccessState::Refused => "refused",
        pb::OperatorAccessState::Revoked => "revoked",
        pb::OperatorAccessState::Expired => "expired",
        pb::OperatorAccessState::Unspecified => "unknown",
    };
    json!({
        "id": r.request_id,
        "code": r.code,
        "account": r.account,
        "operator": r.operator,
        "reason": r.reason,
        "minutes": r.minutes,
        "requested": r.requested_at_unix_ms,
        "request_expires": r.request_expires_at_unix_ms,
        "state": state,
        "until": r.granted_until_unix_ms,
        "decided_by": r.decided_by,
    })
}

/// `?account=` for the account admin's lists.
#[derive(Debug, Default, Deserialize)]
pub struct AccountAsk {
    pub account: Option<String>,
}

/// `GET /api/account/operator-access` — **the operator's looks into the
/// account**: asked, approved, ended. Its admin's.
pub async fn looks(actor: Actor, Query(ask): Query<AccountAsk>) -> Result<Json<Value>, ApiError> {
    let account = account_of(&actor, ask.account.as_deref())?;
    let rows = actor
        .gunnar
        .enterprise()
        .list_operator_access(pb::ListOperatorAccessRequest { account })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .requests;
    Ok(Json(json!({ "requests": rows.into_iter().map(look_out).collect::<Vec<_>>() })))
}

/// `GET /api/account/step-up` — **the options for a fresh touch of the
/// person's OWN passkey**: the identity core's step-up challenge for their
/// session (`IdentityService.StepUpStart`) and the person's passkeys (asked as
/// the person). Nothing about it is kept here.
pub async fn step_up(
    State(state): State<AppState>,
    crate::security::ClientIp(client): crate::security::ClientIp,
    actor: Actor,
) -> Result<Json<Value>, ApiError> {
    let webauthn = state
        .webauthn
        .as_ref()
        .ok_or_else(|| refused("failedprecondition", "This console has no passkeys configured, so it cannot ask for a step-up."))?;
    let session = crate::api::current_person()
        .ok_or_else(|| refused("permissiondenied", "A step-up is a signed-in person's touch of their own passkey."))?;
    let me = actor.own_principal()?;
    let mine: Vec<Vec<u8>> = actor
        .gunnar
        .access()
        .list_credentials(pb::ListCredentialsRequest { principal_id: me })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .credentials
        .into_iter()
        .map(|c| c.credential_id)
        .collect();
    let challenge = state
        .gunnar
        .identity()
        .step_up_start(nornir_identity::grpc::pb::StepUpStartRequest { session: session.token.clone(), client })
        .await
        .map_err(|status| {
            let (code, message) = crate::auth::identity_refused(&status);
            refused(if code == http::StatusCode::UNAUTHORIZED { "unauthenticated" } else { "permissiondenied" }, message)
        })?
        .into_inner();
    Ok(Json(webauthn.step_up_options(&challenge, &mine)))
}

/// The body of an approval: the request's code, the length the admin chose,
/// and the touch, as the login page sends an assertion.
#[derive(Debug, Deserialize)]
pub struct Approval {
    pub code: String,
    #[serde(default)]
    pub minutes: u32,
    /// The credential id, base64url.
    pub id: String,
    pub response: Touch,
}

/// The authenticator's answer, each part base64url.
#[derive(Debug, Deserialize)]
pub struct Touch {
    #[serde(rename = "clientDataJSON")]
    pub client_data_json: String,
    #[serde(rename = "authenticatorData")]
    pub authenticator_data: String,
    pub signature: String,
}

fn unb64(field: &str, text: &str) -> Result<Vec<u8>, ApiError> {
    let text = text.trim().trim_end_matches('=').replace('+', "-").replace('/', "_");
    base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(text.as_bytes())
        .map_err(|_| refused("invalidargument", format!("`{field}` is not base64url")))
}

/// `POST /api/account/operator-access/{id}/approve` — **the account admin
/// approves the operator's look**, relaying their fresh step-up byte for
/// byte; gunnar verifies it and decides.
pub async fn approve(
    State(state): State<AppState>,
    actor: Actor,
    Path(id): Path<String>,
    Json(body): Json<Approval>,
) -> Result<Json<Value>, ApiError> {
    let webauthn = state
        .webauthn
        .as_ref()
        .ok_or_else(|| refused("failedprecondition", "This console has no passkeys configured, so it cannot relay a step-up."))?;
    // The touch steps the approver's own identity-core session up; gunnar
    // then reads that session's step-up.
    let session = crate::api::current_person()
        .ok_or_else(|| refused("permissiondenied", "An approval is a signed-in person's touch of their own passkey."))?;
    let assertion = crate::auth::Assertion {
        ceremony_id: String::new(),
        id: body.id.clone(),
        credential_id: unb64("id", &body.id)?,
        client_data_json: unb64("clientDataJSON", &body.response.client_data_json)?,
        authenticator_data: unb64("authenticatorData", &body.response.authenticator_data)?,
        signature: unb64("signature", &body.response.signature)?,
    };
    state
        .gunnar
        .identity()
        .step_up_finish(webauthn.step_up_finish_request(&session.token, &assertion))
        .await
        .map_err(|status| refused("unauthenticated", crate::auth::identity_refused(&status).1))?;
    let row = actor
        .gunnar
        .enterprise()
        .approve_operator_access(pb::ApproveOperatorAccessRequest {
            request_id: id,
            code: body.code,
            minutes: body.minutes,
        })
        .await
        .map_err(|status| {
            if status.message().contains("[stepup]") {
                return refused(crate::auth::STEP_UP_CODE, crate::auth::step_up_words());
            }
            ApiError::from(status)
        })?
        .into_inner()
        .request
        .unwrap_or_default();
    Ok(Json(look_out(row)))
}

/// `POST /api/account/operator-access/{id}/refuse` — the account admin says no.
pub async fn refuse(actor: Actor, Path(id): Path<String>) -> Result<Json<Value>, ApiError> {
    let row = actor
        .gunnar
        .enterprise()
        .refuse_operator_access(pb::RefuseOperatorAccessRequest { request_id: id })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .request
        .unwrap_or_default();
    Ok(Json(look_out(row)))
}

/// `POST /api/account/operator-access/{id}/end` and
/// `POST /api/accounts/looks/{id}/end` — end a look now (the account admin,
/// or the operator that asked).
pub async fn end(actor: Actor, Path(id): Path<String>) -> Result<Json<Value>, ApiError> {
    let row = actor
        .gunnar
        .enterprise()
        .revoke_operator_access(pb::RevokeOperatorAccessRequest { request_id: id })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .request
        .unwrap_or_default();
    Ok(Json(look_out(row)))
}

fn review_out(r: pb::NameReview) -> Value {
    json!({
        "id": r.id,
        "kind": r.kind,
        "name": r.name,
        "actor": r.actor,
        "verdict": r.verdict,
        "state": r.state,
        "at": r.at_unix_ms,
        "decided_by": r.decided_by,
    })
}

/// `GET /api/account/names` — **the names the filter stopped in the
/// account**: held ones to decide, refused ones to know about.
pub async fn names(actor: Actor, Query(ask): Query<AccountAsk>) -> Result<Json<Value>, ApiError> {
    let account = account_of(&actor, ask.account.as_deref())?;
    let answer = actor
        .gunnar
        .enterprise()
        .list_name_reviews(pb::ListNameReviewsRequest { account })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(json!({
        "pending": answer.pending,
        "reviews": answer.reviews.into_iter().map(review_out).collect::<Vec<_>>(),
    })))
}

/// The body of `POST /api/account/names/{id}`.
#[derive(Debug, Deserialize)]
pub struct Decision {
    pub account: Option<String>,
    pub allow: bool,
}

/// `POST /api/account/names/{id}` — the account admin allows or refuses a
/// held name.
pub async fn decide_name(actor: Actor, Path(id): Path<String>, Json(body): Json<Decision>) -> Result<Json<Value>, ApiError> {
    let account = account_of(&actor, body.account.as_deref())?;
    let review = actor
        .gunnar
        .enterprise()
        .decide_name_review(pb::DecideNameReviewRequest { account, id, allow: body.allow })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .review
        .unwrap_or_default();
    Ok(Json(review_out(review)))
}

/// `GET /api/accounts/aggregates` — **the operator's totals**: how many
/// accounts, members and repositories, storage used and sold, the looks,
/// health. Never a name; gunnar's server `Read`.
pub async fn aggregates(actor: Actor) -> Result<Json<Value>, ApiError> {
    let t = actor
        .gunnar
        .enterprise()
        .aggregates(pb::AggregatesRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(json!({
        "accounts": t.accounts,
        "enterprise_accounts": t.enterprise_accounts,
        "members": t.members,
        "repos": t.repos,
        "public_repos": t.public_repos,
        "stored_bytes": t.stored_bytes,
        "room_held_bytes": t.sold_bytes,
        "looks_live": t.looks_live,
        "looks_pending": t.looks_pending,
        "health": t.health,
        "health_alarms": t.health_alarms,
        "operator_repos": t.operator_repos,
        "at": t.at_unix_ms,
    })))
}

/// `GET /api/accounts/looks` — the operator's own requests to look inside.
pub async fn my_looks(actor: Actor) -> Result<Json<Value>, ApiError> {
    let rows = actor
        .gunnar
        .enterprise()
        .list_operator_access(pb::ListOperatorAccessRequest { account: String::new() })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .requests;
    Ok(Json(json!({ "requests": rows.into_iter().map(look_out).collect::<Vec<_>>() })))
}

/// The body of `POST /api/accounts/looks`.
#[derive(Debug, Deserialize)]
pub struct LookAsk {
    pub account: String,
    pub reason: String,
    #[serde(default)]
    pub minutes: u32,
}

/// `POST /api/accounts/looks` — **the operator asks to look inside an
/// account**; the answer carries the code to tell the account admin.
pub async fn ask_look(actor: Actor, Json(body): Json<LookAsk>) -> Result<Json<Value>, ApiError> {
    let row = actor
        .gunnar
        .enterprise()
        .request_operator_access(pb::RequestOperatorAccessRequest {
            account: body.account.trim().to_owned(),
            reason: body.reason,
            minutes: body.minutes,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .request
        .unwrap_or_default();
    Ok(Json(look_out(row)))
}

fn lists_out(l: pb::NameLists) -> Value {
    json!({
        "blocked": l.blocked,
        "borderline": l.borderline,
        "reserved": l.reserved,
        "reserved_prefixes": l.reserved_prefixes,
        "allowed": l.allowed,
    })
}

/// `GET /api/accounts/name-lists` — the name filter's lists, gunnar's.
pub async fn name_lists(actor: Actor) -> Result<Json<Value>, ApiError> {
    let lists = actor
        .gunnar
        .enterprise()
        .get_name_lists(pb::GetNameListsRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(lists_out(lists)))
}

/// The body of `PUT /api/accounts/name-lists`: every list whole.
#[derive(Debug, Deserialize)]
pub struct Lists {
    #[serde(default)]
    pub blocked: Vec<String>,
    #[serde(default)]
    pub borderline: Vec<String>,
    #[serde(default)]
    pub reserved: Vec<String>,
    #[serde(default)]
    pub reserved_prefixes: Vec<String>,
    #[serde(default)]
    pub allowed: Vec<String>,
}

/// `PUT /api/accounts/name-lists` — replace them (gunnar's server `Admin`).
pub async fn set_name_lists(actor: Actor, Json(body): Json<Lists>) -> Result<Json<Value>, ApiError> {
    let lists = actor
        .gunnar
        .enterprise()
        .set_name_lists(pb::SetNameListsRequest {
            lists: Some(pb::NameLists {
                blocked: body.blocked,
                borderline: body.borderline,
                reserved: body.reserved,
                reserved_prefixes: body.reserved_prefixes,
                allowed: body.allowed,
            }),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(lists_out(lists)))
}