crates/gunnar-core/src/policy_store.rs
//! **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()))
}
}