gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! `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(),
        }))
    }
}