crates/monetize-server/src/services/entitlement.rs
use std::sync::Arc;
use monetize::embed::signing::verify_fact;
use monetize_proto::entitlement_server::Entitlement;
use monetize_proto::{self as pb};
use tonic::{Request, Response, Status};
use super::{blocking, ledger_err, product_or_not_found};
use crate::convert::{fact_from_pb, fact_to_pb};
use crate::state::AppState;
pub struct EntitlementService(pub Arc<AppState>);
#[tonic::async_trait]
impl Entitlement for EntitlementService {
async fn get(
&self,
req: Request<pb::GetEntitlementRequest>,
) -> Result<Response<pb::GetEntitlementResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
product_or_not_found(s, &r.product)?;
let fact = match s
.ledger
.entitlement(&r.product, &r.tenant)
.map_err(ledger_err)?
{
Some(f) => Some(f),
None => crate::signup::signup_facts(s, &r.product)
.map_err(|e| Status::internal(format!("signup store: {e}")))?
.into_iter()
.find(|f| f.tenant.0 == r.tenant),
};
let pending_push = s
.ledger
.pending_push(&r.product, &r.tenant)
.map_err(ledger_err)?
.map(|p| pb::PendingPush {
since_unix_ms: p.since_unix_ms,
attempts: p.attempts,
last_error: p.last_error,
});
Ok(Response::new(pb::GetEntitlementResponse {
fact: fact.as_ref().map(fact_to_pb),
pending_push,
}))
})
.await
}
async fn list(
&self,
req: Request<pb::ListEntitlementsRequest>,
) -> Result<Response<pb::ListEntitlementsResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
product_or_not_found(s, &r.product)?;
let facts = s.ledger.entitlements(&r.product).map_err(ledger_err)?;
// A paid signup's cap is a fact too, though it writes no ledger row.
let facts = crate::signup::with_signup_facts(s, &r.product, facts)
.map_err(|e| Status::internal(format!("signup store: {e}")))?;
Ok(Response::new(pb::ListEntitlementsResponse {
facts: facts.iter().map(fact_to_pb).collect(),
}))
})
.await
}
/// What the tenant is standing on. The nonpay purge gives back exactly the
/// bought subset of this, from the same fold (`Ledger::holdings`), so the
/// page and the give-back cannot drift apart.
async fn held(
&self,
req: Request<pb::HeldRequest>,
) -> Result<Response<pb::HeldResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
product_or_not_found(s, &r.product)?;
let holdings = s
.ledger
.holdings(&r.product, &r.tenant)
.map_err(ledger_err)?;
Ok(Response::new(pb::HeldResponse {
holdings: holdings
.iter()
.map(|h| pb::Holding {
reference: h.reference.clone(),
resource: Some(crate::convert::resource_to_pb(&h.resource)),
provider: h
.id
.as_ref()
.map(|i| i.provider.clone())
.unwrap_or_default(),
id: h.id.as_ref().map(|i| i.id.clone()).unwrap_or_default(),
})
.collect(),
}))
})
.await
}
async fn set(
&self,
req: Request<pb::SetEntitlementRequest>,
) -> Result<Response<pb::SetEntitlementResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
let product = product_or_not_found(s, &r.product)?;
let mut fact = fact_from_pb(
r.fact
.as_ref()
.ok_or_else(|| Status::invalid_argument("fact is required"))?,
)?;
if fact.source.is_empty() {
fact.source = "operator".into();
}
// `fact.plan` is a free label on a hand-set fact (an order
// reference on a purchased one); nothing to look up. The caps
// must name meters the product declares, or the product would be
// pushed a number it cannot read.
if let Some(cap) = fact
.caps
.keys()
.find(|k| !product.meters().iter().any(|m| m.name == k.as_str()))
{
return Err(Status::invalid_argument(format!(
"cap {cap:?} names a meter {} does not declare",
product.id()
)));
}
let signer = s.signer_for(product.id());
let fact = if fact.signature.is_empty() {
signer.sign_fact(fact)
} else {
verify_fact(&fact, &signer.verifying_key()).map_err(|e| {
Status::invalid_argument(format!(
"fact carries a signature that is not monetize's for {}: {e}",
product.id()
))
})?;
fact
};
let pushed = s.apply_fact(product, &fact).map_err(ledger_err)?;
Ok(Response::new(pb::SetEntitlementResponse {
fact: Some(fact_to_pb(&fact)),
pushed: pushed.is_ok(),
push_error: pushed.err().unwrap_or_default(),
}))
})
.await
}
/// The Ed25519 VERIFYING key monetize signs `product`'s facts with (G8:
/// one key per product). Not a secret — it is exactly what the product is
/// configured with. See the proto for why a caller wants it and why it is
/// not handed out alongside a `Snapshot`. An unnamed product is the one
/// product served here; with several it is refused, never guessed.
async fn public_key(
&self,
req: Request<pb::PublicKeyRequest>,
) -> Result<Response<pb::PublicKeyResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
let id = if r.product.is_empty() {
match s.registry.product_ids().as_slice() {
[only] => (*only).to_owned(),
ids => return Err(Status::invalid_argument(format!("name the product: every product signs with its own key (G8), and this server serves {ids:?}"))),
}
} else {
product_or_not_found(s, &r.product)?.id().to_owned()
};
let key = s.signer_for(&id).public_key();
Ok(Response::new(pb::PublicKeyResponse {
public_key: key.to_vec(),
hex: key.iter().map(|b| format!("{b:02x}")).collect(),
algorithm: "ed25519".into(),
}))
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::Registry;
use monetize::testing::MockProduct;
use monetize_product::{EntitlementFact, State, TenantId};
fn two_products() -> Arc<AppState> {
Arc::new(AppState::in_memory(
Registry::new()
.product(MockProduct::gunnar())
.product_as(MockProduct::gunnar(), "holger"),
))
}
fn unsigned(tenant: &str) -> pb::EntitlementFact {
fact_to_pb(&EntitlementFact {
tenant: TenantId(tenant.into()),
plan: "pro".into(),
state: State::Paid,
paid_until_unix_ms: Some(1_900_000_000_000),
caps: Default::default(),
source: String::new(),
signature: Vec::new(),
issued_unix_ms: None,
issued_signature: Vec::new(),
})
}
/// **G8: a fact, a snapshot and the advertised key are the PRODUCT's.** One
/// monetize serves gunnar and holger; what it signs for holger does not
/// verify under the key a gunnar estate trusts, a fact signed for gunnar is
/// not taken as holger's, and `PublicKey` answers per product.
#[tokio::test(flavor = "multi_thread")]
async fn each_product_is_signed_for_with_its_own_key() {
let s = two_products();
let svc = EntitlementService(Arc::clone(&s));
let (gk, hk) = (
s.signer_for("gunnar").verifying_key(),
s.signer_for("holger").verifying_key(),
);
assert_ne!(gk, hk, "two products, two keys");
let set = svc
.set(Request::new(pb::SetEntitlementRequest {
product: "holger".into(),
fact: Some(unsigned("acme")),
}))
.await
.unwrap()
.into_inner();
let fact = fact_from_pb(set.fact.as_ref().unwrap()).unwrap();
assert!(verify_fact(&fact, &hk).is_ok(), "signed with holger's key");
assert!(
verify_fact(&fact, &gk).is_err(),
"and a gunnar estate does not take it"
);
// A fact signed for gunnar is not admitted as holger's.
let gunnars = s
.signer_for("gunnar")
.sign_fact(fact_from_pb(&unsigned("acme")).unwrap());
let e = svc
.set(Request::new(pb::SetEntitlementRequest {
product: "holger".into(),
fact: Some(fact_to_pb(&gunnars)),
}))
.await
.unwrap_err();
assert_eq!(e.code(), tonic::Code::InvalidArgument, "{e}");
let snap = s
.signer_for("holger")
.sign_snapshot(1, s.ledger.entitlements("holger").unwrap());
assert!(monetize::verify_snapshot(&snap, &hk).is_ok());
assert!(
monetize::verify_snapshot(&snap, &gk).is_err(),
"holger's set is no gunnar set"
);
let key = svc
.public_key(Request::new(pb::PublicKeyRequest {
product: "holger".into(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(
key.public_key,
hk.to_bytes().to_vec(),
"the advertised key is the product's"
);
let e = svc
.public_key(Request::new(pb::PublicKeyRequest {
product: String::new(),
}))
.await
.unwrap_err();
assert_eq!(
e.code(),
tonic::Code::InvalidArgument,
"two products, no product named: {e}"
);
let e = svc
.public_key(Request::new(pb::PublicKeyRequest {
product: "nobody".into(),
}))
.await
.unwrap_err();
assert_eq!(e.code(), tonic::Code::NotFound, "{e}");
}
/// One product served: an unnamed `PublicKey` is that product's key, so a
/// caller written before G8 keeps working on a single-product server.
#[tokio::test(flavor = "multi_thread")]
async fn one_product_answers_an_unnamed_public_key_with_its_own() {
let s = Arc::new(AppState::in_memory(
Registry::new().product(MockProduct::gunnar()),
));
let key = EntitlementService(Arc::clone(&s))
.public_key(Request::new(pb::PublicKeyRequest {
product: String::new(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(key.public_key, s.signer_for("gunnar").public_key().to_vec());
assert_ne!(
key.public_key,
s.keys.root().public_key().to_vec(),
"the root signs nothing a product sees"
);
}
}