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, 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;
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"
}
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"
))
}
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"
);
}
}
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",
)
})
}
pub const WEB_SESSION_MAX_MS: i64 = 30 * 86_400_000;
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}"))
}
pub const INVITE_DEFAULT_MS: i64 = 7 * 86_400_000;
pub const INVITE_MAX_MS: i64 = 30 * 86_400_000;
impl AccessService {
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(());
}
let on_idp_session = if caller.fingerprint.starts_with("session:") {
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))
}
}
}
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)
}
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 {
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),
}))
}
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"));
}
let id = if request.id.is_empty() {
request.name.as_str()
} else {
request.id.as_str()
};
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",
));
}
}
}
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();
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)?;
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> {
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",
));
}
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> {
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();
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"));
}
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> {
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> {
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 }))
}
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;
}
}
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,
}))
}
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(),
}))
}
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),
}))
}
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)?,
))
}
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,
}))
}
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,
}))
}
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,
}))
}
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))?;
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 }))
}
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"));
};
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 {}))
}
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),
}))
}
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(),
));
}
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),
}))
}
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),
}))
}
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 }))
}
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) = {
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
)));
}
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)
};
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"))?;
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"
);
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),
}))
}
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> {
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> {
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> {
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 }))
}
}
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)
}
fn grant_scope(namespace: &str, repo: &str) -> Scope {
if repo.is_empty() {
Scope::namespace(namespace)
} else {
Scope::Repo(gunnar_proto::RepoPath::new(namespace, repo))
}
}