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 repository's policy document lives** (`refs/gunnar/policy`).
//!
//! The third document in the control store, beside [`CATALOG_REF`](crate::CATALOG_REF)
//! and [`AUTH_REF`](crate::AUTH_REF), and the one that closes `Policy.*`:
//! every RPC on that service answered `Unimplemented` for one reason, recorded
//! in `gunnar-server`'s adapter as
//!
//! > gunnar-core has no store for branch protection or ref-name policy, so
//! > gunnar-cli reads the gate configuration from a `gunnar-policy.json` beside
//! > each repository.
//!
//! A file beside a repository is not reachable over the control plane, is not
//! versioned, is not replicated with the catalog, and does not exist at all on
//! an appliance whose repositories are archives. This is that file's home.
//!
//! # Why the value is a JSON string and not a typed policy
//!
//! Because the schema belongs to `gunnar-policy` and this crate must not own a
//! second copy of it. `gunnar-core` does not link `gunnar-policy` — it has no
//! opinion about what a protection rule is, and acquiring one would put the
//! meaning of a gate in two crates that can disagree (one implementation). So the store
//! keeps the **document**: it versions it, replicates it with the rest of the
//! control state, and hands it back byte-for-byte. Parsing and validating it is
//! `gunnar-policy`'s job, at the one seam that already does it.
//!
//! The cost is real and worth naming: a malformed document can be stored. The
//! writer is expected to have parsed it first — `Policy.SetProtection` does —
//! and the reader refuses rather than running an empty policy, which is the
//! half that matters. A store that validated would be a second validator.

use std::sync::Arc;

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

/// **The name the policy document is stored under** in the control store.
/// See [`crate::AUTH_REF`] for why it still looks like a reference name.
pub const POLICY_REF: &str = "refs/gunnar/policy";

/// The policy documents as they are stored.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyDoc {
    /// Keyed by `<namespace>/<repo>`, exactly as the catalog keys repositories,
    /// so the two are joinable without a translation table.
    ///
    /// Sharded for the same reason the catalog's `repos` is: a policy write
    /// must rewrite one leaf, not every policy on the server. It matters more
    /// here than there, because the read is on the **push path** — every push
    /// asks for its repository's policy.
    #[serde(default)]
    repos: ShardedMap<String>,
}

impl_doc!(PolicyDoc { repos });

/// Read and write per-repository policy documents.
///
/// Cheap to clone; every clone reads and writes the same document.
#[derive(Clone, Debug)]
pub struct PolicyStore {
    doc: DocRef<PolicyDoc>,
}

impl PolicyStore {
    /// Open the policy document held in `store` (the server's control store).
    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
        Ok(PolicyStore {
            doc: DocRef::new(store, POLICY_REF)?,
        })
    }

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

    /// Every version this document has ever had, oldest first (the certificate-identity plan).
    ///
    /// Policy history is not a nicety. "Who turned the signature requirement
    /// off, and when" is the question that gets asked after the fact, and a
    /// configuration store with no history cannot answer it.
    pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
        self.doc.history()
    }

    /// The policy document for `repo`, or `None` if none was ever stored.
    ///
    /// **`None` is not an empty policy**, and the caller must not turn it into
    /// one silently: it means nobody has configured this repository, which is
    /// the state where every well-formed push is served. `gunnar-server` logs
    /// that distinction at open time rather than inferring it.
    pub fn get(&self, repo: &RepoRef) -> Result<Option<String>> {
        self.doc.load()?.repos.get(&repo.key())
    }

    /// Store `document` as `repo`'s policy, replacing whatever was there.
    ///
    /// The bytes are not parsed here; see the module docs. What is checked is
    /// that they are not empty — an empty document is the one value that would
    /// read back as "a policy exists and it configures nothing", which is
    /// indistinguishable from a write that lost its content. Removing a policy
    /// is [`PolicyStore::remove`], and it says so.
    pub fn set(&self, repo: &RepoRef, document: &str) -> Result<()> {
        if document.trim().is_empty() {
            return Err(crate::error::Error::InvalidInput {
                what: "policy document",
                reason: "empty; use `remove` to delete a repository's policy".to_owned(),
            });
        }
        let key = repo.key();
        let document = document.to_owned();
        self.doc.update(|doc| {
            doc.repos.insert(key.clone(), document.clone())?;
            Ok(())
        })
    }

    /// Delete `repo`'s policy, reporting whether there was one.
    ///
    /// The boolean is the difference between "the policy is gone" and "there
    /// was nothing to delete", and a caller that collapsed them could report a
    /// successful removal of a rule that never existed.
    pub fn remove(&self, repo: &RepoRef) -> Result<bool> {
        let key = repo.key();
        self.doc.update(|doc| Ok(doc.repos.remove(&key)?.is_some()))
    }
}