use gunnar_proto::enterprise_server::Enterprise;
use gunnar_proto::{
AggregatesRequest, AggregatesResponse, ApproveOperatorAccessRequest, ApproveOperatorAccessResponse,
AuditExportFormat, AuditLogRequest, AuditLogResponse, CheckNameRequest, CheckNameResponse,
DecideNameReviewRequest, DecideNameReviewResponse, ExportAuditLogRequest, ExportAuditLogResponse,
GetNameListsRequest, GrantLevel, ListNameReviewsRequest, ListNameReviewsResponse,
ListOperatorAccessRequest, ListOperatorAccessResponse, NameLists, OperatorAccessState,
RefuseOperatorAccessRequest, RefuseOperatorAccessResponse, RequestOperatorAccessRequest,
RequestOperatorAccessResponse, RevokeOperatorAccessRequest, RevokeOperatorAccessResponse,
SetNameListsRequest,
};
use tonic::{Request, Response, Status};
use crate::auth::{Access, Caller};
use crate::enterprise::{AccountAuditEvent, AccountAuditQuery, OperatorAccessDecision};
use crate::service::EnterpriseService;
fn not_the_admin(caller: &Caller, account: &str, what: &str) -> Status {
Status::permission_denied(format!(
"principal {} is not an admin of account {account}: {what} is the account admin's only \
(a server-wide grant is not: the operator sees an account's totals, Enterprise.Aggregates)",
caller.principal_id
))
}
fn account_named(text: &str) -> Result<String, Status> {
let account = text.trim();
if account.is_empty() || account.contains('/') {
return Err(Status::invalid_argument("`account` is required: the account's name (its namespace)"));
}
Ok(account.to_owned())
}
fn state_of(row: &gunnar_proto::OperatorAccess) -> OperatorAccessState {
OperatorAccessState::try_from(row.state).unwrap_or(OperatorAccessState::Unspecified)
}
fn state_word(row: &gunnar_proto::OperatorAccess) -> &'static str {
match state_of(row) {
OperatorAccessState::Pending => "pending",
OperatorAccessState::Approved => "approved",
OperatorAccessState::Refused => "refused",
OperatorAccessState::Revoked => "revoked",
OperatorAccessState::Expired => "expired",
OperatorAccessState::Unspecified => "unknown",
}
}
fn minutes_word(minutes: u32) -> String {
if minutes % 60 == 0 {
format!("{} h", minutes / 60)
} else {
format!("{minutes} min")
}
}
impl EnterpriseService {
fn require_account_admin(&self, caller: &Caller, account: &str, method: &'static str, what: &str) -> Result<(), Status> {
if self.ctx.config.auth == crate::auth::AuthMode::AllowAllInsecure {
return Ok(());
}
self.ctx
.require(caller, Access::namespace(account), GrantLevel::Read, method)
.map_err(|status| match status.code() {
tonic::Code::PermissionDenied => not_the_admin(caller, account, what),
_ => status,
})?;
let admin = match self.ctx.control.enterprise() {
Some(plane) => plane.is_account_admin(&caller.principal_id, account)?,
None => caller.principal_id == account,
};
if admin {
return Ok(());
}
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
scope = %format!("namespace:{account}"),
decision = "deny",
reason = "not an admin of the account",
"gRPC control-plane call refused"
);
Err(not_the_admin(caller, account, what))
}
fn verify_step_up(
&self,
caller: &Caller,
up: Option<gunnar_proto::AccountStepUp>,
method: &'static str,
) -> Result<(Vec<u8>, u32), Status> {
let refused = |why: String| {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "enterprise.step_up_refused",
method,
principal = %caller.principal_id,
reason = %why,
"a step-up was refused"
);
Status::permission_denied(format!("{method} refused: {why}"))
};
let Some(up) = up else {
return Err(Status::failed_precondition(
"an approval needs a fresh step-up: the account admin touches their OWN passkey, \
user-verified, over a challenge gunnar issued (Access.WebChallenge)",
));
};
let person = self.ctx.control.principal(&caller.principal_id)?;
if person.as_ref().map(|p| p.kind) != Some(gunnar_proto::PrincipalKind::User as i32) {
return Err(Status::permission_denied(format!(
"only a person approves, and {} is not a person",
caller.principal_id
)));
}
crate::service::step_up_of_own_passkey(&self.ctx, crate::service::presented!(up), &caller.principal_id)?
.map(|done| (done.credential_id, done.sign_count))
.map_err(|why| match why {
nornir_identity_session::step_up::Refused::Malformed(why) => Status::invalid_argument(why),
nornir_identity_session::step_up::Refused::Ceremony(why) => refused(why),
})
}
fn look(&self, id: &str) -> Result<gunnar_proto::OperatorAccess, Status> {
self.ctx
.enterprise()?
.operator_access_get(id)?
.ok_or_else(|| Status::not_found(format!("no operator access request {id}")))
}
}
#[tonic::async_trait]
impl Enterprise for EnterpriseService {
async fn audit_log(&self, request: Request<AuditLogRequest>) -> Result<Response<AuditLogResponse>, Status> {
const METHOD: &str = "Enterprise.AuditLog";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let account = account_named(&request.account)?;
self.require_account_admin(&caller, &account, METHOD, "the audit log")?;
let query = AccountAuditQuery::from_filter(request.filter.as_ref(), request.before_seq, request.page_size);
Ok(Response::new(self.ctx.enterprise()?.account_audit_log(&account, &query)?))
}
async fn export_audit_log(
&self,
request: Request<ExportAuditLogRequest>,
) -> Result<Response<ExportAuditLogResponse>, Status> {
const METHOD: &str = "Enterprise.ExportAuditLog";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let account = account_named(&request.account)?;
self.require_account_admin(&caller, &account, METHOD, "the audit log")?;
let format = AuditExportFormat::try_from(request.format).unwrap_or(AuditExportFormat::Unspecified);
let query = AccountAuditQuery::from_filter(request.filter.as_ref(), None, 0);
let export = self.ctx.enterprise()?.account_audit_export(&account, &query, format)?;
self.ctx.account_event(
Some(account),
AccountAuditEvent::by(&caller, "audit.exported", export.file_name.clone())
.detail(format!("{} entries", export.entries)),
);
Ok(Response::new(export))
}
async fn aggregates(&self, request: Request<AggregatesRequest>) -> Result<Response<AggregatesResponse>, Status> {
self.ctx
.authorize(&request, Access::Server, GrantLevel::Read, "Enterprise.Aggregates")?;
Ok(Response::new(self.ctx.enterprise()?.aggregates()?))
}
async fn request_operator_access(
&self,
request: Request<RequestOperatorAccessRequest>,
) -> Result<Response<RequestOperatorAccessResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Enterprise.RequestOperatorAccess",
)?;
let request = request.into_inner();
let account = account_named(&request.account)?;
let waiting = self
.ctx
.enterprise()?
.operator_access_list(Some(&account))?
.into_iter()
.any(|row| row.operator == caller.principal_id && state_of(&row) == OperatorAccessState::Pending);
let row = self
.ctx
.enterprise()?
.operator_access_request(&account, &caller.principal_id, &request.reason, request.minutes)?;
if !waiting {
self.ctx.account_event(
Some(account),
AccountAuditEvent::by(&caller, "operator_access.requested", row.operator.clone()).detail(format!(
"for {} — code {} — {}",
minutes_word(row.minutes),
row.code,
row.reason
)),
);
}
Ok(Response::new(RequestOperatorAccessResponse { request: Some(row) }))
}
async fn list_operator_access(
&self,
request: Request<ListOperatorAccessRequest>,
) -> Result<Response<ListOperatorAccessResponse>, Status> {
const METHOD: &str = "Enterprise.ListOperatorAccess";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let requests = if request.account.trim().is_empty() {
self.ctx.require(&caller, Access::Server, GrantLevel::Admin, METHOD)?;
self.ctx
.enterprise()?
.operator_access_list(None)?
.into_iter()
.filter(|row| row.operator == caller.principal_id)
.collect()
} else {
let account = account_named(&request.account)?;
self.require_account_admin(&caller, &account, METHOD, "the operator's looks into it")?;
self.ctx.enterprise()?.operator_access_list(Some(&account))?
};
Ok(Response::new(ListOperatorAccessResponse { requests }))
}
async fn approve_operator_access(
&self,
request: Request<ApproveOperatorAccessRequest>,
) -> Result<Response<ApproveOperatorAccessResponse>, Status> {
const METHOD: &str = "Enterprise.ApproveOperatorAccess";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let held = self.look(request.request_id.trim())?;
self.require_account_admin(&caller, &held.account, METHOD, "approving the operator's look")?;
if state_of(&held) != OperatorAccessState::Pending {
return Err(Status::failed_precondition(format!(
"this request is {} and cannot be approved now",
state_word(&held)
)));
}
if request.code.trim() != held.code {
return Err(Status::failed_precondition(format!(
"the code {:?} is not this request's: approve the request whose code the operator shows",
request.code.trim()
)));
}
let step_up = self.verify_step_up(&caller, request.step_up, METHOD)?;
let decided = self.ctx.enterprise()?.operator_access_decide(&OperatorAccessDecision {
request_id: held.request_id.clone(),
approver: caller.principal_id.clone(),
approve: true,
minutes: (request.minutes > 0).then_some(request.minutes),
step_up: Some(step_up),
})?;
self.ctx.account_event(
Some(decided.account.clone()),
AccountAuditEvent::by(&caller, "operator_access.approved", decided.operator.clone()).detail(format!(
"for {} — code {} — reads until {}",
minutes_word(decided.minutes),
decided.code,
decided.granted_until_unix_ms
)),
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "enterprise.operator_access_approved",
request = %decided.request_id,
by = %caller.principal_id,
minutes = decided.minutes,
"an account admin approved the operator's look"
);
Ok(Response::new(ApproveOperatorAccessResponse { request: Some(decided) }))
}
async fn refuse_operator_access(
&self,
request: Request<RefuseOperatorAccessRequest>,
) -> Result<Response<RefuseOperatorAccessResponse>, Status> {
const METHOD: &str = "Enterprise.RefuseOperatorAccess";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let held = self.look(request.request_id.trim())?;
self.require_account_admin(&caller, &held.account, METHOD, "refusing the operator's look")?;
let decided = self.ctx.enterprise()?.operator_access_decide(&OperatorAccessDecision {
request_id: held.request_id.clone(),
approver: caller.principal_id.clone(),
approve: false,
minutes: None,
step_up: None,
})?;
self.ctx.account_event(
Some(decided.account.clone()),
AccountAuditEvent::by(&caller, "operator_access.refused", decided.operator.clone())
.detail(format!("code {}", decided.code)),
);
Ok(Response::new(RefuseOperatorAccessResponse { request: Some(decided) }))
}
async fn revoke_operator_access(
&self,
request: Request<RevokeOperatorAccessRequest>,
) -> Result<Response<RevokeOperatorAccessResponse>, Status> {
const METHOD: &str = "Enterprise.RevokeOperatorAccess";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let held = self.look(request.request_id.trim())?;
if held.operator == caller.principal_id {
self.ctx.require(&caller, Access::Server, GrantLevel::Read, METHOD)?;
} else {
self.require_account_admin(&caller, &held.account, METHOD, "ending the operator's look")?;
}
let ended = self.ctx.enterprise()?.operator_access_revoke(&held.request_id, &caller.principal_id)?;
self.ctx.account_event(
Some(ended.account.clone()),
AccountAuditEvent::by(&caller, "operator_access.revoked", ended.operator.clone())
.detail(format!("code {}", ended.code)),
);
Ok(Response::new(RevokeOperatorAccessResponse { request: Some(ended) }))
}
async fn check_name(&self, request: Request<CheckNameRequest>) -> Result<Response<CheckNameResponse>, Status> {
let caller = self.ctx.caller(&request)?;
if !caller.authenticated && self.ctx.config.auth != crate::auth::AuthMode::AllowAllInsecure {
return Err(crate::auth::AuthFailure::Missing.status());
}
let request = request.into_inner();
if !matches!(request.kind.as_str(), "account" | "member" | "namespace" | "repo") {
return Err(Status::invalid_argument("`kind` is `account`, `member`, `namespace` or `repo`"));
}
if request.name.trim().is_empty() {
return Err(Status::invalid_argument("`name` is required"));
}
crate::auth::audit_self("Enterprise.CheckName", &caller, self.ctx.config.auth, self.ctx.audit.as_deref());
let verdict = self.ctx.enterprise()?.name_check(&request.kind, request.name.trim(), request.account.trim())?;
Ok(Response::new(CheckNameResponse {
verdict: verdict.verdict,
sentence: verdict.sentence,
}))
}
async fn get_name_lists(&self, request: Request<GetNameListsRequest>) -> Result<Response<NameLists>, Status> {
self.ctx
.authorize(&request, Access::Server, GrantLevel::Read, "Enterprise.GetNameLists")?;
Ok(Response::new(self.ctx.enterprise()?.name_lists()?))
}
async fn set_name_lists(&self, request: Request<SetNameListsRequest>) -> Result<Response<NameLists>, Status> {
let caller = self
.ctx
.authorize(&request, Access::Server, GrantLevel::Admin, "Enterprise.SetNameLists")?;
let lists = request.into_inner().lists.unwrap_or_default();
let stored = self.ctx.enterprise()?.set_name_lists(lists)?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "enterprise.name_lists_set",
principal = %caller.principal_id,
blocked = stored.blocked.len(),
borderline = stored.borderline.len(),
reserved = stored.reserved.len(),
"the name filter's lists were replaced"
);
Ok(Response::new(stored))
}
async fn list_name_reviews(
&self,
request: Request<ListNameReviewsRequest>,
) -> Result<Response<ListNameReviewsResponse>, Status> {
const METHOD: &str = "Enterprise.ListNameReviews";
let caller = self.ctx.caller(&request)?;
let account = account_named(&request.into_inner().account)?;
self.require_account_admin(&caller, &account, METHOD, "the names held for review")?;
let reviews = self.ctx.enterprise()?.name_reviews(&account)?;
let pending = reviews.iter().filter(|r| r.state == "pending").count() as u32;
Ok(Response::new(ListNameReviewsResponse { reviews, pending }))
}
async fn decide_name_review(
&self,
request: Request<DecideNameReviewRequest>,
) -> Result<Response<DecideNameReviewResponse>, Status> {
const METHOD: &str = "Enterprise.DecideNameReview";
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let account = account_named(&request.account)?;
self.require_account_admin(&caller, &account, METHOD, "deciding a held name")?;
let review = self
.ctx
.enterprise()?
.name_review_decide(&account, request.id.trim(), request.allow, &caller.principal_id)?;
self.ctx.account_event(
Some(account),
AccountAuditEvent::by(
&caller,
if request.allow { "name.allowed" } else { "name.refused_by_admin" },
review.name.clone(),
)
.detail(format!("a {} name", review.kind)),
);
Ok(Response::new(DecideNameReviewResponse { review: Some(review) }))
}
}