crates/gunnar-ui-server/src/api/mod.rs
//! The console's own HTTP API: gunnar's answers, shaped for a browser.
//!
//! Everything here is same-origin JSON. Two things are done for the browser
//! that gunnar does not do for anyone:
//!
//! **Lists are sliced here.** gunnar's `page_size` is read by exactly one RPC
//! (`Refs.Log`); every other list ignores it and answers with the whole
//! collection in one message. So the honest thing the console can say is not
//! "the first N" but "N of M" — it received all M and chose to render N.
//!
//! **Refusals keep gunnar's words.** A [`Fail`] carries gunnar's status code and
//! its message verbatim. Rewording them would make the console a second source
//! of truth about what the server decided, and an operator comparing the two
//! would have no way to know which one to believe.
/// **A namespace, or a repository under it, the caller cannot see does not
/// exist**: one 404, one body, whatever is behind the name. A layer over every
/// namespace-scoped read; see the module.
pub mod absent;
pub mod access;
/// **A person's OWN SSH keys**, and the one mutating surface a viewer may
/// reach. Singular, and not to be confused with [`accounts`] — see the module.
pub mod account;
pub mod encryption_key;
pub mod accounts;
/// **Åtgärder** — the queue of things the caller's own account has to do
/// something about, and the one line the console carries at the top of every
/// page. See the module for why the telling happens here and not inside a
/// purchase.
pub mod actions;
pub mod admin;
pub mod blame;
pub mod browse;
pub mod chain;
/// **You → Clans** (2026-10-05): the person's clan verbs, signed as the
/// person; gunnar decides. See the module.
pub mod clans;
/// **Every disk, for the administrator**: the data set and its members, the
/// system disk, every mount and block device, the exploded read cache and the
/// twins' volumes. `Admin.Capacity { disks: true }` + `StorageInfo` + `Health`.
/// An ADMIN read. See the module.
pub mod disks;
/// **Connecting a forge**: the token door, the OAuth door, and the listing a
/// picker is built from. Reaches no gunnar RPC — see the module.
#[cfg(feature = "forges")]
pub mod forges;
/// Not built (AIRGAP): every forge door is the one refusal that names the `forges` feature.
#[cfg(not(feature = "forges"))]
pub mod forges {
pub use crate::forges::door as authorize;
pub use crate::forges::door as callback;
pub use crate::forges::door as connect;
pub use crate::forges::door as forget;
pub use crate::forges::door as list;
pub use crate::forges::door as repos;
}
/// **The tenant's "more room" panel.** Only under `metered` — the community
/// appliance does not contain it. See the module for the whole argument.
#[cfg(feature = "metered")]
pub mod increase;
pub mod issues;
/// **The machine's storage reality**: what the installer decided, what its
/// filesystems hold now, and where gunnar is configured to put things, read
/// out of `Admin.StorageInfo`'s `reality` field. The appliance has no shell,
/// so this is the only road those facts have. See the module for why the three
/// layers are carried separately, why the install record is carried unparsed,
/// and why nothing in it is ever inferred.
pub mod machine;
/// **Pull mirrors**: import, sync, watch, forget. The `Mirror` service.
pub mod mirrors;
/// **Importing into one's own namespace**: who may, whether it fits, how
/// much at once. See the module.
pub mod mirror_gate;
pub mod policy;
pub mod prs;
/// **The repository's front page, typeset.** The tree walk is `browse`'s and
/// the credential is [`Reader`]'s; what is new is that the answer is a picture.
/// See [`crate::readme`].
pub mod readme;
/// **Any markdown file in a tree, typeset** — the README's pipeline pointed at
/// a path the caller names, plus the PDF of it. The logged-in / anonymous fork
/// is [`crate::reading`]'s, not this module's.
pub mod render;
pub mod refs;
pub mod repos;
/// What THIS person may do on one repository: gunnar's `Access.Check`, asked as them.
pub mod rights;
pub mod search;
/// **"My room"**: the session's own namespace — used, cap, free — and its
/// repositories (`GET /api/account/room`, You → Account → Storage).
pub mod room;
/// **The forge's SSH host key** (`GET /api/server-key`), for the Overview's
/// "Server key SHA256:…" line. See the module.
pub mod server_key;
/// **A customer renewing their own subscription.** Only under `metered`, like
/// [`increase`] and for the same reason — the community appliance sells
/// nothing, so it does not contain this either. The two are the same
/// transaction from opposite sides: `increase` is an operator acting on a
/// tenant's behalf, this is the tenant acting for themselves, and the
/// difference is enforced by gunnar rather than asserted here. See the module.
#[cfg(feature = "metered")]
pub mod subscription;
// The twin: its heartbeat as the primary sees it, and the restore from it.
pub mod twin;
/// [Rebuild main]: the ONE box's guardian, asked for THIS estate's main (main rebuild 2026-10-12).
#[cfg(feature = "metered")]
pub mod rebuild;
/// `Admin.Topology`, carried and drawn by the suite's one estate renderer (2026-10-04).
pub mod topology;
pub mod usage;
// Who may change a repository's visibility: the account admin.
pub mod visibility;
// The account admin gives a member Read/Write on a repository (2026-10-07).
pub mod members;
// The account admin's own surface: the namespace's settings and its employees.
pub mod account_admin;
/// Administration → Identity provider (company SSO, 2026-10-05); feature `sso` (A34-15).
#[cfg(feature = "sso")]
pub mod sso;
/// The owner's enterprise list (2026-10-05): the audit log, the operator's look, the names held.
pub mod enterprise;
use std::time::Duration;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::grpc::{Cached, Fail};
use crate::pb;
/// How long a read is held before it costs another credential.
///
/// Short enough that no operator notices, long enough that a page of panels
/// asking the same question at once costs one call rather than six. gunnar's
/// replay guard remembers 8192 nonces across five minutes for the whole server,
/// and this is what keeps the console well clear of it.
const READ_TTL: Duration = Duration::from_secs(3);
/// How many rows the console renders unless asked otherwise.
const DEFAULT_LIMIT: usize = 250;
/// The most rows one response will carry.
const MAX_LIMIT: usize = 2000;
/// Reads worth holding briefly.
pub struct Caches {
pub health: Cached<admin::Health>,
pub stats: Cached<admin::Stats>,
pub storage: Cached<admin::Storage>,
/// The box — `Admin.Capacity` asked for nothing in particular. One value
/// for the whole server and one caller (this console's key), so the
/// per-namespace, per-caller hazard that keeps `Usage.Read` out of here
/// does not apply.
pub capacity: Cached<usage::Machine>,
pub repos: Cached<Vec<repos::Repo>>,
pub namespaces: Cached<Vec<repos::Namespace>>,
pub principals: Cached<Vec<access::Principal>>,
pub keys: Cached<Vec<access::Key>>,
pub grants: Cached<Vec<access::Grant>>,
/// **Whether any account here has an identity provider** (`Sso.Discover`
/// `any_provider`, round 5.3): the login page's company field. Kept
/// `crate::auth::sso::SSO_OFFERED_FRESH` (feature `sso`).
#[cfg(feature = "sso")]
pub sso_offered: Cached<bool>,
}
impl Default for Caches {
fn default() -> Caches {
Caches {
health: Cached::new(READ_TTL),
stats: Cached::new(READ_TTL),
storage: Cached::new(READ_TTL),
capacity: Cached::new(READ_TTL),
repos: Cached::new(READ_TTL),
namespaces: Cached::new(READ_TTL),
principals: Cached::new(READ_TTL),
keys: Cached::new(READ_TTL),
grants: Cached::new(READ_TTL),
#[cfg(feature = "sso")]
sso_offered: Cached::new(crate::auth::sso::SSO_OFFERED_FRESH),
}
}
}
impl Caches {
/// Drop everything a repository mutation could have changed.
pub async fn invalidate_repos(&self) {
self.repos.invalidate().await;
self.namespaces.invalidate().await;
self.stats.invalidate().await;
self.storage.invalidate().await;
}
/// Drop everything an identity mutation could have changed.
pub async fn invalidate_access(&self) {
self.principals.invalidate().await;
self.keys.invalidate().await;
self.grants.invalidate().await;
self.stats.invalidate().await;
}
}
/// A slice of a list, and how much was left out.
#[derive(Debug, Clone, Serialize)]
pub struct Page<T> {
pub items: Vec<T>,
/// Everything gunnar returned. The console shows "N of M" from this.
pub total: usize,
/// True when `items` is shorter than `total`.
pub truncated: bool,
}
impl<T> Page<T> {
/// Take the first `limit` of `all`, remembering how many there were.
pub fn of(all: Vec<T>, limit: usize) -> Page<T> {
let total = all.len();
let limit = limit.clamp(1, MAX_LIMIT);
let mut items = all;
items.truncate(limit);
Page {
truncated: items.len() < total,
items,
total,
}
}
}
/// `?limit=` on a list request.
#[derive(Debug, Clone, Deserialize)]
pub struct Limit {
#[serde(default)]
pub limit: Option<usize>,
}
impl Limit {
/// The requested limit, or the default.
pub fn get(&self) -> usize {
self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
}
}
/// A refusal on its way to the browser.
#[derive(Debug)]
pub struct ApiError(pub Fail);
impl From<tonic::Status> for ApiError {
fn from(status: tonic::Status) -> ApiError {
let fail = Fail::from(status);
if fail.is_restoring() {
tracing::info!(message = %fail.message, "gunnar's main is being restored; the person is told to try again in a few minutes");
} else if fail.is_clock_skew() {
// Not an identity fault and not the operator's: the front and the
// appliance disagree about the time. Logged apart from the key
// fault below, so the journal does not say "your key is not
// enrolled" about a key that is.
tracing::warn!(
message = %fail.message,
"gunnar refused the console's credential on clock skew; the key is fine, the clocks are not"
);
} else if fail.is_console_identity_problem() {
// Not the operator's problem and not fixable by retrying: the
// console's own key is not enrolled, or lost its grant.
tracing::error!(
code = %fail.code,
message = %fail.message,
"gunnar refused the console's own credential"
);
}
ApiError(fail)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.0.http_status();
if self.0.is_restoring() {
// The console's word, and nothing of gunnar's: the page says "Gunnar is being
// restored. Try again in a few minutes." (`api::Fail::summary`).
return (status, Json(serde_json::json!({ "code": "restoring", "message": "" }))).into_response();
}
if self.0.is_infrastructure_fault() {
// The visitor gets one word; the journal already has the reason
// (`From<tonic::Status>` above logged gunnar's message verbatim).
// Not even the raw message travels in the body: devtools is a
// screen too.
return (status, Json(serde_json::json!({ "code": "maintenance", "message": "" }))).into_response();
}
(status, Json(&self.0)).into_response()
}
}
#[cfg(test)]
mod maintenance_tests {
use super::*;
async fn body_of(r: Response) -> String {
let bytes = axum::body::to_bytes(r.into_body(), 64 * 1024).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn a_clock_skew_refusal_reaches_the_browser_as_maintenance_with_no_internals() {
let r = ApiError::from(tonic::Status::unauthenticated(
"the credential is 7200691 ms away from this server's clock; the window is ±300000 ms",
))
.into_response();
assert_eq!(r.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = body_of(r).await.to_ascii_lowercase();
assert!(body.contains("\"maintenance\""), "{body}");
for leak in ["clock", "credential", "ms away", "unauthenticated", "window"] {
assert!(!body.contains(leak), "internal word {leak:?} reached the browser: {body}");
}
}
/// ★ **"maintenance" is never produced for a condition that is not
/// maintenance.** The word is reserved for the two estate faults
/// `Fail::is_infrastructure_fault` names. monetize refusing an order because
/// the box has no room is neither: it is `FailedPrecondition`, it will not
/// clear by waiting, and the body must carry what the server said.
/// MEASURED 2026-09-20: the owner read "while we carry out maintenance" over
/// a box that was 62 GiB short and nobody was maintaining anything.
#[tokio::test]
async fn a_capacity_refusal_from_monetize_is_not_maintenance() {
let reason = "the product cannot deliver this to rickard: gunnar cannot serve 100.0 GiB of \
stored bytes: /data has 39.8 GiB free, so 37.8 GiB may be served — short 62.1 GiB";
let r = ApiError::from(tonic::Status::failed_precondition(reason)).into_response();
assert_eq!(r.status(), http::StatusCode::BAD_REQUEST);
let body = body_of(r).await;
assert!(
!body.to_ascii_lowercase().contains("maintenance"),
"a full store was dressed as maintenance: {body}"
);
assert!(body.contains("short 62.1 GiB"), "the server's words were dropped: {body}");
}
/// ★ **A write while Gunnar is being restored says so in customer words** (wiring tracer B
/// 2026-10-08). gunnar bddb36d answers a write on a box whose log follows the twin's with
/// UNAVAILABLE "this box is being restored (or is a twin): its transaction log follows the
/// twin's …"; the console passed it to the browser verbatim — "transaction log" and "twin"
/// on a customer's screen during a recharge. The body carries the console's word and none
/// of gunnar's internals; the journal keeps the message.
#[tokio::test]
async fn a_write_refused_while_gunnar_is_restored_reaches_the_browser_as_restoring_with_no_internals() {
let r = ApiError::from(tonic::Status::unavailable(
"this box is being restored (or is a twin): its transaction log follows the twin's and it takes no write until the restore is whole — try again",
))
.into_response();
assert_eq!(r.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = body_of(r).await.to_ascii_lowercase();
assert!(body.contains("\"restoring\""), "{body}");
for leak in ["transaction log", "twin", "follows", "unavailable"] {
assert!(!body.contains(leak), "internal word {leak:?} reached the browser: {body}");
}
}
#[tokio::test]
async fn an_ordinary_refusal_still_carries_gunnars_own_words() {
let r = ApiError::from(tonic::Status::not_found("no such repository")).into_response();
assert_eq!(r.status(), http::StatusCode::NOT_FOUND);
assert!(body_of(r).await.contains("no such repository"));
}
}
/// What a handler returns.
pub type ApiResult<T> = Result<Json<T>, ApiError>;
/// **Which gunnar credential this request is answered with, decided once.**
///
/// Every read handler that a visitor with no session can reach takes one of
/// these instead of reaching for `state.gunnar`, and that is the entire
/// mechanism by which anonymous traffic is kept off the console's admin key.
///
/// # Why an extractor and not a helper function
///
/// Because a helper is something a handler can forget to call, and the failure
/// mode of forgetting is not a compile error or a 500 — it is the private
/// repositories of every tenant, served to a stranger, with a 200. An extractor
/// is in the signature: a handler either has the credential in its hands or it
/// does not have one at all. `state.gunnar` remains available and remains
/// correct for the handlers that are behind a session, so this is not a rule to
/// remember, it is a fork in the road with both branches named.
///
/// # Why the session's ROLE is the input, and not merely `Caller`'s presence
///
/// [`crate::auth::middleware::require_auth`] inserts a `Caller` extension when
/// and only when it verified a session or the admin token, and it lets an
/// anonymous request through only for the two allow-listed read surfaces. So
/// "no `Caller` in the extensions" is not a guess about who is calling — it is
/// the middleware's own decision, read back.
///
/// Until 2026-09-11 that presence was the WHOLE input: any `Caller` at all got
/// `state.gunnar`. Measured against the fake gunnar in [`crate::anonymous`]: a
/// session minted with `Role::Viewer` — which is exactly what a paid signup
/// mints (`auth::signup`) and what an invite may mint — read a **private**
/// repository in a namespace it has nothing to do with, and the fingerprint on
/// the wire was the console's server-wide admin key. `role_floor` could not
/// help: every GET is a viewer's by the table, whatever namespace the path
/// names.
///
/// So the credential is chosen by what the session may do, and the two
/// credentials this console holds map onto the ladder:
///
/// * **admin / operator** — `state.gunnar`. The console's key does not exceed
/// what these sessions may already do through it: an operator's POST deletes
/// a repository in any namespace and an admin's hands out grants, both signed
/// with this same key. Narrowing only their READS would leave the console
/// showing an operator less than it lets them destroy.
/// * **viewer** — **the viewer's OWN key**, minted by this console and enrolled
/// on that person's own gunnar principal ([`crate::tenant`]). A viewer is the
/// tenant role: the lowest rung, minted by payment, and not somebody this
/// console may lend its admin grant to — but also somebody who is PAYING to
/// see their own private repositories. Signing as them is the only credential
/// that is both: gunnar applies their grants, on gunnar's side of the wire.
/// * **a viewer this console cannot name at gunnar** — `state.guest`, the
/// grantless identity, and the same narrowing gunnar performs for a stranger.
/// That is a console user with no gunnar principal: there is nothing that
/// would be their credential, and the console will not invent one on a GET.
/// It is also where every failure lands, because the guest is the narrowest
/// thing this console holds.
#[derive(Clone)]
pub struct Reader {
/// The channel to sign this request's control-plane calls with.
pub gunnar: crate::grpc::Gunnar,
/// True when this request is answered by the GUEST identity — whether
/// because nobody is logged in, or because who is logged in is a viewer.
///
/// Read by the handlers that hold a cache. It is NOT read to decide what
/// may be served — gunnar decides that — only to decide what may be
/// *reused*, which is a different question with a different wrong answer.
/// It follows the CHANNEL and not the session, because the cache holds the
/// admin's answer and the hazard is being handed one you did not ask for.
pub guest: bool,
/// True when this request is answered by the SESSION'S OWN key — a
/// credential enrolled on the caller's gunnar principal.
///
/// Never true at the same time as `guest`: they are two of the three
/// credentials this console can reach for, and [`Reader::narrowed`] is the
/// question both of them answer.
pub tenant: bool,
/// True when NOBODY is logged in — no `Caller` at all.
///
/// Not the same question as `guest`, and the merge that brought the two
/// together is where the difference started to matter. `guest` follows the
/// CHANNEL and is true for a logged-in viewer as well; this one follows the
/// SESSION. The concurrency ceiling in [`search`] is keyed on it: the
/// ceiling exists because an unauthenticated GET is free to repeat, and a
/// viewer's is not — theirs is behind a passkey and an invite, and can be
/// revoked by name.
pub anonymous: bool,
}
impl Reader {
/// **May this request's answer come out of, or go into, a shared cache?**
///
/// No, for an anonymous request. The console's caches
/// ([`Caches::repos`] and friends) hold one value for the whole server with
/// no key for who asked, because until now there was only ever one asker:
/// the console's own admin principal. A guest reading `Caches::repos` would
/// be handed the admin's answer — every private repository on the box —
/// without a single gRPC call being made, and gunnar would never learn it
/// had been asked. That is the same disclosure the guest key exists to
/// prevent, arriving through the side door.
///
/// Bypassing rather than partitioning, because a second cache is a second
/// thing to invalidate and the cost here is one gRPC call on a page a
/// stranger loads. [`crate::git::ObjectCache`] is the exception and is left
/// shared on purpose — see [`crate::git::Repository::read_object`].
pub fn may_cache(&self) -> bool {
!self.narrowed()
}
/// **Is this answer narrower than the console's own view of the server?**
///
/// True for the guest and true for a tenant, and it is the question every
/// caller actually has. A tenant credential is not the guest — it holds
/// that person's grants and sees their private repositories — but it is
/// just as much NOT the console's key, so anything keyed on "may this be
/// shared" or "must this be filtered per subscriber" wants this and not
/// `guest`. [`crate::sse::events`] is the second caller.
pub fn narrowed(&self) -> bool {
self.guest || self.tenant
}
}
/// The guest channel, or the refusal that says why there is none.
///
/// One function for the two callers that reach for it — the visitor with no
/// session and the viewer whose session does not buy the console's grant —
/// because the answer is the same channel and the only thing that differs is
/// what to say when it is absent. **There is no fallback to `state.gunnar`
/// here, and there must never be one**: falling back on a misconfiguration is
/// how a console serves every private repository on the box and looks like it
/// is working.
///
/// `permissiondenied` and not an invented code: [`Fail`] carries gunnar's
/// vocabulary and `http_status` maps this one to 403, which is the truth — the
/// console will not act for a caller it cannot name. A 401 would send the
/// browser to a login it does not need, or already has.
fn guest_reader(state: &crate::app::AppState, anonymous: bool) -> Result<Reader, ApiError> {
match state.guest.clone() {
Some(gunnar) => Ok(Reader {
gunnar,
guest: true,
tenant: false,
anonymous,
}),
None if anonymous => Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "this console has no guest identity, so it serves nothing anonymously"
.to_owned(),
})),
None => Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "this console has no guest identity, so a viewer's reads cannot be answered \
at all; set GUNNAR_UI_GUEST_IDENTITY, or give this session the operator role"
.to_owned(),
})),
}
}
tokio::task_local! {
/// **The signed-in person of the request the gate let through**
/// (`auth::middleware::require_auth`): what [`person_identity`] acts as
/// for a handler that names the person and holds no [`Caller`] of its own.
///
/// [`Caller`]: crate::auth::middleware::Caller
pub(crate) static PERSON: crate::auth::Session;
}
/// The request's signed-in person ([`PERSON`]), when the gate found one.
pub(crate) fn current_person() -> Option<crate::auth::Session> {
PERSON.try_with(Clone::clone).ok()
}
/// **The credential a session's person acts with**: their identity-core
/// session (IDENTITY-HOST, A34-18) — every call carries the core's token and
/// gunnar's gate asks the core who it is, so gunnar applies THEIR grants. The
/// console holds no key of theirs.
///
/// **Test fixtures only**: a session a test planted
/// (`Webauthn::make_session`) has no core behind it; pinned to a principal
/// (`plant_bound_credential_for_tests`) it stands on a key enrolled through
/// the fake's `AddKey` ([`crate::tenant::TenantKeys::ensure`]), the stand-in
/// for gunnar knowing the person. Never compiled outside `cfg(test)`.
pub(crate) async fn identity_of(
state: &crate::app::AppState,
session: &crate::auth::Session,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
#[cfg(test)]
if crate::auth::Webauthn::is_planted(&session.token) {
return planted_identity(state, &session.user).await;
}
Ok(Some(crate::tenant::TenantIdentity {
principal_id: session.user.clone(),
// A session holds no key at gunnar: nothing of the person's key list
// is this console's.
fingerprint: String::new(),
gunnar: state.gunnar.as_session(&session.token),
signer: state.gunnar.signer(),
}))
}
#[cfg(test)]
async fn planted_identity(
state: &crate::app::AppState,
user: &str,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
match state.webauthn.as_ref().and_then(|webauthn| webauthn.principal_of(user)) {
Some(principal) => state.tenants.ensure(user, &principal, &state.gunnar).await.map(Some),
None => Ok(None),
}
}
/// [`identity_of`] the request's signed-in person ([`PERSON`]), when that is
/// `user`; `None` when the request carries no session of theirs.
pub(crate) async fn person_identity(
state: &crate::app::AppState,
user: &str,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
if let Some(session) = PERSON.try_with(Clone::clone).ok().filter(|s| s.user == user) {
return identity_of(state, &session).await;
}
#[cfg(test)]
if state.webauthn.as_ref().is_some_and(|w| w.principal_of(user).is_some()) {
return planted_identity(state, user).await;
}
Ok(None)
}
/// **The session's own credential, or the guest — never the console's.**
///
/// The person's session key ([`person_identity`]): gunnar named the principal
/// when it verified their passkey, and their reads are signed with the key it
/// enrolled there, so gunnar applies THEIR grants. This asks gunnar nothing
/// about who the caller is.
///
/// Two ways to end up on the guest instead, and they are the same shape —
/// *this console cannot answer as you, so it answers as nobody*:
///
/// * the session holds no live session key here;
/// * the state store refused, or the key was unusable. Logged at `warn` with
/// the reason, because a console quietly serving the public subset to a
/// paying tenant is a support ticket nobody can diagnose from the outside.
///
/// **There is no arm here that falls back to `state.gunnar`**, and there must
/// never be one, for the reason stated on [`crate::app::App::guest`]: the
/// fallback IS the hole.
pub(crate) async fn tenant_reader(state: &crate::app::AppState, user: &str) -> Result<Reader, ApiError> {
reader_from(state, user, person_identity(state, user).await)
}
/// [`tenant_reader`] for the session the gate found.
pub(crate) async fn session_reader(state: &crate::app::AppState, session: &crate::auth::Session) -> Result<Reader, ApiError> {
reader_from(state, &session.user, identity_of(state, session).await)
}
fn reader_from(
state: &crate::app::AppState,
user: &str,
identity: Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError>,
) -> Result<Reader, ApiError> {
match identity {
Ok(Some(identity)) => Ok(Reader {
gunnar: identity.gunnar,
guest: false,
tenant: true,
anonymous: false,
}),
Ok(None) => guest_reader(state, false),
Err(why) => {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.credential_unavailable",
user = %user,
reason = %why,
"this console could not sign as the person, so their reads are answered \
by the grantless guest and show only the public subset"
);
guest_reader(state, false)
}
}
}
impl axum::extract::FromRequestParts<crate::app::AppState> for Reader {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut http::request::Parts,
state: &crate::app::AppState,
) -> Result<Reader, ApiError> {
match parts.extensions.get::<crate::auth::middleware::Caller>() {
// The break-glass token: bootstrap, before any passkey exists — the
// console's own key, which is what that token stands for.
Some(crate::auth::middleware::Caller::AdminToken) => Ok(Reader {
gunnar: state.gunnar.clone(),
guest: false,
tenant: false,
anonymous: false,
}),
// **Every passkey session reads as its person — an operator and an
// administrator included** (thin layer, owner 2026-10-04: "roles →
// gunnar grants"). Their console role IS their server-wide grant at
// gunnar, so their own key reads what the role reads, and gunnar's
// audit names them rather than the console. Answered as the guest
// where this console cannot name them at gunnar.
#[cfg(test)]
Some(caller) if console_stand_in(state, caller) => Ok(Reader {
gunnar: state.gunnar.clone(),
guest: false,
tenant: false,
anonymous: false,
}),
Some(crate::auth::middleware::Caller::Operator(session)) => session_reader(state, session).await,
// The open-console posture (`trunk serve` on loopback, no passkey
// and no admin token) has no `Caller` either, and there it means the
// opposite thing: everybody is admin. Told apart by asking the state
// rather than by inspecting the request, because it is a fact about
// how the process was started.
None if !state.passkey_enabled() && state.admin_token.is_none() => Ok(Reader {
gunnar: state.gunnar.clone(),
guest: false,
tenant: false,
anonymous: false,
}),
None => guest_reader(state, true),
}
}
}
/// **The credential a session ACTS with** (thin layer, owner 2026-10-04).
///
/// [`Reader`] is the read half; this is the half that changes something. The
/// console does not decide whether a person may set a repository's
/// visibility, their account's settings, enroll an employee or point a
/// repository at a remote: it signs the call AS THE PERSON and gunnar's grant
/// table, groups and account gates decide — the same answer the person would
/// get from `gunnar control …` with their own key.
///
/// * the box's administrator (an admin session, the break-glass token, the
/// open console) signs with the console's own key, which holds the
/// server-wide `Admin` that role stands for — until console roles are
/// grants on the person's own principal;
/// * every other session signs with the person's own key, enrolled on the
/// principal their passkey is BOUND to ([`crate::tenant::TenantKeys`]) —
/// an operator included: an operator's console role is not an account's
/// admin;
/// * a session bound to no principal, or one this console cannot sign for, is
/// refused here — **never the guest**: a mutation signed by nobody is not
/// something gunnar should be asked to refuse.
#[derive(Clone)]
pub struct Actor {
/// The client that signs AS this session's person.
pub gunnar: crate::grpc::Gunnar,
/// Who acted, for the audit line.
pub who: String,
/// True when the console's own key signs (the box's administrator).
pub console: bool,
/// The principal the person's key is enrolled on; `None` for the console.
pub principal: Option<String>,
}
impl std::fmt::Debug for Actor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Actor")
.field("who", &self.who)
.field("console", &self.console)
.field("principal", &self.principal)
.finish()
}
}
/// `principal_id` for gunnar's lists that means **every principal**: only an
/// administrator asks for it, by this name. An empty id is gunnar's "all", so
/// an unknown principal must never fall back to it.
pub const EVERY_PRINCIPAL: &str = "";
impl Actor {
/// **The principal this person's own key is enrolled on**, for a lookup of
/// what is theirs. `None` (the console's own key: the break-glass token,
/// the open console) is refused — never `""`, which gunnar reads as every
/// principal.
pub fn own_principal(&self) -> Result<String, ApiError> {
self.principal.clone().filter(|p| !p.is_empty()).ok_or_else(|| {
ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "This sign-in is bound to no gunnar principal, so it has no passkeys of its own to list."
.to_owned(),
})
})
}
/// The console's own key, for the box's administrator.
fn console(state: &crate::app::AppState, who: &str) -> Actor {
Actor {
gunnar: state.gunnar.clone(),
who: who.to_owned(),
console: true,
principal: None,
}
}
/// The person's own credential — their identity-core session — or the
/// refusal that says why there is none.
pub(crate) async fn person(state: &crate::app::AppState, session: &crate::auth::Session) -> Result<Actor, ApiError> {
Actor::person_from(&session.user, identity_of(state, session).await)
}
/// [`Actor::person`] for the request's signed-in person named `user`
/// ([`PERSON`]): a handler that holds a name and no session.
pub(crate) async fn person_named(state: &crate::app::AppState, user: &str) -> Result<Actor, ApiError> {
Actor::person_from(user, person_identity(state, user).await)
}
fn person_from(
user: &str,
identity: Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError>,
) -> Result<Actor, ApiError> {
match identity {
Ok(Some(identity)) => Ok(Actor {
gunnar: identity.gunnar,
who: user.to_owned(),
console: false,
principal: Some(identity.principal_id),
}),
Ok(None) => Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "Your sign-in holds no key at gunnar any more, so this console cannot \
act as you; log in again."
.to_owned(),
})),
Err(why) => {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.credential_unavailable",
user = %user,
reason = %why,
"this console could not sign as the person, so their act was not sent"
);
Err(ApiError(Fail {
code: "unavailable".to_owned(),
message: format!(
"This console could not sign as you just now, so nothing was changed: \
{why}"
),
}))
}
}
}
}
impl axum::extract::FromRequestParts<crate::app::AppState> for Actor {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut http::request::Parts,
state: &crate::app::AppState,
) -> Result<Actor, ApiError> {
Actor::of(state, parts.extensions.get::<crate::auth::middleware::Caller>()).await
}
}
impl Actor {
/// The [`Actor`] for `caller`, for a handler that needs one only on some
/// requests (a PATCH that touches visibility and not just the branch).
pub(crate) async fn of(
state: &crate::app::AppState,
caller: Option<&crate::auth::middleware::Caller>,
) -> Result<Actor, ApiError> {
match caller {
// The break-glass token: bootstrap — the console's own key.
Some(crate::auth::middleware::Caller::AdminToken) => {
Ok(Actor::console(state, crate::auth::middleware::Caller::AdminToken.name()))
}
#[cfg(test)]
Some(caller) if console_stand_in(state, caller) => Ok(Actor::console(state, caller.name())),
// Every passkey session acts as its person — the administrator
// too: their role is their server-wide grant at gunnar.
Some(crate::auth::middleware::Caller::Operator(session)) => Actor::person(state, session).await,
None if !state.passkey_enabled() && state.admin_token.is_none() => {
Ok(Actor::console(state, crate::forges::OPEN_CONSOLE))
}
None => Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "this console acts only for a session it can name".to_owned(),
})),
}
}
}
/// **May the caller of `gunnar` do `level` on `namespace` / `repo`?** —
/// gunnar's own `Access.Check`, asked AS the caller (thin layer, owner
/// 2026-10-04). The same decision every verb's own check makes (grants,
/// groups, account ownership, the gate), asked without acting; the page draws
/// a control from it and the verb, signed as the same person, is decided by
/// gunnar again. `Err` is gunnar not answering — never "no".
///
/// `Ok((allowed, reason))`: `reason` is gunnar's refusal sentence when not
/// allowed, empty when allowed.
pub async fn may(
gunnar: &crate::grpc::Gunnar,
namespace: &str,
repo: &str,
level: pb::GrantLevel,
) -> Result<(bool, String), ApiError> {
let answer = gunnar
.access()
.check(pb::CheckRequest {
namespace: namespace.to_owned(),
repo: repo.to_owned(),
level: level as i32,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok((answer.allowed, answer.reason))
}
/// **Test fixtures only**: a session a test minted directly
/// (`Webauthn::make_session`) at operator or admin, bound to no principal,
/// reads and acts with the console's own key — the stand-in for "this person's
/// key holds a server-wide grant" in the many fixtures that model gunnar as
/// "the console's key may do everything". Never compiled outside `cfg(test)`;
/// production has no such arm.
#[cfg(test)]
fn console_stand_in(state: &crate::app::AppState, caller: &crate::auth::middleware::Caller) -> bool {
let crate::auth::middleware::Caller::Operator(session) = caller else {
return false;
};
let Some(webauthn) = state.webauthn.as_ref() else {
return false;
};
webauthn
.planted_role(&session.user)
.is_some_and(|role| role.allows(crate::role::Role::Operator))
&& webauthn.principal_of(&session.user).is_none()
}
/// **A caller who may hold a forge token and import repositories** — any
/// session that can be NAMED, since 2026-10-02.
///
/// An extractor for the reason [`Reader`] and `buy_site::GunnarSite::operator` (was `increase::Ordering`) are: a
/// helper is something a handler can forget to call, and forgetting here means
/// a visitor with no session at all reaches a surface that holds somebody's
/// GitHub credential and points repositories at remotes. A handler either has
/// one of these in its signature or it has no name to key a connection by.
///
/// # Who may do what, and where that is decided
///
/// Until 2026-10-02 this was an ADMIN floor, because the console calls gunnar
/// with its own server-wide admin key and gunnar cannot tell one browser
/// session from another. The reason stands; the floor moved. An ordinary user
/// may now import into a namespace where THEIR principal holds write or admin,
/// and that is checked against gunnar's own grant table by
/// [`mirror_gate`] before `Mirror.Add` is signed — so this extractor carries
/// what the check needs ([`Importer::admin`], [`Importer::principal`]) and
/// decides nothing itself. An administrator's session keeps today's full,
/// box-wide power: [`Importer::admin`] is true and the grant check is skipped.
///
/// Connecting a forge (`/api/forges`) needs only a name: the connection is the
/// person's own, keyed to their session in [`crate::forges::ForgeSessions`],
/// and reaches no gunnar RPC.
///
/// # Anonymous gets nothing, and the refusal is here
///
/// None of these paths is on `is_anonymous_read` or `is_anonymous_page`, so a
/// request with no session is refused by the middleware before a handler
/// exists. This is the second wall, and it is the one that would still hold if
/// somebody added a mirror route to an allow-list by mistake: with no `Caller`
/// and no open-console posture there is no owner to key a connection by, so the
/// refusal is a fact about the request rather than a rule to remember. The
/// guest identity is **never** reached from any of these handlers.
#[derive(Debug, Clone)]
pub struct Importer {
/// The session this connection belongs to, and the name in the audit line.
/// Also the key into [`crate::forges::ForgeSessions`] and into
/// [`mirror_gate::Imports`].
pub who: String,
/// True for an administrator's session, the break-glass token and the
/// open console: the box-wide view and no per-namespace check.
pub admin: bool,
/// The gunnar principal this session's passkey is BOUND to, read out of
/// the console's own table and never resolved from a name (GUI-001).
/// `None` for the break-glass token, the open console, and a passkey bound
/// to nobody — which may then import nowhere unless it is an admin.
pub principal: Option<String>,
}
impl axum::extract::FromRequestParts<crate::app::AppState> for Importer {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut http::request::Parts,
state: &crate::app::AppState,
) -> Result<Importer, ApiError> {
match parts.extensions.get::<crate::auth::middleware::Caller>() {
Some(caller) => {
let principal = match caller {
crate::auth::middleware::Caller::Operator(session) => state
.webauthn
.as_ref()
.and_then(|webauthn| webauthn.principal_of(&session.user)),
crate::auth::middleware::Caller::AdminToken => None,
};
Ok(Importer {
who: caller.name().to_owned(),
admin: caller.role().allows(crate::role::Role::Admin),
principal,
})
}
// The open-console posture: no passkey and no admin token, which
// `open_console_is_confined` has already restricted to loopback.
// Told apart by asking the state rather than the request, because
// it is a fact about how the process was started.
None if !state.passkey_enabled() && state.admin_token.is_none() => Ok(Importer {
who: crate::forges::OPEN_CONSOLE.to_owned(),
admin: true,
principal: None,
}),
None => Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "importing repositories needs a session that can be named".to_owned(),
})),
}
}
}
/// **A caller with a session of any role**, for the reads that are not an act
/// of administration.
///
/// Seeing that a mirror exists and where its sync got to is a reader's
/// business, exactly as `GcStatus` is: `role_floor` already puts every GET at
/// [`crate::role::Role::Viewer`], and this adds only the half that table cannot
/// express — that the guest identity must never answer one of these. An
/// anonymous request has no `Caller`, gets no `SignedIn`, and is refused
/// without gunnar being dialled at all.
#[derive(Debug, Clone)]
pub struct SignedIn {
/// Who is reading, for the audit line and for keying a connection.
pub who: String,
}
impl axum::extract::FromRequestParts<crate::app::AppState> for SignedIn {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut http::request::Parts,
state: &crate::app::AppState,
) -> Result<SignedIn, ApiError> {
if let Some(caller) = parts.extensions.get::<crate::auth::middleware::Caller>() {
return Ok(SignedIn {
who: caller.name().to_owned(),
});
}
if !state.passkey_enabled() && state.admin_token.is_none() {
return Ok(SignedIn {
who: crate::forges::OPEN_CONSOLE.to_owned(),
});
}
Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "this console does not answer mirror questions anonymously".to_owned(),
}))
}
}
/// A Unix-millisecond timestamp, or `None` when gunnar did not record one.
///
/// Several of gunnar's timestamps are never filled in — principals, grants and
/// namespaces all arrive with a zero — and proto3 cannot tell "unset" from
/// "zero". Passing that through renders as January 1970, which in a relative
/// clock reads as "689 months ago" next to a row created two minutes back.
/// gunnar was not running in 1970, so zero means unrecorded.
pub fn recorded(unix_ms: i64) -> Option<i64> {
(unix_ms > 0).then_some(unix_ms)
}
/// `<namespace>/<repo>` as a path gunnar understands.
pub fn repo_path(namespace: &str, repo: &str) -> pb::RepoPath {
pb::RepoPath {
namespace: namespace.to_owned(),
repo: repo.to_owned(),
}
}
/// The lowercase name of a hash kind, or `"unspecified"`.
pub fn hash_kind_name(value: i32) -> &'static str {
match pb::HashKind::try_from(value) {
Ok(pb::HashKind::Sha1) => "sha1",
Ok(pb::HashKind::Sha256) => "sha256",
_ => "unspecified",
}
}
/// The lowercase name of a visibility.
pub fn visibility_name(value: i32) -> &'static str {
match pb::Visibility::try_from(value) {
Ok(pb::Visibility::Private) => "private",
Ok(pb::Visibility::Internal) => "internal",
Ok(pb::Visibility::Public) => "public",
_ => "unspecified",
}
}
/// Parse a visibility name back into gunnar's enum.
pub fn visibility_from_name(name: &str) -> Option<pb::Visibility> {
match name {
"private" => Some(pb::Visibility::Private),
"internal" => Some(pb::Visibility::Internal),
"public" => Some(pb::Visibility::Public),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_page_reports_what_it_left_out() {
// gunnar hands over everything; the count is the only honest thing the
// console can say about what it is not showing.
let page = Page::of((0..500).collect::<Vec<_>>(), 100);
assert_eq!(page.items.len(), 100);
assert_eq!(page.total, 500);
assert!(page.truncated);
}
#[test]
fn a_short_list_is_not_marked_truncated() {
let page = Page::of(vec![1, 2, 3], 100);
assert_eq!(page.total, 3);
assert!(!page.truncated);
}
#[test]
fn a_limit_cannot_be_zero_or_unbounded() {
assert_eq!(Limit { limit: Some(0) }.get(), 1);
assert_eq!(
Limit {
limit: Some(usize::MAX)
}
.get(),
MAX_LIMIT
);
assert_eq!(Limit { limit: None }.get(), DEFAULT_LIMIT);
assert_eq!(Page::of(vec![0u8; 5000], 0).items.len(), 1);
}
#[test]
fn enum_names_round_trip() {
for name in ["private", "internal", "public"] {
let parsed = visibility_from_name(name).expect("a known name");
assert_eq!(visibility_name(parsed as i32), name);
}
assert_eq!(visibility_from_name("secret"), None);
}
#[test]
fn an_unknown_enum_value_is_named_rather_than_guessed() {
assert_eq!(hash_kind_name(99), "unspecified");
assert_eq!(visibility_name(0), "unspecified");
}
#[test]
fn an_unrecorded_timestamp_is_absent_rather_than_1970() {
// Observed against a real gunnar: principals and grants created moments
// ago carry a zero, which a relative clock renders as "689 months ago".
assert_eq!(recorded(0), None);
assert_eq!(recorded(-1), None);
assert_eq!(recorded(1_700_000_000_000), Some(1_700_000_000_000));
}
}