crates/gunnar-grpc/src/service/entitlement.rs
//! `Entitlement`. The paid state per namespace — **the metered appliance's
//! service**, compiled only with the `metered` feature.
//!
//! `Set` is the one writer and it is `Admin` level on the server: an
//! operator with the server's admin key telling this box what a tenant has
//! paid for (`gunnar control entitlement set`). **It is operator-only**
//! (monetize v2.0 P2 T13, A28 L13): monetize no longer calls it. The ONE box
//! pulls every Gunnar estate, so a paid plan reaches the box as a signed order
//! its main pulls; the monetize guest has no gRPC half. A namespace admin is
//! not a server admin and is refused (`tests/entitlement_scope.rs`). The signature check, the attestation line and
//! the control-document write are all the control plane's
//! (`ControlPlane::entitlement_set`); this handler authorises, validates the
//! shape, and reports. A fact that does not verify comes back as a gRPC
//! error naming the reason — `PERMISSION_DENIED` for a signature the
//! configured monetize key did not make, `INVALID_ARGUMENT` for a fact that
//! is not even well formed — never as a field a caller could overlook.
//!
//! The three reads are server-wide `Read`: a plan is the provider's business
//! with the tenant, and the tenant's own view of it is the billing page, not
//! this RPC.
use gunnar_proto::entitlement_server::Entitlement;
use gunnar_proto::{
EntitlementFact, EntitlementState, GetEntitlementRequest, GetEntitlementResponse, GrantLevel,
ListEntitlementsRequest, ListEntitlementsResponse, SetEntitlementRequest,
SetEntitlementResponse,
};
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::service::EntitlementService;
/// The checks a fact must pass before the control plane is asked to verify
/// its signature: cheap, shape-only, and answered as `INVALID_ARGUMENT` so a
/// malformed push is told apart from a forged one.
///
/// `pub` so `tests/entitlement_caps_door.rs` can assert the refusals directly.
/// This is the door itself — the handler below calls it before it looks at
/// anything else — so a test of this function is a test of the door and not of
/// a copy of it.
pub fn well_formed(fact: &EntitlementFact) -> Result<(), Status> {
if fact.namespace.is_empty() {
return Err(Status::invalid_argument("fact.namespace is required"));
}
if fact.plan.is_empty() {
return Err(Status::invalid_argument("fact.plan is required"));
}
if fact.state() == EntitlementState::Unspecified {
return Err(Status::invalid_argument(
"fact.state is unspecified; a fact names one of Free, Paid, Grace, Suspended, Retention",
));
}
if fact.source.is_empty() {
return Err(Status::invalid_argument(
"fact.source is required: `operator` or `payment:<vendor>:<reference>`",
));
}
if fact.signature.len() != 64 {
return Err(Status::invalid_argument(format!(
"fact.signature is {} byte(s); an Ed25519 signature is 64",
fact.signature.len()
)));
}
// **The stamp is both halves or neither.** An issue time with no signature
// over it is not a weaker fact, it is one whose monotonic half anybody could
// have written — and the control plane refuses it as forged a moment later.
// Saying so HERE, in the same words as the signature length above, tells a
// caller which field is wrong instead of only that the fact did not verify.
match (fact.issued_unix_ms, fact.issued_signature.len()) {
(None, 0) | (Some(_), 64) => {}
(None, n) => {
return Err(Status::invalid_argument(format!(
"fact.issued_signature is {n} byte(s) but fact.issued_unix_ms is absent; \
a fact carries both halves of the stamp or neither"
)))
}
(Some(_), n) => {
return Err(Status::invalid_argument(format!(
"fact.issued_signature is {n} byte(s); the Ed25519 signature over \
fact.issued_unix_ms is 64"
)))
}
}
// **A cap this appliance can neither refuse on nor report is refused HERE,
// at the sale, rather than carried silently for the life of the plan.**
//
// `caps` is an open `map<string, uint64>` and this handler used to look
// straight past it: a fact could name any key and gunnar stored it, logged
// an attestation line for it and then did nothing with it, ever. The
// operator found out from a tenant three months later, or never.
//
// The alternative that was tried on paper and rejected: log a warning and
// accept. A warning is what the attestation line already was — a trace
// nobody reads until someone is already unhappy — and the doctrine on this
// box is that a refusal is a message. `crate::service::meters` carries the
// measured blast radius: this cannot reach the boot path, and an
// `INVALID_ARGUMENT` leaves the tenant's PREVIOUS fact in force, so the
// worst case is an operator with until `paid_until` to fix an order.
//
// A cap on a MEASURED meter is not refused — see
// [`crate::service::meters::caps_gunnar_cannot_honour`] for why selling a
// labelled measurement is honest and refusing it would not be.
let unknown = crate::service::meters::caps_gunnar_cannot_honour(fact.caps.keys());
if !unknown.is_empty() {
return Err(Status::invalid_argument(format!(
"fact.caps names {} this appliance can neither enforce nor report, so a tenant \
would be sold a number nothing on this box ever reads; it honours {:?}",
unknown
.iter()
.map(|meter| format!("`{meter}`"))
.collect::<Vec<_>>()
.join(", "),
crate::service::meters::honourable_caps(),
)));
}
Ok(())
}
#[tonic::async_trait]
impl Entitlement for EntitlementService {
async fn get(
&self,
request: Request<GetEntitlementRequest>,
) -> Result<Response<GetEntitlementResponse>, Status> {
// **The namespace FIRST, then the grant on it.**
//
// The order is the point. An empty name is a malformed request and is
// answered as one, before any grant is consulted — the same shape
// `Billing.MintActorTicket` uses — so a caller cannot learn anything
// about what they may reach by varying a name they did not supply.
let caller = self.ctx.caller(&request)?;
let namespace = request.into_inner().namespace;
if namespace.is_empty() {
return Err(Status::invalid_argument("namespace is required"));
}
// **`Access::Server` until 2026-09-17, and this is a WIDENING that
// takes nothing away.**
//
// A `Scope::Server` grant satisfies `Access::namespace(..)` —
// `Scope::covers_namespace` answers true for `Server` — so every
// caller that could read an entitlement before still can, on every
// namespace, unchanged. What it adds is exactly one capability: a
// principal may read the entitlement of a namespace it holds.
//
// It is added because a tenant could not otherwise be shown their own
// renewal date. The console reads this with the CALLER's key (the one
// `gunnar-ui`'s `TenantKeys` enrolled on their own principal), so with
// a server-scoped requirement the answer was `PermissionDenied` for
// the one person the fact is about.
//
// `Entitlement.Set` is deliberately NOT touched: writing a fact is
// monetize's, it is verified against monetize's key, and nothing about
// reading your own plan argues for being able to grant yourself one.
self.ctx.require(
&caller,
Access::namespace(&namespace),
GrantLevel::Read,
"Entitlement.Get",
)?;
let fact = self.ctx.control.entitlement_get(&namespace)?;
Ok(Response::new(GetEntitlementResponse { fact }))
}
async fn set(
&self,
request: Request<SetEntitlementRequest>,
) -> Result<Response<SetEntitlementResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Entitlement.Set",
)?;
let fact = request
.into_inner()
.fact
.ok_or_else(|| Status::invalid_argument("fact is required"))?;
well_formed(&fact)?;
let namespace = fact.namespace.clone();
// **A plan is set for an account that exists** (round 5.3; the members
// walkthrough of 2026-10-07: ben, a member of acme, paid for room for
// tenant `ben`, and this box took the fact although no namespace `ben`
// exists — room that lands nowhere, and a shop that now knows a member
// as a customer). `Billing.MintActorTicket` refuses to sell for such a
// name; this is the other end of the same rule. A paid signup makes the
// principal — and with it the namespace — before it sets the fact, so
// no honest order arrives here first. NOT_FOUND, which monetize's
// gunnar plugin files as final: the fact can never be enforced here.
// Asked after the Admin check, so only the provider learns it; nothing
// is verified, logged or stored.
//
// **Unless this box's log FOLLOWS another's** (wiring tracer B
// 2026-10-08): a main being restored has not replayed a namespace made
// after the twin's snapshot yet, and NOT_FOUND is final at monetize —
// the dead-main watch's re-push of a paid cap would be dropped. Such a
// box records no fact anyway; it says "try again", in the txlog's own
// words (monetize's `reads_as_restoring`).
if self.ctx.control.log_follows() {
return Err(Status::unavailable(
"txlog: this box is a twin (or restoring); it originates no transactions — the \
fact is taken once it opens as the primary: try again",
));
}
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 no account to set \
a plan for: an entitlement is set for an account that exists. Nothing was \
verified, logged or stored"
)));
}
let source = fact.source.clone();
// What the account held before: the audit entry says what the money
// did — bought, prolonged, upgraded — in the account's own log.
let before = self.ctx.control.entitlement_get(&namespace).ok().flatten();
let (plan, paid_until, caps) = (
fact.plan.clone(),
fact.paid_until_unix_ms,
fact.caps.clone(),
);
let (state, attestation_seq) = self.ctx.control.entitlement_set(fact)?;
let kind = match &before {
None => "billing.bought",
Some(prev) if prev.plan != plan || prev.caps != caps => "billing.upgraded",
Some(prev) if paid_until.unwrap_or(0) > prev.paid_until_unix_ms.unwrap_or(0) => {
"billing.prolonged"
}
Some(_) => "billing.changed",
};
self.ctx.account_event(
self.ctx.account_of_namespace(&namespace),
crate::enterprise::AccountAuditEvent::by(&caller, kind, namespace.clone()).detail(
format!(
"plan {plan}, {state:?}{}, {source}",
paid_until
.map(|t| format!(", paid until {t}"))
.unwrap_or_default()
),
),
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "entitlement.set",
principal = %caller.principal_id,
%namespace,
state = ?state,
%source,
attestation_seq,
"entitlement fact accepted"
);
Ok(Response::new(SetEntitlementResponse {
state: state as i32,
attestation_seq,
}))
}
async fn list(
&self,
request: Request<ListEntitlementsRequest>,
) -> Result<Response<ListEntitlementsResponse>, Status> {
self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Read,
"Entitlement.List",
)?;
let facts = self.ctx.control.entitlement_list()?;
// What the box HOLDS and does not enforce, beside what it enforces: an empty
// `facts` alone is also what a box with no customers answers.
let refused_at_boot = self.ctx.control.entitlement_refused_at_boot()?;
Ok(Response::new(ListEntitlementsResponse {
facts,
refused_at_boot,
}))
}
}