Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-grpc/src/service/enterprise.rs

//! `Enterprise`: the owner's enterprise list (2026-10-05) — the account
//! admin's audit log, the operator's aggregates and the look the account admin
//! grants, the name filter. See `enterprise.proto` for the rules; this file
//! authorises, verifies a step-up, and hands the plane what it decides.
//!
//! **Who is an account's admin is the plane's answer**
//! ([`crate::seam::ControlPlane::is_account_admin`]): the account's own
//! person, or one its admin made Admin over the namespace. A server-scope
//! grant never is — the operator does not read an account's log.

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;

/// The sentence a caller who is not the account's admin reads.
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 {
    /// **The caller must be an admin of `account`**, audited in the control
    /// plane's trail as the decision it is.
    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(());
        }
        // The narrowed principals, the browse box, the gated account: the
        // gate's own rules first, at the account's namespace and `Read`.
        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))
    }

    /// **The approver stepped up** (IDENTITY-HOST): a PERSON whose
    /// identity-core session holds a fresh, user-verified touch of their own
    /// passkey (the core's rule). A signed key is never a step-up.
    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(())
    }

    /// The request `id`, or NOT_FOUND.
    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() {
            // The operator's own requests.
            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),
        }))
    }
}