use gunnar_proto::access_server::Access;
use gunnar_proto::{
AddGroupMemberRequest, AddGroupMemberResponse, AddKeyRequest, AddKeyResponse,
AddPrincipalRequest, AddPrincipalResponse, GrantLevel, GrantRequest, GrantResponse,
ListGrantsRequest, ListGrantsResponse, ListGroupMembershipEventsRequest,
ListGroupMembershipEventsResponse, ListGroupMembersRequest, ListGroupMembersResponse,
ListKeysRequest, ListKeysResponse, ListPrincipalsRequest, ListPrincipalsResponse,
MembershipAction, PrincipalKind, RemoveGroupMemberRequest, RemoveGroupMemberResponse,
RemovePrincipalRequest, RemovePrincipalResponse, RenamePrincipalRequest,
RenamePrincipalResponse, RevokeKeyRequest, RevokeKeyResponse, RevokeRequest, RevokeResponse,
SignedMembershipEvent, CheckRequest, CheckResponse, WhoAmIRequest, WhoAmIResponse,
AddCredentialRequest, AddCredentialResponse, ListCredentialsRequest, ListCredentialsResponse,
RevokeCredentialRequest, RevokeCredentialResponse, CreateInviteRequest, CreateInviteResponse,
ListInvitesRequest, ListInvitesResponse, RevokeInviteRequest, RevokeInviteResponse,
CheckInviteRequest, CheckInviteResponse,
WebChallengeRequest, WebChallengeResponse, WebLoginRequest, WebLoginResponse,
WebRegisterRequest, WebRegisterResponse, RenameCredentialRequest, RenameCredentialResponse,
ApproveMachineEnrolmentRequest, ApproveMachineEnrolmentResponse, ListMachineEnrolmentsRequest,
ListMachineEnrolmentsResponse, MachineEnrolmentStatusRequest, MachineEnrolmentStatusResponse,
RefuseMachineEnrolmentRequest, RefuseMachineEnrolmentResponse, RequestMachineEnrolmentRequest,
RequestMachineEnrolmentResponse,
};
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;
#[derive(Debug, Clone, PartialEq, Eq)]
enum InviteRight {
ServerAdmin,
AccountAdmin(String),
Myself,
}
impl AccessService {
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
)))
}
fn invite_right(
&self,
caller: &Caller,
principal_id: &str,
method: &'static str,
) -> Result<InviteRight, Status> {
if self.ctx.check(caller, Scope::Server, GrantLevel::Admin)?.is_none() {
self.ctx.require(caller, Scope::Server, GrantLevel::Admin, method)?;
return Ok(InviteRight::ServerAdmin);
}
if self.ctx.is_self(caller, principal_id) {
self.ctx.require_self(caller, GrantLevel::Admin, method)?;
return Ok(InviteRight::Myself);
}
let employer = match self.ctx.control.employer_of(principal_id) {
Ok(employer) => employer,
Err(crate::seam::ControlError::Unimplemented(_)) => None,
Err(err) => return Err(err.into()),
};
if let Some(account) = employer {
if self
.ctx
.check(caller, Scope::namespace(&account), GrantLevel::Admin)?
.is_none()
{
self.ctx
.require(caller, Scope::namespace(&account), GrantLevel::Admin, method)?;
return Ok(InviteRight::AccountAdmin(account));
}
}
self.ctx.require(caller, Scope::Server, GrantLevel::Admin, method)?;
Err(Status::permission_denied(format!(
"principal {} may not invite {principal_id}",
caller.principal_id
)))
}
}
#[tonic::async_trait]
impl Access for AccessService {
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")?;
} 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")?;
} 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 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"));
};
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 create_invite(
&self,
request: Request<CreateInviteRequest>,
) -> Result<Response<CreateInviteResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let principal_id = request.principal_id.trim().to_owned();
if principal_id.is_empty() {
return Err(Status::invalid_argument("`principal_id` is required"));
}
let right = self.invite_right(&caller, &principal_id, "Access.CreateInvite")?;
let server_level = GrantLevel::try_from(request.server_level)
.map_err(|_| Status::invalid_argument("unknown `server_level`"))?;
if server_level != GrantLevel::Unspecified && right != InviteRight::ServerAdmin {
return Err(Status::permission_denied(format!(
"only a server admin's invite carries a server-wide grant, and principal {} \
invites {principal_id} as {}",
caller.principal_id,
match &right {
InviteRight::AccountAdmin(account) => format!("the admin of account {account}"),
_ => "themselves".to_owned(),
}
)));
}
if server_level == GrantLevel::Admin {
return Err(Status::invalid_argument(
"an invite never makes an admin: an invite link is a bearer credential; grant \
Admin to the person once they hold a passkey",
));
}
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; 0 means seven days",
))
}
at if at > now + INVITE_MAX_MS => {
return Err(Status::invalid_argument(
"an invite lives at most thirty days; ask again nearer the time",
))
}
at => at,
};
let secret = crate::webauthn::b64url(&crate::webauthn::random_bytes(32));
let invite = self.ctx.control.create_invite(crate::seam::NewInvite {
id: crate::webauthn::hex(&crate::webauthn::random_bytes(12)),
principal_id: principal_id.clone(),
created_by: caller.principal_id.clone(),
created_at_unix_ms: now,
expires_at_unix_ms: expires,
note: request.note.trim().to_owned(),
server_level,
secret_hash: crate::webauthn::sha256_hex(secret.as_bytes()),
})?;
self.ctx.account_event(
self.ctx.account_of_principal(&principal_id),
crate::enterprise::AccountAuditEvent::by(&caller, "invite.created", principal_id.clone()).detail(format!(
"invite {} until {}{}",
invite.invite_id,
expires,
if server_level == GrantLevel::Unspecified {
String::new()
} else {
format!(", server {}", crate::auth::level_name(server_level))
}
)),
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.invite_created",
invite = %invite.invite_id,
principal = %principal_id,
caller = %caller.principal_id,
server_level = crate::auth::level_name(server_level),
"an invite was created"
);
Ok(Response::new(CreateInviteResponse {
invite: Some(invite),
secret,
}))
}
async fn list_invites(
&self,
request: Request<ListInvitesRequest>,
) -> Result<Response<ListInvitesResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let account = request.into_inner().account.trim().to_owned();
if account.is_empty() {
self.ctx
.require(&caller, Scope::Server, GrantLevel::Admin, "Access.ListInvites")?;
} else {
self.ctx.require(
&caller,
Scope::namespace(&account),
GrantLevel::Admin,
"Access.ListInvites",
)?;
}
let now = crate::bus::now_unix_ms();
let mut invites = Vec::new();
for invite in self.ctx.control.invites()? {
if invite.expires_at_unix_ms <= now {
continue;
}
if !account.is_empty() && invite.principal_id != account {
let employer = match self.ctx.control.employer_of(&invite.principal_id) {
Ok(employer) => employer,
Err(crate::seam::ControlError::Unimplemented(_)) => None,
Err(err) => return Err(err.into()),
};
if employer.as_deref() != Some(account.as_str()) {
continue;
}
}
invites.push(invite);
}
let invites = if account.is_empty() {
self.members_hidden(&caller, invites, |i| i.principal_id.clone(), true)?
} else {
invites
};
Ok(Response::new(ListInvitesResponse { invites }))
}
async fn check_invite(
&self,
request: Request<CheckInviteRequest>,
) -> Result<Response<CheckInviteResponse>, Status> {
use gunnar_proto::check_invite_response::State;
const METHOD: &str = "Access.CheckInvite";
self.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let secret = request.into_inner().invite_secret;
let secret = secret.trim();
if secret.is_empty() {
return Err(Status::invalid_argument("`invite_secret` is required"));
}
let now = crate::bus::now_unix_ms();
let answer = match self
.ctx
.control
.check_invite(&crate::webauthn::sha256_hex(secret.as_bytes()), now)?
{
crate::seam::InviteCheck::Valid(invite) => CheckInviteResponse {
state: State::Valid as i32,
principal_id: invite.principal_id,
server_level: invite.server_level,
expires_at_unix_ms: invite.expires_at_unix_ms,
},
crate::seam::InviteCheck::Used => CheckInviteResponse { state: State::Used as i32, ..Default::default() },
crate::seam::InviteCheck::Expired => CheckInviteResponse { state: State::Expired as i32, ..Default::default() },
crate::seam::InviteCheck::Unknown => CheckInviteResponse { state: State::Unknown as i32, ..Default::default() },
};
Ok(Response::new(answer))
}
async fn revoke_invite(
&self,
request: Request<RevokeInviteRequest>,
) -> Result<Response<RevokeInviteResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let id = request.into_inner().invite_id.trim().to_owned();
let invite = self
.ctx
.control
.invites()?
.into_iter()
.find(|invite| invite.invite_id == id);
let Some(invite) = invite else {
self.ctx
.require(&caller, Scope::Server, GrantLevel::Admin, "Access.RevokeInvite")?;
return Err(Status::not_found(format!("no invite {id}")));
};
self.invite_right(&caller, &invite.principal_id, "Access.RevokeInvite")?;
if self.ctx.control.revoke_invite(&id)?.is_none() {
return Err(Status::not_found(format!("no invite {id}")));
}
self.ctx.account_event(
self.ctx.account_of_principal(&invite.principal_id),
crate::enterprise::AccountAuditEvent::by(&caller, "invite.revoked", invite.principal_id.clone())
.detail(format!("invite {id}")),
);
Ok(Response::new(RevokeInviteResponse {}))
}
async fn web_challenge(
&self,
request: Request<WebChallengeRequest>,
) -> Result<Response<WebChallengeResponse>, Status> {
self.ctx.authorize(
&request,
Scope::Server,
GrantLevel::Admin,
"Access.WebChallenge",
)?;
let (challenge, expires_at_unix_ms) = self
.ctx
.web_challenges
.issue(crate::bus::now_unix_ms())
.map_err(Status::resource_exhausted)?;
Ok(Response::new(WebChallengeResponse {
challenge,
expires_at_unix_ms,
}))
}
async fn web_login(
&self,
request: Request<WebLoginRequest>,
) -> Result<Response<WebLoginResponse>, Status> {
const METHOD: &str = "Access.WebLogin";
let caller = self
.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let request = request.into_inner();
crate::webauthn::check_credential_id(&request.credential_id)
.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"));
}
ssh_key::PublicKey::from_openssh(request.session_public_key.trim()).map_err(|err| {
Status::invalid_argument(format!("`session_public_key` is not an OpenSSH public key: {err}"))
})?;
let now = crate::bus::now_unix_ms();
if request.session_expires_at_unix_ms <= now {
return Err(Status::invalid_argument(
"`session_expires_at_unix_ms` is required and must be in the future",
));
}
let expires = request.session_expires_at_unix_ms.min(now + WEB_SESSION_MAX_MS);
let data = crate::webauthn::client_data(&request.client_data_json)
.map_err(|why| ceremony_refused(METHOD, why))?;
let fresh = self.ctx.web_challenges.take(&data.challenge, now);
crate::webauthn::check_ceremony(&data, "webauthn.get", &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 stored = self
.ctx
.control
.credential(&request.credential_id)?
.ok_or_else(|| ceremony_refused(METHOD, "this passkey is not registered at gunnar".to_owned()))?;
let person = stored.credential.principal_id.clone();
let login_event = |refused: Option<&str>| {
let mut event = crate::enterprise::AccountAuditEvent {
kind: "login".to_owned(),
actor: person.clone(),
actor_machine: false,
target: "passkey".to_owned(),
detail: format!("passkey {:?}", stored.credential.label),
refused: false,
};
if let Some(why) = refused {
event.detail = format!("passkey {:?}: {why}", stored.credential.label);
event.refused = true;
}
self.ctx.account_event(self.ctx.account_of_principal(&person), event);
};
let verified = match crate::webauthn::verify_assertion(
&stored.public_key,
stored.credential.sign_count,
&request.client_data_json,
&request.authenticator_data,
&request.signature,
request.rp_id.trim(),
request.require_user_verification,
) {
Ok(verified) => verified,
Err(why) => {
login_event(Some(&why));
return Err(ceremony_refused(METHOD, why));
}
};
if let Some(plane) = self.ctx.control.sso() {
if let Some(why) = plane.passkey_login_refused(&stored.credential.principal_id)? {
login_event(Some(&why));
return Err(ceremony_refused(METHOD, why));
}
}
let outcome = self.ctx.control.web_login(crate::seam::WebLoginCommit {
credential_id: request.credential_id.clone(),
sign_count: verified.sign_count,
now_unix_ms: now,
session_openssh: request.session_public_key.trim().to_owned(),
comment: format!("web session (passkey {:?})", stored.credential.label),
expires_at_unix_ms: expires,
})?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.web_login",
principal = %outcome.principal.id,
relay = %caller.principal_id,
fingerprint = %outcome.fingerprint,
user_verified = verified.user_verified,
expires,
"a web login was verified and a session key enrolled"
);
login_event(None);
Ok(Response::new(WebLoginResponse {
principal: Some(outcome.principal),
credential_label: outcome.credential.label,
fingerprint: outcome.fingerprint,
user_verified: verified.user_verified,
session_expires_at_unix_ms: expires,
}))
}
async fn web_register(
&self,
request: Request<WebRegisterRequest>,
) -> Result<Response<WebRegisterResponse>, Status> {
const METHOD: &str = "Access.WebRegister";
let caller = self
.ctx
.authorize(&request, Scope::Server, GrantLevel::Admin, METHOD)?;
let request = request.into_inner();
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 secret = request.invite_secret.trim();
let principal_id = request.principal_id.trim();
if secret.is_empty() && principal_id.is_empty() {
return Err(Status::invalid_argument(
"name the principal, or redeem an invite with its secret",
));
}
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.take(&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))?;
let registered = self.ctx.control.web_register(crate::seam::WebRegistration {
invite_secret_hash: (!secret.is_empty())
.then(|| crate::webauthn::sha256_hex(secret.as_bytes())),
principal_id: (!principal_id.is_empty()).then(|| principal_id.to_owned()),
credential_id: request.credential_id.clone(),
public_key: request.public_key.clone(),
label: request.label.trim().to_owned(),
sign_count: attested.sign_count,
now_unix_ms: now,
})?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "access.web_register",
principal = %registered.principal.id,
relay = %caller.principal_id,
invite = !secret.is_empty(),
server_level = crate::auth::level_name(registered.server_level),
"a passkey was registered"
);
self.ctx.account_event(
self.ctx.account_of_principal(®istered.principal.id),
crate::enterprise::AccountAuditEvent {
kind: if secret.is_empty() { "credential.added" } else { "enrolment.completed" }.to_owned(),
actor: registered.principal.id.clone(),
actor_machine: false,
target: registered.principal.id.clone(),
detail: format!(
"passkey {:?}{}",
registered.credential.label,
if secret.is_empty() { "" } else { ", by an invite" }
),
refused: false,
},
);
Ok(Response::new(WebRegisterResponse {
credential: Some(registered.credential),
principal: Some(registered.principal),
server_level: registered.server_level as i32,
}))
}
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.take(&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 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 Some(up) = request.step_up else {
return Err(Status::failed_precondition(
"an approval needs a fresh step-up: the approver touches their OWN passkey, \
user-verified, over a challenge gunnar issued (the installer's break-glass \
token approves nothing: it is the owner's first invite)",
));
};
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
)));
}
crate::webauthn::check_credential_id(&up.credential_id)
.map_err(Status::invalid_argument)?;
if up.rp_id.trim().is_empty() || up.origin.trim().is_empty() {
return Err(Status::invalid_argument(
"the step-up's `rp_id` and `origin` are both required",
));
}
let data = crate::webauthn::client_data(&up.client_data_json)
.map_err(|why| ceremony_refused(METHOD, why))?;
let fresh = self.ctx.web_challenges.take(&data.challenge, now);
crate::webauthn::check_ceremony(&data, "webauthn.get", &up.origin, &up.rp_id)
.and_then(|()| crate::webauthn::check_relying_party(&self.ctx.config.web_relying_parties, &up.rp_id, &up.origin))
.map_err(|why| ceremony_refused(METHOD, why))?;
if !fresh {
return Err(ceremony_refused(
METHOD,
"the step-up's challenge was not issued here, was already used, or expired"
.to_owned(),
));
}
let stored = self.ctx.control.credential(&up.credential_id)?.ok_or_else(|| {
ceremony_refused(METHOD, "the step-up's passkey is not registered at gunnar".to_owned())
})?;
if stored.credential.principal_id != caller.principal_id {
return Err(ceremony_refused(
METHOD,
format!(
"the step-up is a passkey of {}, and the approver is {}: a step-up is \
the approver's own",
stored.credential.principal_id, caller.principal_id
),
));
}
let verified = crate::webauthn::verify_assertion(
&stored.public_key,
stored.credential.sign_count,
&up.client_data_json,
&up.authenticator_data,
&up.signature,
up.rp_id.trim(),
true,
)
.map_err(|why| ceremony_refused(METHOD, why))?;
(
caller.principal_id.clone(),
"step-up",
Some((up.credential_id, verified.sign_count)),
)
};
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))
}
}