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)))
}