Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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"
        );
    }
}