crates/gunnar-grpc/src/service/access.rs
//! `Access`: principals, public keys and grants (the ACL model).
//!
//! This is the service authentication matters most for. Every RPC here reads
//! or mutates who may read or write which repository over SSH. It is how the
//! key table the SSH listener authenticates against is *written*, so every one
//! of them takes **Admin**, and the identity-table reads take Admin too: a
//! principal list and a key list are the reconnaissance half of the same
//! attack.
//!
//! `Grant` and `Revoke` scope to what they hand out: a namespace grant needs
//! Admin on that namespace, a repo grant needs Admin on that repo. A tenant
//! administrator can therefore manage their own tenant without being a server
//! administrator (multi-tenancy).
//!
//! **The self-scope** (owner, 2026-10-04): a principal manages its OWN keys —
//! `AddKey`, `ListKeys` and `RevokeKey` on itself — with no grant at all
//! (`ServiceContext::require_self`). Everything about anybody else is still a
//! server Admin's.
use gunnar_proto::access_server::Access;
use gunnar_proto::{
AddCredentialRequest, AddCredentialResponse, AddGroupMemberRequest, AddGroupMemberResponse,
AddKeyRequest, AddKeyResponse, AddPrincipalRequest, AddPrincipalResponse,
AddUnlockMethodRequest, AddUnlockMethodResponse, ApproveMachineEnrolmentRequest,
ApproveMachineEnrolmentResponse, CheckRequest, CheckResponse, CreateEncryptionKeyRequest,
CreateEncryptionKeyResponse, GetEncryptionKeyRequest, GetEncryptionKeyResponse, GrantLevel,
GrantRequest, GrantResponse, ListCredentialsRequest, ListCredentialsResponse,
ListGrantsRequest, ListGrantsResponse, ListGroupMembersRequest, ListGroupMembersResponse,
ListGroupMembershipEventsRequest, ListGroupMembershipEventsResponse, ListKeysRequest,
ListKeysResponse, ListMachineEnrolmentsRequest, ListMachineEnrolmentsResponse,
ListPrincipalsRequest, ListPrincipalsResponse, MachineEnrolmentStatusRequest,
MachineEnrolmentStatusResponse, MembershipAction, PrincipalKind, RefuseMachineEnrolmentRequest,
RefuseMachineEnrolmentResponse, RemoveGroupMemberRequest, RemoveGroupMemberResponse,
RemovePrincipalRequest, RemovePrincipalResponse, RemoveUnlockMethodRequest,
RemoveUnlockMethodResponse, RenameCredentialRequest, RenameCredentialResponse,
RenamePrincipalRequest, RenamePrincipalResponse, ReplaceEncryptionKeyRequest,
ReplaceEncryptionKeyResponse, RequestMachineEnrolmentRequest, RequestMachineEnrolmentResponse,
RevokeCredentialRequest, RevokeCredentialResponse, RevokeKeyRequest, RevokeKeyResponse,
RevokeRequest, RevokeResponse, SignedMembershipEvent, WhoAmIRequest, WhoAmIResponse,
};
use tonic::{Request, Response, Status};
use crate::auth::{Access as Scope, Caller};
use crate::service::AccessService;
use crate::status::opt_str;
/// **The names machines sign their own calls under** (Stage L, 2026-10-04):
/// `console-*` (the console's per-user principals of old), `gunnar-ui` (the
/// console itself) and `uitest-console` (its test rig's) — and, since
/// 2026-10-05, `kit-*`: the machine an approved enrolment makes for an ops
/// kit (`Access.ApproveMachineEnrolment`). A person, a group or a deploy key
/// may never carry one, as its id or as its name — otherwise an account admin
/// enrolls `console-<x>` (or `kit-<x>`) and whatever trusts the name trusts
/// them. Compared case-insensitively. A MACHINE principal may: the console's
/// own is made exactly so, by a server admin, and a kit's by an approval.
pub fn is_reserved_principal_name(text: &str) -> bool {
let text = text.trim().to_ascii_lowercase();
text.starts_with("console-")
|| text.starts_with(crate::machine_enrolment::MACHINE_PREFIX)
|| text == "gunnar-ui"
|| text == "uitest-console"
}
/// The refusal for [`is_reserved_principal_name`], naming the rule.
pub fn reserved_name_refusal(text: &str, what: &str) -> Status {
Status::invalid_argument(format!(
"{text:?} is a reserved name — `console-*`, `gunnar-ui` and `uitest-console` are what a \
console signs its own calls under, and `kit-*` what an approved ops kit does — so \
{what} may not carry it; only a machine principal made by a server admin (or an \
approval) may"
))
}
/// **An encryption public key's fingerprint**: `SHA256:` and unpadded base64 of SHA-256 over
/// the key's text, the one `gunnar_crypt::door::fingerprint` gives (CRYPT-PQ-REKEY audit).
fn key_fingerprint(public_key: &str) -> String {
use base64::Engine as _;
use sha2::Digest as _;
format!(
"SHA256:{}",
base64::engine::general_purpose::STANDARD_NO_PAD
.encode(sha2::Sha256::digest(public_key.trim().as_bytes()))
)
}
/// The audit line for every request a sweep found expired.
fn audit_expired(expired: crate::machine_enrolment::Expired) {
for row in expired {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.machine_enrolment_expired",
request = %row.request_id,
machine = %row.machine,
code = %row.code,
"a machine enrolment request expired with nobody's approval"
);
}
}
/// The role a request asks for, or an approver gives: ADMIN, a console's
/// operator (WRITE), or READ. **A request that names none asks for the
/// lowest, READ** (owner order 2026-10-18): the asking door needs no login,
/// so its default must never be a power; the approving admin grants more.
fn machine_level(level: i32) -> Result<GrantLevel, Status> {
if level == GrantLevel::Unspecified as i32 {
return Ok(GrantLevel::Read);
}
GrantLevel::try_from(level)
.ok()
.filter(|l| matches!(l, GrantLevel::Admin | GrantLevel::Write | GrantLevel::Read))
.ok_or_else(|| {
Status::invalid_argument(
"`server_level`: ADMIN, WRITE (a console's operator) or READ — a machine credential \
is asked for to administer, operate or read the box",
)
})
}
/// The longest a web session key lives: thirty days.
pub const WEB_SESSION_MAX_MS: i64 = 30 * 86_400_000;
/// A web ceremony gunnar would not accept, as the refusal the relay gets —
/// and one WARN line, because a refused passkey is what an operator looks
/// for after "I cannot log in".
fn ceremony_refused(method: &'static str, why: String) -> Status {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "access.web_ceremony_refused",
method,
reason = %why,
"a web ceremony was refused"
);
Status::permission_denied(format!("{method} refused: {why}"))
}
/// How long a first-login link lives when nobody says: seven days.
pub const INVITE_DEFAULT_MS: i64 = 7 * 86_400_000;
/// The longest a link may live: thirty days.
pub const INVITE_MAX_MS: i64 = 30 * 86_400_000;
impl AccessService {
/// **A34-24: `require_sso` on a credential an employee mints for
/// themself** ([`crate::sso::mint_refused`]): from the session key of a
/// sign-in through the IdP, or the identity-core session such a sign-in
/// minted (its persisted admission, [`crate::identity::GunnarStore::sso_admitted_fingerprint`]), yes; from any other key of
/// theirs (an SSH key, a passkey login's core session: both bypass the
/// IdP), refused by name and audited. An admin's mint
/// for somebody else is the admin's word and is not asked here.
fn refuse_a_mint_past_the_idp(
&self,
caller: &Caller,
method: &'static str,
) -> Result<(), Status> {
let Some(plane) = self.ctx.control.sso() else {
return Ok(());
};
let required = plane.passkey_login_refused(&caller.principal_id)?;
if required.is_none() {
return Ok(());
}
// An identity-core session (`session:…`) is the IdP's only when the sign-in
// through it vouched for it; a passkey login's core session bypasses the IdP.
let on_idp_session = if caller.fingerprint.starts_with("session:") {
// RA5-38: the persisted admission, the one record the gate reads too.
self.ctx.identity.as_ref().is_some_and(|i| {
i.host()
.gunnar_store()
.sso_admitted_fingerprint(&caller.fingerprint)
})
} else {
self.ctx
.control
.list_keys(Some(&caller.principal_id))?
.iter()
.any(|k| {
k.fingerprint == caller.fingerprint
&& crate::sso::is_session_key(
k.added_at_unix_ms,
k.expires_at_unix_ms,
WEB_SESSION_MAX_MS,
)
})
};
match crate::sso::mint_refused(required, on_idp_session) {
None => Ok(()),
Some(why) => {
crate::auth::audit_denied(
method,
caller,
&Scope::Server,
GrantLevel::Admin,
self.ctx.config.auth,
&why,
self.ctx.ledger(),
);
Err(Status::permission_denied(why))
}
}
}
/// **Rows of people inside an account closed to the caller's server
/// grant, dropped** (2026-10-05, the operator sees aggregates only).
/// `owners_shown`: the account's own person is kept — its id is the
/// account's name, which the operator addresses to gate, bill or recover
/// it; what is hidden is the account's MEMBERS (its employees).
fn members_hidden<T>(
&self,
caller: &Caller,
rows: Vec<T>,
principal_of: impl Fn(&T) -> String,
owners_shown: bool,
) -> Result<Vec<T>, Status> {
if self.ctx.control.enterprise().is_none() {
return Ok(rows);
}
let mut kept = Vec::with_capacity(rows.len());
let mut known: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
for row in rows {
let principal = principal_of(&row);
let hidden = match known.get(&principal) {
Some(hidden) => *hidden,
None => {
let hidden = match self.ctx.account_of_principal(&principal) {
Some(account) if owners_shown && account == principal => false,
Some(account) => self.ctx.shielded(caller, &account)?,
None => false,
};
known.insert(principal, hidden);
hidden
}
};
if !hidden {
kept.push(row);
}
}
Ok(kept)
}
/// **The box keeps one admin who can log in.** Refuse to revoke a passkey
/// when it is the last one held by any principal with a server-wide Admin
/// — the rule the console kept in its own store until passkeys came to
/// gunnar. A passkey of a principal who is not an admin is never held
/// back by this.
fn refuse_the_last_admin_passkey(&self, credential_id: &[u8]) -> Result<(), Status> {
let Some(stored) = self.ctx.control.credential(credential_id)? else {
return Ok(());
};
let is_admin = |principal: &str| {
self.ctx
.control
.may(principal, &Scope::Server, GrantLevel::Admin)
.map_err(|err| {
Status::unavailable(format!("the identity table could not be read: {err}"))
})
};
if !is_admin(&stored.credential.principal_id)? {
return Ok(());
}
let mut admins: std::collections::BTreeMap<String, bool> =
std::collections::BTreeMap::new();
for other in self.ctx.control.credentials(None)? {
if other.credential_id == stored.credential.credential_id {
continue;
}
let admin = match admins.get(&other.principal_id) {
Some(known) => *known,
None => {
let known = is_admin(&other.principal_id)?;
admins.insert(other.principal_id.clone(), known);
known
}
};
if admin {
return Ok(());
}
}
Err(Status::failed_precondition(format!(
"this is the last passkey any server admin holds ({}'s): the box must keep one admin \
who can log in — add another passkey, or another admin, first",
stored.credential.principal_id
)))
}
}
#[tonic::async_trait]
impl Access for AccessService {
/// **A first-login link onto an existing person, minted by the identity
/// core** on a server admin's authority (the console relaying a paid
/// sign-up, an operator's CLI). Only a person; `first_only` refuses one who
/// holds a passkey. The secret is the core's, shown here once.
async fn enrolment_link(
&self,
request: Request<gunnar_proto::EnrolmentLinkRequest>,
) -> Result<Response<gunnar_proto::EnrolmentLinkResponse>, Status> {
const METHOD: &str = "Access.EnrolmentLink";
let caller = self
.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let request = request.into_inner();
let principal_id = request.principal_id.trim();
let identity = self.ctx.identity.clone().ok_or_else(|| {
Status::unavailable("this server serves no identity core, so it mints no link")
})?;
let person = self
.ctx
.control
.principal(principal_id)?
.ok_or_else(|| Status::not_found(format!("no principal {principal_id}")))?;
if person.kind != gunnar_proto::PrincipalKind::User as i32 {
return Err(Status::failed_precondition(format!(
"{principal_id} is not a person: a link enrols a person's passkey"
)));
}
if request.first_only && !self.ctx.control.credentials(Some(principal_id))?.is_empty() {
return Err(Status::failed_precondition(format!(
"{principal_id} already holds a passkey: sign in with it, or add one from a signed-in device"
)));
}
let now = crate::bus::now_unix_ms();
let expires = match request.expires_at_unix_ms {
0 => now + INVITE_DEFAULT_MS,
at if at <= now => {
return Err(Status::invalid_argument(
"`expires_at_unix_ms` is in the past",
))
}
at if at > now + INVITE_MAX_MS => {
return Err(Status::invalid_argument("a link lives at most thirty days"));
}
at => at,
};
let made = identity
.enrolment(
principal_id,
&caller.principal_id,
Some((expires - now).div_euclid(1000)),
)
.map_err(crate::identity::status_of)?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.enrolment_link",
principal = %principal_id,
by = %caller.principal_id,
invite = %made.invite_id,
first_only = request.first_only,
"the identity core minted a first-login link onto an existing person"
);
Ok(Response::new(gunnar_proto::EnrolmentLinkResponse {
secret: made.secret,
invite_id: made.invite_id,
expires_at_unix_ms: made.expires_at.saturating_mul(1000),
}))
}
/// A fresh challenge for a machine's enrolment ceremony: the relay's.
async fn machine_challenge(
&self,
request: Request<gunnar_proto::MachineChallengeRequest>,
) -> Result<Response<gunnar_proto::MachineChallengeResponse>, Status> {
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.MachineChallenge",
)?;
let client = request.get_ref().client.trim();
let client = (!client.is_empty()).then(|| crate::webauthn::ClientKey::of_peer(client));
let (challenge, expires_at_unix_ms) = self
.ctx
.web_challenges
.issue(client.as_ref(), crate::bus::now_unix_ms())
.map_err(|full| Status::resource_exhausted(full.to_string()))?;
Ok(Response::new(gunnar_proto::MachineChallengeResponse {
challenge,
expires_at_unix_ms,
}))
}
async fn add_principal(
&self,
request: Request<AddPrincipalRequest>,
) -> Result<Response<AddPrincipalResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.AddPrincipal",
)?;
let request = request.into_inner();
let kind = PrincipalKind::try_from(request.kind)
.map_err(|_| Status::invalid_argument("unknown principal kind"))?;
if kind == PrincipalKind::Unspecified {
return Err(Status::invalid_argument(
"`kind` is required: USER, MACHINE, DEPLOY or GROUP",
));
}
if request.name.is_empty() {
return Err(Status::invalid_argument("`name` is required"));
}
// An empty `id` means the name, which is what every caller from before
// the field existed asked for. The id is the account, so a caller that
// sets it is choosing an account slug that is not the display name.
let id = if request.id.is_empty() {
request.name.as_str()
} else {
request.id.as_str()
};
// A console's name is a machine's. The caller is a server admin here
// already (the line above), so that is the whole exception.
if kind != PrincipalKind::Machine {
for text in [id, request.name.as_str()] {
if is_reserved_principal_name(text) {
return Err(reserved_name_refusal(
text,
"a person, a group or a deploy key",
));
}
}
}
// The name filter (2026-10-05): a person's id is an account's name.
// Reserved names are a machine's to carry, and the operator's (a
// PERSON with server Admin) to hand out; a machine relaying somebody's
// choice is held to them.
// Reserved names are a PERSON's to be refused: a machine, a group or a
// deploy key named `twin` or `kit-…` is gunnar's own machinery.
let what = if kind == PrincipalKind::User {
"account"
} else {
"member"
};
for text in [id, request.name.as_str()] {
self.ctx.filter_name(
&caller,
what,
text,
None,
kind != PrincipalKind::User || !caller.machine,
)?;
}
let principal = self.ctx.control.add_principal(id, &request.name, kind)?;
self.ctx.account_event(
self.ctx.account_of_principal(&principal.id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"account.created",
principal.id.clone(),
),
);
Ok(Response::new(AddPrincipalResponse {
principal: Some(principal),
}))
}
async fn list_principals(
&self,
request: Request<ListPrincipalsRequest>,
) -> Result<Response<ListPrincipalsResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.ListPrincipals",
)?;
let request = request.into_inner();
// A name filter is one probe on the name index, not a listing that is
// filtered afterwards: the console asks this on every registration.
let principals = match opt_str(&request.name) {
Some(name) => self
.ctx
.control
.principal_by_name(name)?
.into_iter()
.collect(),
None => self.ctx.control.list_principals()?,
};
let principals = self.members_hidden(&caller, principals, |p| p.id.clone(), true)?;
// **Last sign-in and last operation** (2026-10-21), for the principals
// this answer names and no others, behind the same admin-only gate as
// the listing: nobody reads another person's stamps without it.
let seen = self.ctx.control.principal_activity()?;
let mut last_sign_in_unix_ms = std::collections::HashMap::new();
let mut last_operation_unix_ms = std::collections::HashMap::new();
for p in &principals {
if let Some(s) = seen.get(&p.id) {
if let Some(at) = s.last_sign_in_unix_ms {
last_sign_in_unix_ms.insert(p.id.clone(), at);
}
if let Some(at) = s.last_operation_unix_ms {
last_operation_unix_ms.insert(p.id.clone(), at);
}
}
}
Ok(Response::new(ListPrincipalsResponse {
principals,
next_page_token: String::new(),
last_sign_in_unix_ms,
last_operation_unix_ms,
}))
}
async fn rename_principal(
&self,
request: Request<RenamePrincipalRequest>,
) -> Result<Response<RenamePrincipalResponse>, Status> {
// Same gate as AddPrincipal: a name is what an operator reads in a
// listing, a grant prompt or an audit line, and letting a non-admin
// choose one is letting them choose what an operator sees.
let caller = self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.RenamePrincipal",
)?;
let request = request.into_inner();
if request.principal_id.is_empty() || request.name.is_empty() {
return Err(Status::invalid_argument(
"`principal_id` and `name` are both required",
));
}
// The same door as AddPrincipal: a person is not renamed into a
// console's name; a machine may be.
if is_reserved_principal_name(&request.name) {
let kind = self
.ctx
.control
.principal(&request.principal_id)?
.map(|p| p.kind)
.unwrap_or_default();
if kind != PrincipalKind::Machine as i32 {
return Err(reserved_name_refusal(
&request.name,
"a person, a group or a deploy key",
));
}
}
let account = self.ctx.account_of_principal(&request.principal_id);
let target_kind = self
.ctx
.control
.principal(&request.principal_id)?
.map(|p| p.kind)
.unwrap_or_default();
let what = if account.as_deref() == Some(request.principal_id.as_str()) {
"account"
} else {
"member"
};
self.ctx.filter_name(
&caller,
what,
&request.name,
account.clone(),
target_kind != PrincipalKind::User as i32 || !caller.machine,
)?;
let principal = self
.ctx
.control
.rename_principal(&request.principal_id, &request.name)?;
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(
&caller,
"member.renamed",
principal.id.clone(),
)
.detail(format!("now called {:?}", principal.name)),
);
Ok(Response::new(RenamePrincipalResponse {
principal: Some(principal),
}))
}
async fn remove_principal(
&self,
request: Request<RemovePrincipalRequest>,
) -> Result<Response<RemovePrincipalResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.RemovePrincipal",
)?;
let request = request.into_inner();
let account = self.ctx.account_of_principal(&request.principal_id);
let (keys_revoked, grants_revoked) =
self.ctx.control.remove_principal(&request.principal_id)?;
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(
&caller,
"member.removed",
request.principal_id.clone(),
)
.detail(format!(
"{keys_revoked} keys and {grants_revoked} grants with it"
)),
);
Ok(Response::new(RemovePrincipalResponse {
keys_revoked,
grants_revoked,
}))
}
async fn add_key(
&self,
request: Request<AddKeyRequest>,
) -> Result<Response<AddKeyResponse>, Status> {
// Adding a key IS granting SSH access. Nothing below Admin — for
// anybody ELSE's principal. On the caller's own (the self-scope,
// 2026-10-04) no grant is asked: the call is already that principal's
// own signed word, so no attestation is owed either
// (`key_attestation::Requirement::CallerIsTarget`).
let caller = self.ctx.caller(&request)?;
if self.ctx.is_self(&caller, &request.get_ref().principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.AddKey")?;
self.refuse_a_mint_past_the_idp(&caller, "Access.AddKey")?;
} else {
self.ctx
.require(&caller, Scope::Server, GrantLevel::Admin, "Access.AddKey")?;
}
let request = request.into_inner();
// The caller's credential proves who asked. The attestation, checked
// behind the seam against the target's own keys, proves the person
// whose access grows agreed — required exactly when there is somebody
// to ask and the caller is not them (`key_attestation::Requirement`).
let key = self.ctx.control.add_key(
&request.principal_id,
&request.openssh_public_key,
&request.comment,
request.expires_at_unix_ms,
&caller.principal_id,
request.attestation,
)?;
self.ctx.account_event(
self.ctx.account_of_principal(&request.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"credential.added",
request.principal_id.clone(),
)
.detail(format!("SSH key {}", key.fingerprint)),
);
Ok(Response::new(AddKeyResponse { key: Some(key) }))
}
async fn revoke_key(
&self,
request: Request<RevokeKeyRequest>,
) -> Result<Response<RevokeKeyResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
if request.fingerprint.is_empty() {
return Err(Status::invalid_argument("`fingerprint` is required"));
}
// **A key that is the caller's own is the caller's to revoke** (the
// self-scope, 2026-10-04) — the one it is signing with included: the
// call is already authenticated, and losing the NEXT call is what was
// asked for. Any other fingerprint — somebody else's, or none at all —
// is a server Admin's, and is refused in the same words either way, so
// the refusal is no oracle for whose a key is.
let own = caller.authenticated
&& self
.ctx
.control
.list_keys(Some(&caller.principal_id))?
.iter()
.any(|key| key.fingerprint == request.fingerprint);
if own && self.ctx.is_self(&caller, &caller.principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.RevokeKey")?;
} else {
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.RevokeKey",
)?;
}
let holder = self
.ctx
.control
.registered_key(&request.fingerprint)
.ok()
.flatten()
.map(|k| k.principal_id);
self.ctx.control.revoke_key(&request.fingerprint)?;
if let Some(holder) = holder {
self.ctx.account_event(
self.ctx.account_of_principal(&holder),
crate::enterprise::AccountAuditEvent::by(
&caller,
"credential.removed",
holder.clone(),
)
.detail(format!("SSH key {}", request.fingerprint)),
);
}
Ok(Response::new(RevokeKeyResponse {}))
}
async fn list_keys(
&self,
request: Request<ListKeysRequest>,
) -> Result<Response<ListKeysResponse>, Status> {
// The whole table, or another principal's keys: a server Admin's (the
// reconnaissance half). The caller's OWN keys: theirs (the self-scope,
// 2026-10-04).
let caller = self.ctx.caller(&request)?;
if self.ctx.is_self(&caller, &request.get_ref().principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Read, "Access.ListKeys")?;
} else {
self.ctx
.require(&caller, Scope::Server, GrantLevel::Admin, "Access.ListKeys")?;
}
let request = request.into_inner();
let keys = self.ctx.control.list_keys(opt_str(&request.principal_id))?;
let keys = self.members_hidden(&caller, keys, |k| k.principal_id.clone(), true)?;
Ok(Response::new(ListKeysResponse { keys }))
}
async fn grant(
&self,
request: Request<GrantRequest>,
) -> Result<Response<GrantResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
self.ctx.require(
&caller,
grant_scope(&request.namespace, &request.repo),
GrantLevel::Admin,
"Access.Grant",
)?;
let level = GrantLevel::try_from(request.level)
.map_err(|_| Status::invalid_argument("unknown grant level"))?;
let grant = self.ctx.control.grant(
&request.principal_id,
&request.namespace,
&request.repo,
level,
)?;
self.ctx.account_event(
self.ctx.account_of_namespace(&request.namespace),
crate::enterprise::AccountAuditEvent::by(
&caller,
"grant.added",
request.principal_id.clone(),
)
.detail(format!(
"{} on {}{}",
crate::auth::level_name(level),
request.namespace,
if request.repo.is_empty() {
String::new()
} else {
format!("/{}", request.repo)
}
)),
);
Ok(Response::new(GrantResponse { grant: Some(grant) }))
}
async fn revoke(
&self,
request: Request<RevokeRequest>,
) -> Result<Response<RevokeResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
self.ctx.require(
&caller,
grant_scope(&request.namespace, &request.repo),
GrantLevel::Admin,
"Access.Revoke",
)?;
self.ctx
.control
.revoke(&request.principal_id, &request.namespace, &request.repo)?;
self.ctx.account_event(
self.ctx.account_of_namespace(&request.namespace),
crate::enterprise::AccountAuditEvent::by(
&caller,
"grant.revoked",
request.principal_id.clone(),
)
.detail(format!(
"on {}{}",
request.namespace,
if request.repo.is_empty() {
String::new()
} else {
format!("/{}", request.repo)
}
)),
);
Ok(Response::new(RevokeResponse {}))
}
async fn list_grants(
&self,
request: Request<ListGrantsRequest>,
) -> Result<Response<ListGrantsResponse>, Status> {
// Server-scoped even when the request filters: the filters are hints,
// and answering a narrower question with a wider query is how an ACL
// read turns into an ACL leak.
//
// **One exception, and it is not a filter used as a hint** (2026-10-07,
// the members model): the ACCOUNT ADMIN reads the grants inside their
// own account — who may read and write its repositories — so they can
// give a member Write and take it away (`Grant` / `Revoke`, Admin on
// the repository, which their account already gives them). The
// namespace is then the QUERY, not a hint: the plane is asked for that
// namespace's rows only, and the caller must hold Admin on it.
let caller = self.ctx.caller(&request)?;
let namespace = request.get_ref().namespace.trim().to_owned();
if !namespace.is_empty()
&& self
.ctx
.check(&caller, Scope::Server, GrantLevel::Admin)?
.is_some()
{
self.ctx.require(
&caller,
Scope::namespace(&namespace),
GrantLevel::Admin,
"Access.ListGrants",
)?;
let request = request.into_inner();
let grants = self.ctx.control.list_grants(
opt_str(&request.principal_id),
Some(&namespace),
opt_str(&request.repo),
)?;
return Ok(Response::new(ListGrantsResponse { grants }));
}
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.ListGrants",
)?;
let request = request.into_inner();
let grants = self.ctx.control.list_grants(
opt_str(&request.principal_id),
opt_str(&request.namespace),
opt_str(&request.repo),
)?;
let grants = self.ctx.retain_unshielded(&caller, grants, |g| {
(!g.namespace.is_empty()).then(|| g.namespace.clone())
})?;
let grants = self.members_hidden(&caller, grants, |g| g.principal_id.clone(), true)?;
Ok(Response::new(ListGrantsResponse { grants }))
}
/// **Who am I?** The caller's own principal, the key that signed this
/// call, and the highest server-wide level it holds — asked of the same
/// decision every verb makes ([`crate::service::ServiceContext::check`]),
/// so a locked-out account holds none. No grant is needed and no
/// principal can be named: it answers about the caller and nobody else.
async fn who_am_i(
&self,
request: Request<WhoAmIRequest>,
) -> Result<Response<WhoAmIResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let principal = self
.ctx
.control
.principal(&caller.principal_id)?
.unwrap_or_else(|| gunnar_proto::Principal {
id: caller.principal_id.clone(),
name: caller.principal_id.clone(),
..Default::default()
});
let mut server_level = GrantLevel::Unspecified;
for level in [GrantLevel::Admin, GrantLevel::Write, GrantLevel::Read] {
if self.ctx.check(&caller, Scope::Server, level)?.is_none() {
server_level = level;
break;
}
}
// **The account it acts in, and what it is to it** (2026-10-07): a
// console draws the buying doors for an individual and an account
// admin only, and a member's storage from the ACCOUNT's room.
let standing = self.ctx.standing(&caller.principal_id)?;
Ok(Response::new(WhoAmIResponse {
principal: Some(principal),
fingerprint: caller.fingerprint.clone(),
server_level: server_level as i32,
account: standing.account,
account_role: standing.role.to_owned(),
account_admin: standing.admin,
}))
}
/// **May I?** The decision the verbs make, for the caller, without acting
/// and without an audit record (see
/// [`crate::service::ServiceContext::check`]). `Check{namespace: N, level:
/// ADMIN}` is exactly the account-admin rule.
async fn check(
&self,
request: Request<CheckRequest>,
) -> Result<Response<CheckResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let level = GrantLevel::try_from(request.level)
.ok()
.filter(|level| *level != GrantLevel::Unspecified)
.ok_or_else(|| Status::invalid_argument("`level`: READ, WRITE or ADMIN"))?;
let namespace = request.namespace.trim();
let repo = request.repo.trim();
let access = match (namespace.is_empty(), repo.is_empty()) {
(true, true) => Scope::Server,
(true, false) => {
return Err(Status::invalid_argument(
"`repo` needs its `namespace`: name both, or neither for the server",
))
}
(false, true) => Scope::namespace(namespace),
(false, false) => Scope::Repo(gunnar_proto::RepoPath::new(namespace, repo)),
};
let refused = self.ctx.check(&caller, access, level)?;
Ok(Response::new(CheckResponse {
allowed: refused.is_none(),
reason: refused.unwrap_or_default(),
}))
}
/// **A passkey on a person** (Stage L). A server admin, or the person
/// themselves (the self-scope — the call is already their own signed
/// word). The key must be a P-256 point and the id within WebAuthn's
/// bound; only a USER may hold one (the plane refuses anything else).
async fn add_credential(
&self,
request: Request<AddCredentialRequest>,
) -> Result<Response<AddCredentialResponse>, Status> {
let caller = self.ctx.caller(&request)?;
if self.ctx.is_self(&caller, &request.get_ref().principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.AddCredential")?;
self.refuse_a_mint_past_the_idp(&caller, "Access.AddCredential")?;
} else {
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.AddCredential",
)?;
}
let request = request.into_inner();
if request.principal_id.trim().is_empty() {
return Err(Status::invalid_argument("`principal_id` is required"));
}
crate::webauthn::check_credential_id(&request.credential_id)
.map_err(Status::invalid_argument)?;
crate::webauthn::check_public_key(&request.public_key).map_err(Status::invalid_argument)?;
let credential = self.ctx.control.add_credential(
&request.principal_id,
&request.credential_id,
&request.public_key,
request.label.trim(),
request.sign_count,
)?;
self.ctx.account_event(
self.ctx.account_of_principal(&credential.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"credential.added",
credential.principal_id.clone(),
)
.detail(format!("passkey {:?}", credential.label)),
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.credential_added",
principal = %credential.principal_id,
caller = %caller.principal_id,
"a web credential was registered"
);
Ok(Response::new(AddCredentialResponse {
credential: Some(credential),
}))
}
/// **The caller's encryption key** (CRYPT-YUBI): the caller's own, always — there is
/// no principal to name, so nobody reads another person's methods, an admin neither.
async fn get_encryption_key(
&self,
request: Request<GetEncryptionKeyRequest>,
) -> Result<Response<GetEncryptionKeyResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Read, "Access.GetEncryptionKey")?;
Ok(Response::new(
self.ctx.control.encryption_key(&caller.principal_id)?,
))
}
/// The caller's first encryption key: its public half and its first method.
async fn create_encryption_key(
&self,
request: Request<CreateEncryptionKeyRequest>,
) -> Result<Response<CreateEncryptionKeyResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.CreateEncryptionKey")?;
let request = request.into_inner();
let first = request.first.ok_or_else(|| {
Status::invalid_argument("`first` (the first unlock method) is required")
})?;
let kind = first.kind.clone();
let key = self.ctx.control.create_encryption_key(
&caller.principal_id,
&request.public_key,
first,
)?;
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"encryption-key.created",
caller.principal_id.clone(),
)
.detail(format!("first method {kind}")),
);
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "access.encryption_key_created", principal = %caller.principal_id, method = %kind, "an encryption key was created");
Ok(Response::new(CreateEncryptionKeyResponse {
public_key: key.public_key,
methods: key.methods,
}))
}
/// One more unlock method of the caller's key.
async fn add_unlock_method(
&self,
request: Request<AddUnlockMethodRequest>,
) -> Result<Response<AddUnlockMethodResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.AddUnlockMethod")?;
let method = request
.into_inner()
.method
.ok_or_else(|| Status::invalid_argument("`method` is required"))?;
let kind = method.kind.clone();
let key = self
.ctx
.control
.add_unlock_method(&caller.principal_id, method)?;
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"encryption-key.method-added",
caller.principal_id.clone(),
)
.detail(kind.clone()),
);
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "access.unlock_method_added", principal = %caller.principal_id, method = %kind, "an unlock method was added");
Ok(Response::new(AddUnlockMethodResponse {
methods: key.methods,
}))
}
/// **Replace the caller's key** (CRYPT-PQ-REKEY): the old public half is retired;
/// audited with both fingerprints.
async fn replace_encryption_key(
&self,
request: Request<ReplaceEncryptionKeyRequest>,
) -> Result<Response<ReplaceEncryptionKeyResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.ReplaceEncryptionKey")?;
let request = request.into_inner();
let first = request.first.ok_or_else(|| {
Status::invalid_argument("`first` (the new key's first unlock method) is required")
})?;
let kind = first.kind.clone();
let (key, retired) = self.ctx.control.replace_encryption_key(
&caller.principal_id,
&request.public_key,
first,
)?;
let (old_fp, new_fp) = (key_fingerprint(&retired), key_fingerprint(&key.public_key));
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"encryption-key.replaced",
caller.principal_id.clone(),
)
.detail(format!(
"{old_fp} retired, {new_fp} in its place (first method {kind})"
)),
);
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "access.encryption_key_replaced", principal = %caller.principal_id, retired = %old_fp, new = %new_fp, method = %kind, "an encryption key was replaced; the old one is retired");
Ok(Response::new(ReplaceEncryptionKeyResponse {
public_key: key.public_key,
methods: key.methods,
retired_public_key: retired,
}))
}
/// Delete one of the caller's unlock methods; never the last.
async fn remove_unlock_method(
&self,
request: Request<RemoveUnlockMethodRequest>,
) -> Result<Response<RemoveUnlockMethodResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.RemoveUnlockMethod")?;
let id = request.into_inner().id;
let key = self
.ctx
.control
.remove_unlock_method(&caller.principal_id, id.trim())?;
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"encryption-key.method-removed",
caller.principal_id.clone(),
)
.detail(id.clone()),
);
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "access.unlock_method_removed", principal = %caller.principal_id, method_id = %id, "an unlock method was removed");
Ok(Response::new(RemoveUnlockMethodResponse {
methods: key.methods,
}))
}
/// A person's own passkeys (the self-scope), or anybody's — every one, for
/// an empty `principal_id` — for a server admin.
async fn list_credentials(
&self,
request: Request<ListCredentialsRequest>,
) -> Result<Response<ListCredentialsResponse>, Status> {
let caller = self.ctx.caller(&request)?;
if self.ctx.is_self(&caller, &request.get_ref().principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Read, "Access.ListCredentials")?;
} else {
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.ListCredentials",
)?;
}
let request = request.into_inner();
let credentials = self
.ctx
.control
.credentials(opt_str(&request.principal_id))?;
// **A passkey inside an account closed to the caller is shown without
// its holder or its label** (2026-10-05): its id is an opaque random
// handle the console's relay offers a browser at login, and nothing
// else on the row is the operator's business.
let shown = self.members_hidden(
&caller,
credentials.clone(),
|c| c.principal_id.clone(),
true,
)?;
let credentials = if shown.len() == credentials.len() {
shown
} else {
let kept: std::collections::BTreeSet<Vec<u8>> =
shown.iter().map(|c| c.credential_id.clone()).collect();
credentials
.into_iter()
.map(|mut c| {
if !kept.contains(&c.credential_id) {
c.principal_id = String::new();
c.label = String::new();
}
c
})
.collect()
};
Ok(Response::new(ListCredentialsResponse { credentials }))
}
/// **Revoke a passkey.** The person's own (the self-scope); anybody's for
/// a server admin. Somebody else's and one that does not exist are refused
/// in the same words, so the refusal is no oracle for whose a credential
/// is.
async fn revoke_credential(
&self,
request: Request<RevokeCredentialRequest>,
) -> Result<Response<RevokeCredentialResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
crate::webauthn::check_credential_id(&request.credential_id)
.map_err(Status::invalid_argument)?;
let own = match self.ctx.control.credential(&request.credential_id)? {
Some(stored) => {
caller.authenticated && stored.credential.principal_id == caller.principal_id
}
None => false,
};
if own && self.ctx.is_self(&caller, &caller.principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.RevokeCredential")?;
} else {
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.RevokeCredential",
)?;
}
self.refuse_the_last_admin_passkey(&request.credential_id)?;
let Some(revoked) = self.ctx.control.revoke_credential(&request.credential_id)? else {
return Err(Status::not_found("no such web credential"));
};
// **The sessions it opened end with it** (IDENTITY-HOST): every core
// session of its person; they log in again with a passkey they hold.
if let Some(identity) = &self.ctx.identity {
if let Err(why) = identity.end_sessions(&revoked.principal_id, &caller.principal_id) {
tracing::error!(target: crate::auth::AUDIT_TARGET, event = "access.sessions_not_ended", principal = %revoked.principal_id, reason = %why, "a passkey was revoked and its person's sessions could not be ended");
}
}
self.ctx.account_event(
self.ctx.account_of_principal(&revoked.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"credential.removed",
revoked.principal_id.clone(),
)
.detail(format!(
"passkey {:?} and the sessions it opened",
revoked.label
)),
);
Ok(Response::new(RevokeCredentialResponse {}))
}
/// **Change a passkey's label**: its own principal (the self-scope), or a
/// server admin. Somebody else's and a missing one are refused in the
/// same words.
async fn rename_credential(
&self,
request: Request<RenameCredentialRequest>,
) -> Result<Response<RenameCredentialResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
crate::webauthn::check_credential_id(&request.credential_id)
.map_err(Status::invalid_argument)?;
let label = request.label.trim();
if label.is_empty() || label.chars().count() > 128 {
return Err(Status::invalid_argument(
"`label`: not empty, at most 128 characters",
));
}
let own = match self.ctx.control.credential(&request.credential_id)? {
Some(stored) => {
caller.authenticated && stored.credential.principal_id == caller.principal_id
}
None => false,
};
if own && self.ctx.is_self(&caller, &caller.principal_id) {
self.ctx
.require_self(&caller, GrantLevel::Admin, "Access.RenameCredential")?;
} else {
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.RenameCredential",
)?;
}
let credential = self
.ctx
.control
.rename_credential(&request.credential_id, label)?
.ok_or_else(|| Status::not_found("no such web credential"))?;
self.ctx.account_event(
self.ctx.account_of_principal(&credential.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"credential.renamed",
credential.principal_id.clone(),
)
.detail(format!("passkey now {:?}", credential.label)),
);
Ok(Response::new(RenameCredentialResponse {
credential: Some(credential),
}))
}
/// **A machine asks for a web credential** (2026-10-05) — relayed by the
/// console (a server admin), anonymously on the machine's behalf. The
/// creation ceremony is checked over a `MachineChallenge` (the challenge
/// spent whatever the outcome); the request is then HELD, pending, and nothing
/// else happens until a person approves it.
async fn request_machine_enrolment(
&self,
request: Request<RequestMachineEnrolmentRequest>,
) -> Result<Response<RequestMachineEnrolmentResponse>, Status> {
const METHOD: &str = "Access.RequestMachineEnrolment";
let caller = self
.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let request = request.into_inner();
let machine = request.machine.trim();
crate::machine_enrolment::check_machine_name(machine).map_err(Status::invalid_argument)?;
let level = machine_level(request.server_level)?;
let label = request.label.trim();
if label.is_empty() || label.chars().count() > 128 {
return Err(Status::invalid_argument(
"`label`: what the machine calls itself, not empty, at most 128 characters",
));
}
crate::webauthn::check_credential_id(&request.credential_id)
.map_err(Status::invalid_argument)?;
crate::webauthn::check_public_key(&request.public_key).map_err(Status::invalid_argument)?;
if request.rp_id.trim().is_empty() || request.origin.trim().is_empty() {
return Err(Status::invalid_argument(
"`rp_id` and `origin` are both required",
));
}
let now = crate::bus::now_unix_ms();
let data = crate::webauthn::client_data(&request.client_data_json)
.map_err(|why| ceremony_refused(METHOD, why))?;
let fresh = self.ctx.web_challenges.spend(&data.challenge, now);
crate::webauthn::check_ceremony(&data, "webauthn.create", &request.origin, &request.rp_id)
.and_then(|()| {
crate::webauthn::check_relying_party(
&self.ctx.config.web_relying_parties,
&request.rp_id,
&request.origin,
)
})
.map_err(|why| ceremony_refused(METHOD, why))?;
if !fresh {
return Err(ceremony_refused(
METHOD,
"the challenge was not issued here, was already used, or expired".to_owned(),
));
}
// Stinky row 13 (2026-10-10): the key is the one the authenticator
// attested, not the relay's word.
let attested = crate::webauthn::verify_attestation(
&request.attestation_object,
&request.client_data_json,
&request.rp_id,
&request.credential_id,
&request.public_key,
)
.map_err(|why| ceremony_refused(METHOD, why))?;
if self
.ctx
.control
.credential(&request.credential_id)?
.is_some()
{
return Err(Status::already_exists(
"this credential is registered at gunnar already",
));
}
let (row, expired) = self.ctx.machine_enrolments.hold(
machine,
label,
level,
&request.credential_id,
&request.public_key,
attested.sign_count,
now,
)?;
audit_expired(expired);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.machine_enrolment_requested",
request = %row.request_id,
machine = %row.machine,
label = %row.label,
role = crate::auth::level_name(level),
code = %row.code,
key = %row.key_fingerprint,
relay = %caller.principal_id,
"a machine asked for a web credential; it waits for an admin's approval"
);
Ok(Response::new(RequestMachineEnrolmentResponse {
request: Some(row),
}))
}
/// One request's state — the relay's, for the machine's poll.
async fn machine_enrolment_status(
&self,
request: Request<MachineEnrolmentStatusRequest>,
) -> Result<Response<MachineEnrolmentStatusResponse>, Status> {
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.MachineEnrolmentStatus",
)?;
let id = request.into_inner().request_id.trim().to_owned();
let (held, expired) = self
.ctx
.machine_enrolments
.get(&id, crate::bus::now_unix_ms());
audit_expired(expired);
let held = held.ok_or_else(|| {
Status::not_found(format!(
"no machine enrolment request {id}: it was never made here, or it is long gone"
))
})?;
Ok(Response::new(MachineEnrolmentStatusResponse {
request: Some(held.row),
}))
}
/// The pending requests: a server admin's.
async fn list_machine_enrolments(
&self,
request: Request<ListMachineEnrolmentsRequest>,
) -> Result<Response<ListMachineEnrolmentsResponse>, Status> {
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.ListMachineEnrolments",
)?;
let (requests, expired) = self
.ctx
.machine_enrolments
.pending(crate::bus::now_unix_ms());
audit_expired(expired);
Ok(Response::new(ListMachineEnrolmentsResponse { requests }))
}
/// ★ **Approve a machine's request** — the ONE door to a machine's web
/// credential, an admin one included. A server admin who is a PERSON, with
/// a fresh user-verified assertion of their OWN passkey over a challenge
/// gunnar issued (the step-up, verified here as a login is). Nothing else:
/// the installer's break-glass token is the owner's FIRST INVITE (he
/// registers with it first), never a machine's approval. Then the machine
/// principal, its grant and its credential, in one update.
async fn approve_machine_enrolment(
&self,
request: Request<ApproveMachineEnrolmentRequest>,
) -> Result<Response<ApproveMachineEnrolmentResponse>, Status> {
const METHOD: &str = "Access.ApproveMachineEnrolment";
let caller = self
.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let stepped_up = crate::auth::stepped_up(&request);
let request = request.into_inner();
let id = request.request_id.trim().to_owned();
let now = crate::bus::now_unix_ms();
let (held, expired) = self.ctx.machine_enrolments.get(&id, now);
audit_expired(expired);
let held = held.ok_or_else(|| {
Status::not_found(format!(
"no machine enrolment request {id}: it was never made here, or it is long gone"
))
})?;
match gunnar_proto::MachineEnrolmentState::try_from(held.row.state) {
Ok(gunnar_proto::MachineEnrolmentState::Pending) => {}
Ok(gunnar_proto::MachineEnrolmentState::Expired) => {
return Err(Status::failed_precondition(
"this request expired with nobody's approval; the machine asks again",
))
}
_ => {
return Err(Status::failed_precondition(format!(
"this request was {} already, by {}",
crate::machine_enrolment::state_word(held.row.state),
held.row.decided_by
)))
}
}
if request.code.trim() != held.row.code {
return Err(Status::failed_precondition(format!(
"the code {:?} is not this request's: approve the request whose code the machine \
shows",
request.code.trim()
)));
}
let (by, door, step_up) = {
// The human is the point: a machine that holds Admin — an
// approved kit — never approves another machine.
let approver = self.ctx.control.principal(&caller.principal_id)?;
if approver.as_ref().map(|p| p.kind) != Some(gunnar_proto::PrincipalKind::User as i32) {
return Err(Status::permission_denied(format!(
"only a person approves a machine's credential, and {} is not a person",
caller.principal_id
)));
}
// The core's step-up (IDENTITY-HOST): the approver's own session,
// stepped up by a fresh user-verified touch within the window. A
// signed key and the break-glass token are never a step-up.
if !stepped_up {
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "access.machine_enrolment_step_up_refused", request = %id, by = %caller.principal_id, "an approval came from no stepped-up session");
return Err(Status::permission_denied(format!(
"[stepup] {METHOD} needs a fresh touch of your own passkey (user-verified, within the last 10 minutes)"
)));
}
(caller.principal_id.clone(), "step-up", None)
};
// Claimed BEFORE the commit, so a refusal racing it finds it decided;
// put back if the plane says no.
let decided = self
.ctx
.machine_enrolments
.decide(&id, gunnar_proto::MachineEnrolmentState::Approved, &by, now)
.ok_or_else(|| Status::failed_precondition("this request was decided meanwhile"))?;
// The approver GIVES the role: the one asked for, unless they name
// another (owner order 2026-10-18).
let level = if request.server_level == GrantLevel::Unspecified as i32 {
machine_level(held.row.server_level)?
} else {
machine_level(request.server_level)?
};
let committed = self
.ctx
.control
.approve_machine_enrolment(crate::seam::MachineApproval {
machine: held.row.machine.clone(),
server_level: level,
credential_id: held.row.credential_id.clone(),
public_key: held.public_key.clone(),
label: held.row.label.clone(),
sign_count: held.sign_count,
now_unix_ms: now,
step_up,
});
let approved = match committed {
Ok(approved) => approved,
Err(err) => {
self.ctx.machine_enrolments.reopen(&id);
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "access.machine_enrolment_not_approved",
request = %id,
machine = %held.row.machine,
by = %by,
door,
reason = %err,
"an approval of a machine enrolment was refused by the identity table"
);
return Err(err.into());
}
};
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.machine_enrolment_approved",
request = %decided.request_id,
machine = %decided.machine,
role = crate::auth::level_name(level),
code = %decided.code,
key = %decided.key_fingerprint,
by = %by,
door,
created = approved.created,
"a machine's web credential was approved and enrolled on its machine principal"
);
// The approver's own account log (2026-10-05, approvals): who let
// which machine in, as what, under which code.
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"approval.machine",
decided.machine.clone(),
)
.detail(format!(
"{} as {} — code {} — {}",
decided.label,
crate::auth::level_name(level),
decided.code,
door
)),
);
Ok(Response::new(ApproveMachineEnrolmentResponse {
request: Some(decided),
principal: Some(approved.principal),
credential: Some(approved.credential),
}))
}
/// Refuse a machine's request: a server admin. Nothing is stored.
async fn refuse_machine_enrolment(
&self,
request: Request<RefuseMachineEnrolmentRequest>,
) -> Result<Response<RefuseMachineEnrolmentResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.RefuseMachineEnrolment",
)?;
let id = request.into_inner().request_id.trim().to_owned();
let now = crate::bus::now_unix_ms();
let Some(decided) = self.ctx.machine_enrolments.decide(
&id,
gunnar_proto::MachineEnrolmentState::Refused,
&caller.principal_id,
now,
) else {
let (held, expired) = self.ctx.machine_enrolments.get(&id, now);
audit_expired(expired);
return Err(match held {
Some(held) => Status::failed_precondition(format!(
"this request is {} already",
crate::machine_enrolment::state_word(held.row.state)
)),
None => Status::not_found(format!("no machine enrolment request {id}")),
});
};
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.machine_enrolment_refused",
request = %decided.request_id,
machine = %decided.machine,
code = %decided.code,
by = %caller.principal_id,
"a machine enrolment request was refused; nothing was stored"
);
self.ctx.account_event(
self.ctx.account_of_principal(&caller.principal_id),
crate::enterprise::AccountAuditEvent::by(
&caller,
"approval.machine_refused",
decided.machine.clone(),
)
.detail(format!("{} — code {}", decided.label, decided.code)),
);
Ok(Response::new(RefuseMachineEnrolmentResponse {
request: Some(decided),
}))
}
async fn add_group_member(
&self,
request: Request<AddGroupMemberRequest>,
) -> Result<Response<AddGroupMemberResponse>, Status> {
// Adding a member IS handing that member every grant the group holds.
// Server Admin, the same gate as AddPrincipal, AddKey and a
// server-scoped Grant. A non-admin — a member trying to add themselves
// to a powerful group — is refused here, before the control plane.
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.AddGroupMember",
)?;
let request = request.into_inner();
if request.group_id.is_empty() || request.member_id.is_empty() {
return Err(Status::invalid_argument(
"`group_id` and `member_id` are both required",
));
}
let signed = check_membership_signature(
&caller,
request.signed,
&request.group_id,
&request.member_id,
MembershipAction::Add,
)?;
let index =
self.ctx
.control
.add_group_member(&request.group_id, &request.member_id, signed)?;
Ok(Response::new(AddGroupMemberResponse { index }))
}
async fn remove_group_member(
&self,
request: Request<RemoveGroupMemberRequest>,
) -> Result<Response<RemoveGroupMemberResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Scope::Server,
GrantLevel::Admin,
"Access.RemoveGroupMember",
)?;
let request = request.into_inner();
if request.group_id.is_empty() || request.member_id.is_empty() {
return Err(Status::invalid_argument(
"`group_id` and `member_id` are both required",
));
}
let signed = check_membership_signature(
&caller,
request.signed,
&request.group_id,
&request.member_id,
MembershipAction::Remove,
)?;
let (was_member, index) =
self.ctx
.control
.remove_group_member(&request.group_id, &request.member_id, signed)?;
Ok(Response::new(RemoveGroupMemberResponse {
was_member,
index,
}))
}
async fn list_group_members(
&self,
request: Request<ListGroupMembersRequest>,
) -> Result<Response<ListGroupMembersResponse>, Status> {
// A membership listing is the reconnaissance half of the same attack a
// principal or key listing is, so it takes Admin too.
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.ListGroupMembers",
)?;
let request = request.into_inner();
if request.group_id.is_empty() {
return Err(Status::invalid_argument("`group_id` is required"));
}
let members = self.ctx.control.list_group_members(&request.group_id)?;
Ok(Response::new(ListGroupMembersResponse { members }))
}
async fn list_group_membership_events(
&self,
request: Request<ListGroupMembershipEventsRequest>,
) -> Result<Response<ListGroupMembershipEventsResponse>, Status> {
// Reading the audit trail of who was ever in a group is the same
// reconnaissance a live membership listing is, so it takes Admin too.
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.ListGroupMembershipEvents",
)?;
let request = request.into_inner();
let filter = opt_str(&request.group_id);
let events = self.ctx.control.membership_events(filter)?;
Ok(Response::new(ListGroupMembershipEventsResponse { events }))
}
}
/// **The membership event must be the caller's own word about this exact
/// change.** The signature proves which key spoke and the seam checks it
/// against the ACL key table; this checks, before anything is applied, that the
/// event describes the change the request names and is attributed to the
/// principal on the connection. Without it an admin could sign `remove alice`
/// and apply it as `add mallory`, or file the change in a colleague's name —
/// and the log would carry a signature that says something the server did not
/// do.
fn check_membership_signature(
caller: &Caller,
signed: Option<SignedMembershipEvent>,
group_id: &str,
member_id: &str,
action: MembershipAction,
) -> Result<SignedMembershipEvent, Status> {
let signed = signed.ok_or_else(|| {
Status::invalid_argument(
"`signed` is required: a membership change is the acting admin's signed word, not \
the server's, so it must carry the admin's own sshsig",
)
})?;
let event = signed
.event
.as_ref()
.ok_or_else(|| Status::invalid_argument("`signed.event` is required"))?;
if event.group != group_id || event.member != member_id {
return Err(Status::invalid_argument(format!(
"the signed event is about {:?}/{:?} but the request names {:?}/{:?}; a signature \
for one membership may not be applied to another",
event.group, event.member, group_id, member_id
)));
}
if event.action != action as i32 {
return Err(Status::invalid_argument(
"the signed event's action does not match the RPC (add vs remove); the action is \
inside the signature and may not be repurposed",
));
}
if !caller.principal_id.is_empty() && event.actor != caller.principal_id {
return Err(Status::permission_denied(format!(
"this membership change is attributed to {:?} and the caller is {:?}; a membership \
change is evidence about who acted, so it may not be filed in another principal's \
name",
event.actor, caller.principal_id
)));
}
Ok(signed)
}
/// The scope a grant is administered at: the repository when the grant names
/// one, otherwise the namespace. One writer, used by both `Grant` and
/// `Revoke`, so the two cannot disagree about who may edit what.
fn grant_scope(namespace: &str, repo: &str) -> Scope {
if repo.is_empty() {
Scope::namespace(namespace)
} else {
Scope::Repo(gunnar_proto::RepoPath::new(namespace, repo))
}
}