Skip to main content

gunnar_core/
policy_store.rs

1//! **Where a repository's policy document lives** (`refs/gunnar/policy`).
2//!
3//! The third document in the control store, beside [`CATALOG_REF`](crate::CATALOG_REF)
4//! and [`AUTH_REF`](crate::AUTH_REF), and the one that closes `Policy.*`:
5//! every RPC on that service answered `Unimplemented` for one reason, recorded
6//! in `gunnar-server`'s adapter as
7//!
8//! > gunnar-core has no store for branch protection or ref-name policy, so
9//! > gunnar-cli reads the gate configuration from a `gunnar-policy.json` beside
10//! > each repository.
11//!
12//! A file beside a repository is not reachable over the control plane, is not
13//! versioned, is not replicated with the catalog, and does not exist at all on
14//! an appliance whose repositories are archives. This is that file's home.
15//!
16//! # Why the value is a JSON string and not a typed policy
17//!
18//! Because the schema belongs to `gunnar-policy` and this crate must not own a
19//! second copy of it. `gunnar-core` does not link `gunnar-policy` — it has no
20//! opinion about what a protection rule is, and acquiring one would put the
21//! meaning of a gate in two crates that can disagree (one implementation). So the store
22//! keeps the **document**: it versions it, replicates it with the rest of the
23//! control state, and hands it back byte-for-byte. Parsing and validating it is
24//! `gunnar-policy`'s job, at the one seam that already does it.
25//!
26//! The cost is real and worth naming: a malformed document can be stored. The
27//! writer is expected to have parsed it first — `Policy.SetProtection` does —
28//! and the reader refuses rather than running an empty policy, which is the
29//! half that matters. A store that validated would be a second validator.
30
31use std::sync::Arc;
32
33use crate::control::ControlStore;
34use crate::doc::{impl_doc, DocRef, ShardedMap};
35use crate::error::Result;
36use crate::sandbox::RepoRef;
37
38/// **The name the policy document is stored under** in the control store.
39/// See [`crate::AUTH_REF`] for why it still looks like a reference name.
40pub const POLICY_REF: &str = "refs/gunnar/policy";
41
42/// The policy documents as they are stored.
43#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
44#[serde(deny_unknown_fields)]
45struct PolicyDoc {
46    /// Keyed by `<namespace>/<repo>`, exactly as the catalog keys repositories,
47    /// so the two are joinable without a translation table.
48    ///
49    /// Sharded for the same reason the catalog's `repos` is: a policy write
50    /// must rewrite one leaf, not every policy on the server. It matters more
51    /// here than there, because the read is on the **push path** — every push
52    /// asks for its repository's policy.
53    #[serde(default)]
54    repos: ShardedMap<String>,
55}
56
57impl_doc!(PolicyDoc { repos });
58
59/// Read and write per-repository policy documents.
60///
61/// Cheap to clone; every clone reads and writes the same document.
62#[derive(Clone, Debug)]
63pub struct PolicyStore {
64    doc: DocRef<PolicyDoc>,
65}
66
67impl PolicyStore {
68    /// Open the policy document held in `store` (the server's control store).
69    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
70        Ok(PolicyStore {
71            doc: DocRef::new(store, POLICY_REF)?,
72        })
73    }
74
75    /// The name the document is stored under.
76    pub fn ref_name(&self) -> &str {
77        self.doc.ref_name()
78    }
79
80    /// Every version this document has ever had, oldest first (the certificate-identity plan).
81    ///
82    /// Policy history is not a nicety. "Who turned the signature requirement
83    /// off, and when" is the question that gets asked after the fact, and a
84    /// configuration store with no history cannot answer it.
85    pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
86        self.doc.history()
87    }
88
89    /// The policy document for `repo`, or `None` if none was ever stored.
90    ///
91    /// **`None` is not an empty policy**, and the caller must not turn it into
92    /// one silently: it means nobody has configured this repository, which is
93    /// the state where every well-formed push is served. `gunnar-server` logs
94    /// that distinction at open time rather than inferring it.
95    pub fn get(&self, repo: &RepoRef) -> Result<Option<String>> {
96        self.doc.load()?.repos.get(&repo.key())
97    }
98
99    /// Store `document` as `repo`'s policy, replacing whatever was there.
100    ///
101    /// The bytes are not parsed here; see the module docs. What is checked is
102    /// that they are not empty — an empty document is the one value that would
103    /// read back as "a policy exists and it configures nothing", which is
104    /// indistinguishable from a write that lost its content. Removing a policy
105    /// is [`PolicyStore::remove`], and it says so.
106    pub fn set(&self, repo: &RepoRef, document: &str) -> Result<()> {
107        if document.trim().is_empty() {
108            return Err(crate::error::Error::InvalidInput {
109                what: "policy document",
110                reason: "empty; use `remove` to delete a repository's policy".to_owned(),
111            });
112        }
113        let key = repo.key();
114        let document = document.to_owned();
115        self.doc.update(|doc| {
116            doc.repos.insert(key.clone(), document.clone())?;
117            Ok(())
118        })
119    }
120
121    /// Delete `repo`'s policy, reporting whether there was one.
122    ///
123    /// The boolean is the difference between "the policy is gone" and "there
124    /// was nothing to delete", and a caller that collapsed them could report a
125    /// successful removal of a rule that never existed.
126    pub fn remove(&self, repo: &RepoRef) -> Result<bool> {
127        let key = repo.key();
128        self.doc.update(|doc| Ok(doc.repos.remove(&key)?.is_some()))
129    }
130}