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 require_stepped_up(
&self,
caller: &Caller,
stepped_up: bool,
method: &'static str,
) -> Result<(), Status> {
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
)));
}
if !stepped_up {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "enterprise.step_up_refused",
method,
principal = %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)"
)));
}
Ok(())
}
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 stepped_up = crate::auth::stepped_up(&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()
)));
}
self.require_stepped_up(&caller, stepped_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: None,
})?;
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),
}))
}
}