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;
pub const PURPOSE_CANCEL: &str = "cancel";
pub const PURPOSE_ESTATE: &str = "estate";
const PRODUCT: &str = "gunnar";
const TICKET_LIFE_MS: i64 = 60_000;
const NONCE_BYTES: usize = 16;
impl BillingService {
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 {
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"));
}
let purpose = match request.purpose.as_str() {
"" | PURPOSE_RENEW => PURPOSE_RENEW,
PURPOSE_ORDER => PURPOSE_ORDER,
PURPOSE_READ => PURPOSE_READ,
PURPOSE_CANCEL => PURPOSE_CANCEL,
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:?}"
)))
}
};
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()
)));
}
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
)));
}
self.ctx.require(
&caller,
Access::namespace(&namespace),
GrantLevel::Admin,
"Billing.MintActorTicket",
)?;
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();
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;
let covered = ticket.caps.len();
let bytes = serde_json::to_vec(&ticket)
.map_err(|err| Status::internal(format!("the ticket could not be encoded: {err}")))?;
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,
expires_unix_ms: expires_unix_ms.max(0) as u64,
}))
}
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(),
}))
}
}