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/billing.rs

//! `Billing`. **The appliance's word that this human may renew — or order room
//! for — this tenant.**
//!
//! Metered only. The community appliance does not decline to sell a period, it
//! does not contain the code — the same doctrine `service::entitlement` is
//! compiled under, and the reason the open build mentions no money at all.
//!
//! # Why gunnar does the grant check and not the console
//!
//! The console (`gunnar-ui`'s BFF) holds a **server-wide admin grant** on this
//! appliance, because it renders operator screens. So a console that decided
//! for itself "this browser session may only touch namespace X" would mean any
//! routing bug in the console — one namespace read off the wrong request, one
//! session cached one request too long — was full administrative reach over
//! every customer on the box. The blast radius of a console bug would be the
//! whole estate.
//!
//! So the console does not decide. It presents the human's **own** credential —
//! `gunnar-sshsig-v2`, signed with the ed25519 key `gunnar-ui`'s `TenantKeys`
//! enrolled on that person's own gunnar principal — and the appliance answers
//! the scope question itself, out of the identity table, with the model it
//! already has and no second one invented here:
//!
//! ```text
//! require(&caller, Access::namespace(&ns), GrantLevel::Admin, "Billing.MintActorTicket")
//!   -> Identity::may -> AuthTable::may -> owns_account (a principal's id IS
//!      the namespace it owns, a string equality with no row to get wrong)
//!      or a Scope::Namespace grant
//! ```
//!
//! That is the same call `Repos.Delete` makes about the same namespace. There
//! is no billing-specific authorisation path, and that is the design: a second
//! way to answer "may this caller act for this tenant" is a second way to get
//! it wrong.
//!
//! # The direction the signature travels
//!
//! Every other signed thing on the monetize seam travels monetize → appliance
//! and is VERIFIED here. A ticket travels the other way, because monetize
//! authenticates exactly one caller on its gRPC surface — a shared bearer — and
//! therefore cannot tell one browser session from another. The appliance is the
//! one party that knows, so it says so, and monetize checks the signature
//! offline rather than calling back to a box that may be the very box the
//! customer is paying to un-suspend.
//!
//! # One format, not two
//!
//! The ticket is `monetize_embed::ticket::ActorTicket`, the signed bytes are
//! that crate's `ticket_message`, and the base64 is that crate's
//! `signing::base64_encode`. gunnar writes no second canonical form: the
//! verifier is code gunnar does not run, and two encoders is exactly how they
//! stop agreeing without anybody noticing.

use gunnar_proto::billing_server::Billing;
use gunnar_proto::{
    ApplianceIdentityRequest, ApplianceIdentityResponse, GrantLevel, MintActorTicketRequest,
    MintActorTicketResponse,
};
use monetize_embed::signing::base64_encode;
use monetize_embed::ticket::{
    ticket_message, ActorTicket, MAX_LIFE_MS, PURPOSE_ORDER, PURPOSE_READ, PURPOSE_RENEW,
};
use tonic::{Request, Response, Status};

use crate::auth::Access;
use crate::service::BillingService;

/// **Cancelling the tenant's own order** — an unpaid one (`Purchase.Cancel`)
/// or a refund of one nothing can finish (`Purchase.Abandon`); monetize's
/// word, `monetize_embed::PURPOSE_CANCEL` (2026-10-04). Spelled here because
/// the published `monetize-embed` this tree resolves predates the constant;
/// the verifier compares the purpose as a word, so the literal is the
/// contract.
pub const PURPOSE_CANCEL: &str = "cancel";

/// **A private Gunnar's owner, at its SELLER** (private ownership (c),
/// 2026-10-07): `monetize_embed::ticket::PURPOSE_ESTATE`. The estate's
/// console presents it at the seller's monetize (`EstateOwner.Ladder` /
/// `ChangePlan`); the seller verifies it under this appliance's key and that
/// its tenant is the account the order named. Names no caps. The literal: the
/// published `monetize-embed` this tree resolves predates the constant.
pub const PURPOSE_ESTATE: &str = "estate";

/// **The product id this appliance mints tickets for**, as monetize's
/// `ProductInfo.id`.
///
/// Also the `issuer`. They are the same word on gunnar and two fields in the
/// ticket, because a monetize holding keys for two products needs to tell which
/// appliance wrote a ticket (`issuer`) separately from which product's period
/// it may buy (`product`), and the day those stop coinciding is not a day to
/// discover that one constant was doing both jobs.
const PRODUCT: &str = "gunnar";

/// **How long a minted ticket lives: one minute.**
///
/// `monetize_embed::ticket::MAX_LIFE_MS` is ten, and that cap belongs to the
/// VERIFIER — `verify_ticket` refuses a longer ticket even when the signature
/// is perfect, so an appliance talked into minting an eternal one still cannot
/// spend it. This is the issuer's own, an order of magnitude under it, because
/// a ticket is fetched inside the request that spends it: what it has to cover
/// is one round trip and a clock disagreement, not a session. `debug_assert`ing
/// the relationship rather than deriving the number from the cap keeps the two
/// independent — deriving it would mean a verifier that widened its cap
/// silently widened every issuer with it.
const TICKET_LIFE_MS: i64 = 60_000;

/// The nonce's length in bytes before hex. 16 bytes is 128 bits of collision
/// resistance for a value whose only job is to be different from the last one
/// within a ten-minute window, and 32 hex characters is comfortably inside the
/// 256-byte ceiling `verify_ticket` enforces.
const NONCE_BYTES: usize = 16;

impl BillingService {
    /// A fresh nonce, from the OS.
    ///
    /// The verifier keeps a spent-nonce ring, so a repeated nonce is a refused
    /// renewal — and the most confusing possible refusal, on the one page whose
    /// job is to take money. A counter would be enough for uniqueness on one
    /// box and would stop being enough the moment a twin mints one too.
    fn nonce() -> String {
        use rand::Rng as _;
        let mut bytes = [0u8; NONCE_BYTES];
        rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
    }
}

#[tonic::async_trait]
impl Billing for BillingService {
    /// **Mint a ticket for one namespace**, for one of two verbs.
    ///
    /// # Two purposes, and why an order carries an amount
    ///
    /// `renew` is another period of what the tenant already holds. It names no
    /// caps at all — they come off the tenant's stored fact — so the appliance
    /// vouching for the tenant is the whole of what monetize needs.
    ///
    /// `order` names an AMOUNT, and **an amount the ticket did not cover is an
    /// amount nobody vouched for**. A ticket saying only "this human may buy"
    /// would be a blank cheque signed by this appliance, spendable by whatever
    /// sent it, for whatever it liked. So the caps go inside the signed message
    /// (`ActorTicket::covering`), `monetize_embed::verify_ticket` compares them
    /// against the order they arrived with, and a mismatch is refused rather
    /// than ignored — the same rule the tenant, product and purpose fields are
    /// already held to. That was the owner's ruling: bind the caps.
    ///
    /// **The grant check is the same one, unchanged, for both.** An order is a
    /// renewal with an amount attached; it is not a different question about
    /// who the caller is, and inventing a second answer to "may this caller act
    /// for this tenant" is a second way to get it wrong.
    ///
    /// # The two confusions, refused by name
    ///
    /// An `order` with no caps and a `renew` with caps are both
    /// `INVALID_ARGUMENT`, named. Both are a client that has confused the two
    /// paths, both would produce a ticket `verify_ticket` then refuses, and a
    /// signature failure on the page whose whole job is to take money is a
    /// refusal the customer cannot act on. See the checks themselves for why
    /// silently dropping the caps on a renewal would be worse than refusing.
    ///
    /// # The grant is `Admin` on the namespace, and that is a ruling
    ///
    /// `GrantLevel::Admin`, not `Write`. **This knowingly excludes an
    /// organisation member who holds only `Write` on the namespace and who may
    /// well be the person who actually pays the bill.** That exclusion was
    /// weighed and taken, in the direction whose mistake is recoverable:
    ///
    /// * widening to `Write` later is a one-line change here, and nobody has to
    ///   be told it happened — a customer who could not renew yesterday can
    ///   renew today;
    /// * narrowing from `Write` to `Admin` later would take renewal away from
    ///   customers who had it and were using it, which is a support incident
    ///   with an invoice attached.
    ///
    /// So the level starts where a mistake is cheap. `Admin` is also the level
    /// every other verb that acts ON a namespace rather than IN it already
    /// takes, so a tenant's mental model — "the person who can delete the
    /// namespace is the person who can pay for it" — needs no exception.
    ///
    /// # Nothing is minted on a refusal
    ///
    /// The order below is load-bearing and not incidental. The caller is
    /// resolved, the arguments are checked, the grant is checked, and only then
    /// is the clock read and the key touched. A handler that signed first and
    /// checked afterwards would leave a valid, signed, spendable ticket for
    /// another tenant in whatever sits between here and the caller — a trace, a
    /// log line, a buffered response — while still returning
    /// `PERMISSION_DENIED` and looking correct.
    /// `tests/billing.rs::a_caller_may_not_mint_a_ticket_for_a_namespace_they_
    /// do_not_hold` counts the appliance's signatures to hold this to it.
    ///
    /// # A lapsed tenant reaches this
    ///
    /// Deliberately, and it is the only reason self-service renewal works.
    /// gunnar's entitlement gate is `gunnar_ssh::PlanGate`, consulted by the
    /// SSH/git door alone; the gRPC control plane has no equivalent, so a
    /// Suspended namespace authenticates and is served here exactly as a Paid
    /// one is. A design in which the tenant must be paid-up before they can pay
    /// cannot serve the case it exists for.
    /// `tests/billing.rs::a_lapsed_tenant_can_still_mint_a_ticket` pins that,
    /// because nothing else in the tree connects "the gRPC plane has no plan
    /// check" to "a customer can renew", and adding one would kill renewal
    /// silently.
    async fn mint_actor_ticket(
        &self,
        request: Request<MintActorTicketRequest>,
    ) -> Result<Response<MintActorTicketResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();

        let namespace = request.namespace;
        if namespace.trim().is_empty() {
            return Err(Status::invalid_argument("namespace is required"));
        }
        // An empty purpose is RENEWAL — the purpose that predates the field —
        // so a console that never learned it still renews. A word this
        // appliance does not mint is refused HERE: a ticket minted for a verb
        // this box does not know would be refused by the verifier anyway, but
        // refused as somebody else's problem, from a box that had already
        // signed something it should not have.
        let purpose = match request.purpose.as_str() {
            "" | PURPOSE_RENEW => PURPOSE_RENEW,
            PURPOSE_ORDER => PURPOSE_ORDER,
            // **Reading back what this tenant has already been sold.** Buys
            // nothing, names no caps, and has its own word precisely so that a
            // renewal ticket cannot be spent on a read: a ticket is single-use,
            // so listing a history with a renewal ticket burned the one the
            // customer would have renewed with, and it made this appliance
            // vouch for a verb the caller was not performing.
            PURPOSE_READ => PURPOSE_READ,
            // **Cancelling the tenant's own order** (2026-10-04). Buys nothing
            // and names no caps, and has its own word for the reason `read`
            // does: a single-use ticket for one verb must never be spendable
            // on another. The same line every purpose here takes — the person
            // who may order may cancel what they ordered.
            PURPOSE_CANCEL => PURPOSE_CANCEL,
            // **The estate's owner, at its seller** (private ownership (c)).
            // Buys nothing here and names no caps; the same line as every
            // purpose — the admin of the namespace, which on a sold estate is
            // the account its order named.
            PURPOSE_ESTATE => PURPOSE_ESTATE,
            other => {
                return Err(Status::invalid_argument(format!(
                    "this appliance mints {PURPOSE_RENEW:?}, {PURPOSE_ORDER:?}, \
                     {PURPOSE_READ:?}, {PURPOSE_CANCEL:?} and {PURPOSE_ESTATE:?} tickets and \
                     was asked for {other:?}"
                )))
            }
        };
        // ── The amount, and the two ways a client confuses the paths ────────
        //
        // **Both refused by name, neither ignored.** They are symmetric and
        // they are not the same mistake:
        //
        // * an `order` with no caps is a client that meant `renew`. Minting it
        //   would put out a ticket covering nothing, which `verify_ticket`
        //   then compares against an order that names something and refuses —
        //   a signature failure on the page whose whole job is to take money,
        //   which the customer cannot act on and the operator cannot read.
        // * a `renew` with caps is an order wearing a renewal's word. A
        //   renewal sells another period of what is already held, and the caps
        //   come off the tenant's stored fact; `verify_ticket` refuses a
        //   renewal ticket that carries any, by its own rule. Silently
        //   dropping them here would be worse than refusing: the client would
        //   get a working ticket for an amount it asked for and did not get.
        //
        // Neither is a grant question, so both are `INVALID_ARGUMENT` and both
        // are answered before the check below — the empty-namespace rule
        // above, for the same reason it is where it is.
        let caps = request.caps;
        if purpose == PURPOSE_ORDER && caps.is_empty() {
            return Err(Status::invalid_argument(format!(
                "an {PURPOSE_ORDER:?} ticket covers the caps the order will name and this \
                 request names none; caps is required for an order, and a request for another \
                 period of what is already held is {PURPOSE_RENEW:?}"
            )));
        }
        if (purpose == PURPOSE_READ || purpose == PURPOSE_CANCEL || purpose == PURPOSE_ESTATE)
            && !caps.is_empty()
        {
            return Err(Status::invalid_argument(format!(
                "a {purpose:?} ticket names no caps and this request names {}; it buys \
                 nothing, so there is no amount for it to cover",
                caps.len()
            )));
        }
        if purpose == PURPOSE_RENEW && !caps.is_empty() {
            return Err(Status::invalid_argument(format!(
                "a {PURPOSE_RENEW:?} ticket names no caps and this request names {}; a renewal \
                 sells another period of what is already held, so the caps come off the \
                 tenant's stored fact, and a request for more room is {PURPOSE_ORDER:?}",
                caps.len()
            )));
        }

        // ── Members never buy (owner, 2026-10-07) ─────────────────────────
        //
        // A customer is an ACCOUNT that pays. A MEMBER — a user an account's
        // admin enrolled — uses the account's room and never reaches the
        // shop; more room is the admin's to buy. Refused for any namespace and
        // any purpose, before the grant check: `owns_account` is a string
        // equality, so a member's OWN name passes it although no namespace of
        // that name exists — the walkthrough's "ben paid for nothing". The
        // sentence is about the caller himself, so it tells him nothing new.
        if let Some(account) = self.ctx.employer_of(&caller.principal_id)? {
            return Err(Status::permission_denied(format!(
                "{who} is a member of {account}: a member uses {account}'s room and buys \
                 nothing — more room for {account} is its admin's to buy. Ask the admin of \
                 {account}.",
                who = caller.principal_id
            )));
        }

        // ── THE line. Everything above is parsing; nothing below is a check ──
        self.ctx.require(
            &caller,
            Access::namespace(&namespace),
            GrantLevel::Admin,
            "Billing.MintActorTicket",
        )?;

        // **Room is bought FOR a namespace that exists.** A ticket for a name
        // this box holds no namespace under would sell room that lands
        // nowhere ("your room is ready" for nothing). Asked after the grant
        // check, so only somebody who may act for the name learns it.
        if self.ctx.control.namespace(&namespace)?.is_none() {
            return Err(Status::not_found(format!(
                "there is no namespace {namespace} on this Gunnar, so there is nothing to buy \
                 room for: room is bought for an account that exists"
            )));
        }

        debug_assert!(
            TICKET_LIFE_MS > 0 && TICKET_LIFE_MS <= MAX_LIFE_MS,
            "a minted ticket must live inside the cap the verifier enforces"
        );
        let issued = crate::bus::now_unix_ms();
        // `.covering(caps)` on both paths, and for renewal `caps` is provably
        // empty two checks above. Branching here to skip the builder would mean
        // two mint sites where the rule holds for different reasons, and one of
        // them would eventually stop holding.
        let ticket = ActorTicket::unsigned(
            PRODUCT,
            PRODUCT,
            &namespace,
            purpose,
            BillingService::nonce(),
            issued,
            TICKET_LIFE_MS,
        )
        .covering(caps);
        let signature = self
            .ctx
            .control
            .actor_ticket_sign(&ticket_message(&ticket))?;
        let ticket = ActorTicket {
            signature: base64_encode(&signature),
            ..ticket
        };
        let expires_unix_ms = ticket.expires_unix_ms;
        // How many meters the ticket vouches for, for the line below. The COUNT
        // and not the map: an order's numbers are the customer's business with
        // monetize, and a log line is the one place on this box that is read by
        // whoever can read the disk. Zero says "renewal" as clearly as the
        // purpose beside it does.
        let covered = ticket.caps.len();
        // The bytes that go out are the bytes that were signed. Anything that
        // re-serialises a ticket between here and the verifier breaks it, so
        // the encode happens once, here, and the result is carried as `bytes`.
        let bytes = serde_json::to_vec(&ticket)
            .map_err(|err| Status::internal(format!("the ticket could not be encoded: {err}")))?;

        // The account admin's log: a purchase, a renewal or a cancellation
        // was STARTED here (the money moves at monetize; its result lands as
        // `Entitlement.Set`, `billing.bought` / `prolonged` / `upgraded`).
        let started = match purpose {
            PURPOSE_ORDER => Some("billing.order_started"),
            PURPOSE_RENEW => Some("billing.renewal_started"),
            PURPOSE_CANCEL => Some("billing.cancel_started"),
            _ => None,
        };
        if let Some(kind) = started {
            self.ctx.account_event(
                self.ctx.account_of_namespace(&namespace),
                crate::enterprise::AccountAuditEvent::by(&caller, kind, namespace.clone())
                    .detail(format!("{covered} meters named")),
            );
        }
        tracing::info!(
            event = "billing.ticket.minted",
            method = "Billing.MintActorTicket",
            principal = %caller.principal_id,
            namespace = %namespace,
            purpose,
            covered,
            expires_unix_ms,
            "minted an actor ticket"
        );

        Ok(Response::new(MintActorTicketResponse {
            ticket: bytes,
            // Negative only if the clock is before 1970, which every other
            // timestamp on this box would already be lying about.
            expires_unix_ms: expires_unix_ms.max(0) as u64,
        }))
    }

    /// **The public half of the key the tickets above are signed with.**
    ///
    /// Server `Admin`. The key is PUBLIC and refusing to serve it keeps nothing
    /// secret — that is written down in `billing.proto` so this gate is not
    /// mistaken for protecting key material. It is server-scoped because it is
    /// OPERATOR CONFIGURATION: the one person who needs the string is the one
    /// wiring this appliance to a monetize, and serving it to every tenant who
    /// asks would invite the reading that holding the key is worth something.
    /// It is not; the key verifies tickets, it does not mint them.
    async fn appliance_identity(
        &self,
        request: Request<ApplianceIdentityRequest>,
    ) -> Result<Response<ApplianceIdentityResponse>, Status> {
        self.ctx.authorize(
            &request,
            Access::Server,
            GrantLevel::Admin,
            "Billing.ApplianceIdentity",
        )?;
        Ok(Response::new(ApplianceIdentityResponse {
            public_key_hex: self.ctx.control.actor_ticket_public_key_hex()?,
            issuer: PRODUCT.to_owned(),
        }))
    }
}