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(),
}))
}
}