gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **Where a namespace's paid state lives** (`refs/gunnar/entitlements`) —
//! compiled only with the `metered` feature.
//!
//! The record monetize pushes over `Entitlement.Set`, kept in the control
//! store beside the catalog so a reboot loads it, versioned and replicated
//! with everything else. This module stores; it does not decide. The decision
//! (may this namespace push, right now) is `monetize_embed::EntitlementCache`'s,
//! in `gunnar-server`, out of the same facts — and the signature every fact
//! carries is verified THERE, against monetize's public key, before it is
//! stored here and again when it is loaded from here. A tampered control
//! document therefore does not move a plan: it produces a fact that no longer
//! verifies, which is refused and logged at boot.
//!
//! # The ladder, and the two rules that are not negotiable
//!
//! | state | `git push` | owner `git clone` | anonymous browse |
//! |---|---|---|---|
//! | Free, Paid | ✓ | ✓ | ✓ if Public |
//! | Grace | ✓, with a warning line | ✓ | ✓ if Public |
//! | Suspended | ✗ named reason | ✓ | ✗ |
//! | Retention | ✗ named reason | ✓ | ✗ |
//!
//! 1. **Never hold data hostage.** A suspended tenant can always clone their
//!    own repositories. Refusing that is the one thing that turns a lapsed
//!    customer into a complaint, and it costs nothing — clones are cheap next
//!    to pushes.
//! 2. **Refuse before the bytes, not after.** The check is in
//!    `authorise_service` for `git-receive-pack` and LFS upload, never in a
//!    `gunnar-policy` gate: a policy gate runs after the pack is indexed, and
//!    that is a 1 GB upload accepted and then refused.
//!
//! Neither rule is enforced here — a store cannot enforce — but they are
//! written here because this is the type every enforcer reads.
//!
//! # Why the state is STORED and not derived
//!
//! The clock does not move a fact. `state` is monetize's verdict at the time
//! the fact was signed; gunnar does not know the plan's grace or retention day
//! counts and must keep serving on the fact it holds when monetize is
//! unreachable. Ladder transitions arrive as new facts, each one signed.

use std::collections::BTreeMap;
use std::sync::Arc;

use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Result;

/// **The name the entitlement document is stored under** in the control store.
pub const ENTITLEMENT_REF: &str = "refs/gunnar/entitlements";

/// The ladder (the metered plan). One variant per
/// `monetize_product::State` and per `gunnar.v1.EntitlementState`, spelled
/// with the same names so a fact round-trips through all three without a
/// translation table anybody could get wrong.
#[derive(
    Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum EntitlementState {
    /// The free tier. Also what an unknown namespace is: never a refusal.
    Free,
    /// A paid plan inside its term.
    Paid,
    /// Paid until a date that has passed, inside the plan's grace window:
    /// everything still works, and a push carries a warning line.
    Grace,
    /// Grace is over. Writes refused by name; reads never.
    Suspended,
    /// Suspended past the plan's retention window: still readable, the data
    /// is kept for export, and the next step is the ordinary soft-delete.
    Retention,
}

impl EntitlementState {
    /// The variant name, which is also what the signed canonical JSON carries.
    pub fn name(self) -> &'static str {
        match self {
            EntitlementState::Free => "Free",
            EntitlementState::Paid => "Paid",
            EntitlementState::Grace => "Grace",
            EntitlementState::Suspended => "Suspended",
            EntitlementState::Retention => "Retention",
        }
    }

    /// May this namespace grow its data (push, LFS upload)?
    pub fn allows_write(self) -> bool {
        !matches!(
            self,
            EntitlementState::Suspended | EntitlementState::Retention
        )
    }

    /// May a stranger read this namespace's public repositories? The same
    /// two states close it: a lapsed account's public surface is the thing
    /// that costs the provider and earns nothing.
    pub fn allows_anonymous_read(self) -> bool {
        self.allows_write()
    }
}

impl std::fmt::Display for EntitlementState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.name())
    }
}

/// One accepted fact about one namespace, exactly as monetize signed it plus
/// when this server accepted it.
///
/// The seven signed fields are field for field
/// `monetize_product::EntitlementFact` (`namespace` = its `tenant`), and they
/// are stored **unchanged**, signature included, so the fact can be re-verified
/// from the document alone. Nothing here is re-encoded on the way in: a store
/// that "normalised" a fact would be forging one.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entitlement {
    /// The tenant, as the catalog names it (`team/sub`).
    pub namespace: String,
    /// The plan's name — the service provider's word (`free`, `10gb`, …).
    pub plan: String,
    /// Where the plan stands: free, paid, in grace, or lapsed.
    pub state: EntitlementState,
    /// `None` is the free tier, which never expires — not "unknown".
    pub paid_until_unix_ms: Option<u64>,
    /// Per-meter caps gunnar enforces: `pack_bytes` is the pack quota,
    /// `cache_bytes` becomes `Namespace::explode_budget_bytes`. Keys are the
    /// meter names `Usage.Read` reports. Absent = the server's default.
    #[serde(default)]
    pub caps: BTreeMap<String, u64>,
    /// `operator` | `payment:<vendor>:<reference>`. Appended verbatim to the
    /// attestation log with the fact.
    pub source: String,
    /// Ed25519 (64 bytes) by monetize-server's key over the canonical JSON of
    /// the six fields above. Verified by `monetize_embed::signing::verify_fact`
    /// — gunnar has no second canonical form.
    pub signature: Vec<u8>,
    /// **When monetize issued the fact — the high-water mark this box refuses
    /// below.**
    ///
    /// `EntitlementSlot::set` will not accept a fact for this namespace that is
    /// not NEWER than what is stored here, which is what stops a captured `Paid`
    /// fact being pushed back after the tenant lapsed. It is stored and not
    /// merely checked in memory because a box that forgot it over a reboot would
    /// accept the replay on the next boot.
    ///
    /// `None` is a fact signed before the field existed. A fact read back from
    /// a stored document is kept as-is — re-encoding a fact is forging one —
    /// but `EntitlementSlot::set` REFUSES an unstamped fact on every namespace
    /// (stinky row 12, 2026-10-10): accepted "until the first stamped one", a
    /// captured old `Paid` fact could be replayed onto any namespace that had
    /// not had a stamped fact yet.
    #[serde(default)]
    pub issued_unix_ms: Option<u64>,
    /// Ed25519 (64 bytes) over the canonical JSON of the seven fields above
    /// INCLUDING `issued_unix_ms`. Present exactly when it is. Stored so the
    /// fact can be re-verified WHOLE from the document alone: without it a
    /// reboot could only check the V1 half and would silently lose the guard.
    #[serde(default)]
    pub issued_signature: Vec<u8>,
    /// When THIS server accepted the fact (its own clock). Not signed: it is
    /// gunnar's fact about the fact, and it is what `List` orders by.
    pub accepted_at_unix_ms: i64,
}

impl Entitlement {
    /// `caps["pack_bytes"]`, if the plan sets one. `0` is "unlimited" and is
    /// reported as `None` here so a caller cannot compare against it.
    pub fn pack_cap(&self) -> Option<u64> {
        self.caps.get("pack_bytes").copied().filter(|cap| *cap > 0)
    }

    /// `caps["cache_bytes"]`, if the plan sets one — the value that becomes
    /// `Namespace::explode_budget_bytes` when the fact is accepted.
    pub fn cache_cap(&self) -> Option<u64> {
        self.caps.get("cache_bytes").copied()
    }
}

/// The facts as they are stored.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EntitlementDoc {
    /// Keyed by namespace name. One fact per namespace: `Set` replaces.
    #[serde(default)]
    facts: ShardedMap<Entitlement>,
}

impl_doc!(EntitlementDoc { facts });

/// Keep, list and replace the facts. Cheap to clone; every clone reads and
/// writes the same document.
#[derive(Clone, Debug)]
pub struct EntitlementStore {
    doc: DocRef<EntitlementDoc>,
}

impl EntitlementStore {
    /// Open the entitlement document held in `store` (the server's control
    /// store). A store that has never held one reads as empty — every
    /// namespace Free — which is what a control document written before
    /// entitlements existed must load as.
    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
        Ok(EntitlementStore {
            doc: DocRef::new(store, ENTITLEMENT_REF)?,
        })
    }

    /// The name the document is stored under.
    pub fn ref_name(&self) -> &str {
        self.doc.ref_name()
    }

    /// The fact for one namespace, if any was ever accepted. `None` is Free.
    pub fn get(&self, namespace: &str) -> Result<Option<Entitlement>> {
        self.doc.load()?.facts.get(namespace)
    }

    /// Every stored fact, one per namespace, in namespace order.
    pub fn all(&self) -> Result<Vec<Entitlement>> {
        let mut facts = self.doc.load()?.facts.values()?;
        facts.sort_by(|a, b| a.namespace.cmp(&b.namespace));
        Ok(facts)
    }

    /// Store `fact` for its namespace, replacing whatever was there. Returns
    /// the fact as stored.
    pub fn set(&self, fact: Entitlement) -> Result<Entitlement> {
        let key = fact.namespace.clone();
        self.doc.update(|doc| {
            doc.facts.insert(key.clone(), fact.clone())?;
            Ok(fact.clone())
        })
    }

    /// Forget one namespace's fact, reporting whether there was one. Back to
    /// Free — used by a delete of the namespace, never by a lapse: a lapse is
    /// a new fact, not an absence.
    pub fn remove(&self, namespace: &str) -> Result<bool> {
        let key = namespace.to_owned();
        self.doc.update(|doc| Ok(doc.facts.remove(&key)?.is_some()))
    }
}

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

    fn fact(ns: &str, state: EntitlementState) -> Entitlement {
        Entitlement {
            namespace: ns.into(),
            plan: "10gb".into(),
            state,
            paid_until_unix_ms: Some(1_790_812_800_000),
            caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
            source: "payment:invoice:ocr-1".into(),
            signature: vec![7; 64],
            issued_unix_ms: Some(1_789_000_000_000),
            issued_signature: vec![8; 64],
            accepted_at_unix_ms: 1,
        }
    }

    #[test]
    fn a_fresh_store_is_empty_and_every_namespace_is_free() {
        let store =
            EntitlementStore::new(Arc::new(RedbControlStore::in_memory().unwrap())).unwrap();
        assert!(store.all().unwrap().is_empty());
        assert_eq!(store.get("anyone").unwrap(), None);
    }

    #[test]
    fn set_replaces_per_namespace_and_survives_a_reopen() {
        let backing: Arc<dyn ControlStore> = Arc::new(RedbControlStore::in_memory().unwrap());
        let store = EntitlementStore::new(Arc::clone(&backing)).unwrap();
        store.set(fact("a", EntitlementState::Paid)).unwrap();
        store.set(fact("b", EntitlementState::Paid)).unwrap();
        store.set(fact("a", EntitlementState::Suspended)).unwrap();
        // A second handle over the same backing store: what a reboot sees.
        let reopened = EntitlementStore::new(backing).unwrap();
        let all = reopened.all().unwrap();
        assert_eq!(all.len(), 2, "one fact per namespace: {all:?}");
        assert_eq!(
            all[0].state,
            EntitlementState::Suspended,
            "the LATEST fact for a"
        );
        assert_eq!(all[1].state, EntitlementState::Paid);
        assert_eq!(
            reopened.get("a").unwrap().unwrap().signature,
            vec![7; 64],
            "the signature is stored untouched"
        );
        assert!(reopened.remove("a").unwrap());
        assert!(!reopened.remove("a").unwrap());
        assert_eq!(reopened.get("a").unwrap(), None);
    }

    #[test]
    fn the_ladder_closes_writes_and_anonymous_reads_in_the_same_two_states() {
        use EntitlementState::*;
        for (state, write) in [
            (Free, true),
            (Paid, true),
            (Grace, true),
            (Suspended, false),
            (Retention, false),
        ] {
            assert_eq!(state.allows_write(), write, "{state}");
            assert_eq!(state.allows_anonymous_read(), write, "{state}");
        }
        assert_eq!(Suspended.name(), "Suspended");
        let zero_cap = Entitlement {
            caps: BTreeMap::from([("pack_bytes".to_string(), 0)]),
            ..fact("z", Free)
        };
        assert_eq!(
            zero_cap.pack_cap(),
            None,
            "0 is unlimited, not a cap of nothing"
        );
        assert_eq!(fact("z", Free).pack_cap(), Some(10 << 30));
    }
}