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

nordisk/monetize

crates/monetize-product/src/factory.rs

//! **The registration seam — how a product gets into a server without the
//! server knowing its name.**
//!
//! The same shape `monetize_pay::factory` gives a vendor and
//! `monetize_cloud::factory` gives a cloud, for the same reason: the open
//! server matches on no product name. It is handed a list of
//! [`ProductFactory`] values and looks a configured `[products.<id>]` up in
//! that list. A build registers the plugins it links (`list.push(FACTORY)`);
//! an id in the config that no factory answers for is refused at boot, by
//! name, exactly as an unlinked vendor is.
//!
//! A factory is a `const`: a name and two plain `fn`s — the constructor, and
//! the identity minter `--identity-print` calls (a product whose plugin
//! carries no identity of its own refuses that call by name rather than
//! minting a key nothing will ever verify). No trait, no generics: three
//! fields are the whole contract.
//!
//! A WASM guest host slots in behind the same seam: a factory whose `build`
//! loads a `.wasm` and wraps it in one `Box<dyn Product>` is a factory like
//! any other, and the server cannot tell the difference — which is the point.
use std::path::{Path, PathBuf};

/// **The address a PULLED product's plugin is built with** (`[products.<id>] instance`, no
/// `endpoint`; monetize-server `registry`): monetize never dials an estate, but a plugin builds
/// its lazy channel from an address and refuses an empty one. Never resolvable, never dialled.
pub const PULLED_NEVER_DIALLED: &str = "http://pulled.invalid:1";

/// **What a product is built from** — the `[products.<id>]` block, typed.
/// The fields are the generic credential shapes the server keeps as config
/// keys; a plugin reads the ones it understands and ignores the rest. A plugin
/// whose key lives in the environment (`api_key_env`) reads that variable
/// itself, as a vendor reads its own `config_envs`.
#[derive(Clone, Copy, Debug)]
pub struct ProductBuild<'a> {
    /// The configured id — the factory's own, echoed so an error can say
    /// `[products.<id>]`.
    pub id: &'a str,
    /// The product's control plane: a gRPC address or an HTTPS base.
    pub endpoint: &'a str,
    /// A private key file monetize signs calls TO the product with, when the
    /// plugin authenticates that way.
    pub identity_key: Option<&'a Path>,
    /// The product APPLIANCE's public key (hex), when the plugin verifies
    /// tenant tickets with one.
    pub appliance_key: Option<&'a str>,
    /// A bearer presented on every call, when the plugin authenticates that way.
    pub bearer_token: Option<&'a str>,
    /// The NAME of the environment variable carrying the product's API key,
    /// when the plugin authenticates that way. The value is never in the config.
    pub api_key_env: Option<&'a str>,
    /// **The TWIN's control plane**, when the deployment has one:
    /// `[products.<id>] restoring_from_twin`.
    ///
    /// The **twin growth** (T21) needs it: the standby is grown exactly as main
    /// is grown — the same verbs, on the twin's own control plane. A product
    /// told no twin refuses it BY NAME. (A restore from the twin is no longer
    /// driven from here: it is an order the estate pulls, 5.4.)
    pub restoring_from_twin: Option<&'a str>,
    /// **The CA a `https://` endpoint is verified against** —
    /// `[products.<id>] tls_ca = "/etc/monetize/<product>-ca.pem"`.
    ///
    /// `None` is the compiled-in Mozilla root set, which is what a publicly
    /// issued certificate verifies against; an estate with its own certificate
    /// authority names its PEM here rather than installing it system-wide, so
    /// verification does not depend on what a particular box's trust store
    /// happens to hold. Naming one for a PLAINTEXT endpoint is refused by
    /// name: a block that wrote it believes its traffic is verified.
    pub tls_ca: Option<&'a std::path::Path>,
    /// **The client identity monetize presents** on an `https://` endpoint —
    /// `[products.<id>] tls_client_cert` + `tls_client_key` (PEM paths): an
    /// estate that REQUIRES a client certificate (gunnar's own issuer; an
    /// estate attached from another cloud that trusts the central's attach CA,
    /// f-grpc-tls) is reached with mutual TLS. `None`: no identity, as before.
    pub tls_client: Option<(&'a std::path::Path, &'a std::path::Path)>,
    /// **The signed `.wasm` this product is served by**, when the block says
    /// `guest = "<path>.wasm"`. `None` is a product a linked plugin builds. A
    /// factory that is not the guest host ignores it; the guest host refuses a
    /// block without it, by name.
    pub guest: Option<&'a GuestSpec>,
}

/// **A product served by a signed `.wasm` guest** — the `guest*` keys of a
/// `[products.<id>]` block, typed. What the guest host (`monetize-product-wasm`)
/// is built from; the open server reads them, the host enforces them.
///
/// A guest nobody vouched for does not load: at least one of [`GuestSpec::digest`]
/// (the pinned blake3-256 of the module bytes) and [`GuestSpec::trust_roots`]
/// (DER roots its detached CMS signature must chain to) must be set.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuestSpec {
    /// `guest = "<path>.wasm"`.
    pub module: PathBuf,
    /// `guest_digest = "<hex blake3-256>"` — the module bytes must hash to this.
    pub digest: Option<String>,
    /// `guest_trust_roots = ["<root>.der", …]` — the `.p7s` beside the module
    /// must verify against one of these.
    pub trust_roots: Vec<PathBuf>,
    /// `grants = []` — the host doors this guest may import, from its kind's
    /// own set (a product guest's is empty). An import outside the grant is not
    /// defined on the linker, so the module is refused at load, by name.
    pub grants: Vec<String>,
    /// `http_hosts = ["api.example.se", "http://lab:8080"]` — the only hosts
    /// `host_http` will reach; `https` unless the entry is written `http://`.
    pub http_hosts: Vec<String>,
    /// `guest_grpc_hosts = ["10.0.0.2:50051"]` — the OTHER control planes this
    /// guest may dial, beside the block's own `endpoint`, named by an operator
    /// or not dialled: a guest that could reach an address nobody wrote down
    /// could take a tenant's log somewhere else.
    pub grpc_hosts: Vec<String>,
    /// `guest_fuel` — instructions one call may execute; narrows the kind's
    /// ceiling, never widens it. `0` is refused.
    pub fuel: Option<u64>,
    /// `guest_call_timeout_ms` — the wall-clock ceiling on one call, host
    /// doors included. `0` is refused.
    pub call_timeout_ms: Option<u64>,
    /// `guest_max_memory_bytes` — the linear-memory ceiling. `0` is refused.
    pub max_memory_bytes: Option<u64>,
    /// `[[<block>.http_auth]]` — the credential the HOST adds to a request to
    /// one of `http_hosts`, read from a secret file at every call. The guest
    /// never sees it, and cannot choose it.
    pub http_auth: Vec<HttpAuth>,
    /// `hmac_secrets = ["stripe_webhook"]` — the secrets `host_hmac_verify`
    /// may check a message against, by name.
    pub hmac_secrets: Vec<String>,
    /// Where `secret.<name>` files are read from: the server's `secrets_dir`,
    /// default `<data_dir>/secrets`. `None` means no secret can be read, and a
    /// block that names one is refused at load.
    pub secrets_dir: Option<PathBuf>,
    /// `plugin = "gunnar"` (G15) — the id the guest's `id` op answers, when the
    /// block's own id is another: `[products.gunnar-test2] plugin = "gunnar"`
    /// serves the gunnar guest as product `gunnar-test2`. `None` is the block's
    /// id, as it always was.
    pub plugin: Option<String>,
}

/// **One `[[<block>.http_auth]]` entry**: what the host adds to a request to
/// `host`, from a secret file the guest never sees.
///
/// * `secret` alone: `<header>: <prefix><secret>` (header default `Authorization`);
/// * `basic_user` / `basic_user_secret` with `secret`: `Authorization: Basic base64(user:secret)`;
/// * `client_cert_secret`: a PEM file (certificate chain + private key) presented
///   as the TLS client certificate to THIS host only.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HttpAuth {
    /// Spelled as an `http_hosts` entry, and it must be one.
    pub host: String,
    pub header: Option<String>,
    pub prefix: String,
    pub secret: Option<String>,
    pub basic_user: Option<String>,
    pub basic_user_secret: Option<String>,
    pub client_cert_secret: Option<String>,
    /// **Secrets the host may write INTO the request body** for this host: a
    /// guest writes the placeholder `{{secret:<name>}}` and the host substitutes
    /// the file's value on the way out (Klarna's push URL carries its callback
    /// credentials in the body). Only the names listed here, only for this host.
    pub body_secrets: Vec<String>,
}

impl HttpAuth {
    /// Every secret name this entry reads.
    pub fn secret_names(&self) -> Vec<&str> {
        [
            &self.secret,
            &self.basic_user_secret,
            &self.client_cert_secret,
        ]
        .into_iter()
        .flatten()
        .map(String::as_str)
        .chain(self.body_secrets.iter().map(String::as_str))
        .collect()
    }
}

/// The placeholder a guest writes where the host is to put a body secret.
pub fn body_secret_placeholder(name: &str) -> String {
    format!("{{{{secret:{name}}}}}")
}

impl ProductBuild<'_> {
    /// `[products.<id>] <what>` — the prefix every refusal from a build wears.
    pub fn err(&self, what: impl std::fmt::Display) -> String {
        format!("[products.{}] {what}", self.id)
    }
}

/// A product's build answers its facts' source: monetize serves it as a [`crate::pulled::Pulled`].
pub type ProductBuilder = fn(&ProductBuild<'_>) -> Result<crate::pulled::FactsSource, String>;

/// Mint the plugin's identity key if it is not there yet, and answer the
/// public line to register with the product. A plugin with no identity of its
/// own answers `Err`, by name.
pub type IdentityMinter = fn(&ProductBuild<'_>) -> Result<String, String>;

/// One linkable product plugin: its name, its constructor, its identity minter, and the estate
/// guardian it links, if any (the host holds it beside the product, A32-13).
#[derive(Clone, Copy)]
pub struct ProductFactory {
    pub name: &'static str,
    pub build: ProductBuilder,
    pub mint_identity: IdentityMinter,
    pub guardian: Option<&'static dyn crate::guardian::EstateGuardian>,
}

impl std::fmt::Debug for ProductFactory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ProductFactory")
            .field("name", &self.name)
            .finish_non_exhaustive()
    }
}

/// **Register a factory, replacing one of the same name.** Last writer wins,
/// deliberately — the rule `monetize_pay::factory::register` applies, so a
/// build that links a richer plugin for an open name registers after the open
/// set and takes the name over.
pub fn register(list: &mut Vec<ProductFactory>, f: ProductFactory) {
    if let Some(slot) = list.iter_mut().find(|g| g.name == f.name) {
        *slot = f;
    } else {
        list.push(f);
    }
}

/// The factory registered under `name`, if any.
pub fn find<'a>(list: &'a [ProductFactory], name: &str) -> Option<&'a ProductFactory> {
    list.iter().find(|f| f.name == name)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn refuse(b: &ProductBuild<'_>) -> Result<crate::pulled::FactsSource, String> {
        Err(b.err("no"))
    }
    fn no_identity(b: &ProductBuild<'_>) -> Result<String, String> {
        Err(b.err("nothing to mint"))
    }

    #[test]
    fn register_replaces_a_name_and_find_answers_by_name() {
        let mut list = Vec::new();
        register(
            &mut list,
            ProductFactory {
                name: "a",
                build: refuse,
                mint_identity: no_identity,
                guardian: None,
            },
        );
        register(
            &mut list,
            ProductFactory {
                name: "b",
                build: refuse,
                mint_identity: no_identity,
                guardian: None,
            },
        );
        register(
            &mut list,
            ProductFactory {
                name: "a",
                build: refuse,
                mint_identity: no_identity,
                guardian: None,
            },
        );
        assert_eq!(
            list.len(),
            2,
            "a second registration of a name replaces, never duplicates"
        );
        assert!(find(&list, "a").is_some() && find(&list, "b").is_some());
        assert!(find(&list, "c").is_none());
    }

    #[test]
    fn a_refusal_names_the_block() {
        let b = ProductBuild {
            id: "acme",
            endpoint: "x",
            identity_key: None,
            appliance_key: None,
            bearer_token: None,
            api_key_env: None,
            restoring_from_twin: None,
            tls_ca: None,
            tls_client: None,
            guest: None,
        };
        assert_eq!(refuse(&b).err().unwrap(), "[products.acme] no");
        assert_eq!(
            no_identity(&b).err().unwrap(),
            "[products.acme] nothing to mint"
        );
    }
}