Skip to main content

mkit_server/policy/
ref_policy.rs

1//! Deployment ref policy (SPEC-SERVER ยง9.7): per-ref allowed operation
2//! signers and fast-forward-only rules. Programmatic only; native and Worker
3//! embedders validate rules before constructing their pipelines.
4
5use std::collections::BTreeSet;
6
7use mkit_attest::grant::{RefPattern, packmap_head};
8
9use crate::error::ServerError;
10
11/// One rule: every ref its pattern matches. Overlapping rules all apply.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RefRule {
14    /// The refs the rule covers. A packmap ref is covered through its
15    /// head, so no pattern names one.
16    pub pattern: RefPattern,
17    /// Raw Ed25519 keys of the authenticated operation signers that may
18    /// move a matching ref, the owner included; `None` places no limit.
19    /// Overlapping rules intersect.
20    pub allowed_signers: Option<BTreeSet<[u8; 32]>>,
21    /// A matching ref may only fast-forward or be created, for every
22    /// principal. It needs indexed mode.
23    pub fast_forward_only: bool,
24}
25
26/// The deployment's ref rules ([`crate::pipeline::PipelineConfig::ref_policy`]).
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct RefPolicy {
29    rules: Vec<RefRule>,
30}
31
32/// The rule name a ref is matched under: a packmap ref by its head (D5).
33fn rule_name(ref_name: &str) -> String {
34    packmap_head(ref_name).unwrap_or_else(|| ref_name.to_owned())
35}
36
37impl RefPolicy {
38    /// Validate patterns and the required indexed fast-forward capability.
39    /// # Errors
40    /// A malformed pattern or a fast-forward rule without indexed mode.
41    pub fn validate_for_indexed(&self, indexed: bool) -> Result<(), ServerError> {
42        self.validate()?;
43        if self.has_fast_forward_rule() && !indexed {
44            return Err(ServerError::invalid_argument(
45                "fast-forward-only ref rules require indexed mode",
46            ));
47        }
48        Ok(())
49    }
50    /// A policy of `rules`.
51    #[must_use]
52    pub fn new(rules: Vec<RefRule>) -> Self {
53        Self { rules }
54    }
55
56    /// Whether any rule is fast-forward-only.
57    pub(crate) fn has_fast_forward_rule(&self) -> bool {
58        self.rules.iter().any(|rule| rule.fast_forward_only)
59    }
60
61    /// Startup validation: every pattern is its own parse, so none names a
62    /// packmap ref or a malformed name.
63    pub(crate) fn validate(&self) -> Result<(), ServerError> {
64        for rule in &self.rules {
65            if RefPattern::parse(&rule.pattern.to_string()).as_ref() != Ok(&rule.pattern) {
66                return Err(ServerError::invalid_argument("invalid ref policy pattern"));
67            }
68        }
69        Ok(())
70    }
71
72    /// Whether the authenticated `signer` may move `ref_name`. A matching
73    /// signer rule with no auth v2 signer (`None`) denies.
74    pub(crate) fn signer_allowed(&self, ref_name: &str, signer: Option<&[u8; 32]>) -> bool {
75        let name = rule_name(ref_name);
76        self.rules
77            .iter()
78            .filter(|rule| rule.pattern.matches(&name))
79            .filter_map(|rule| rule.allowed_signers.as_ref())
80            .all(|allowed| signer.is_some_and(|key| allowed.contains(key)))
81    }
82
83    /// Whether a matching rule makes `ref_name` fast-forward-only. Packmaps
84    /// have no ancestry, so only their head is checked.
85    pub(crate) fn fast_forward_only(&self, ref_name: &str) -> bool {
86        packmap_head(ref_name).is_none()
87            && self
88                .rules
89                .iter()
90                .any(|rule| rule.fast_forward_only && rule.pattern.matches(ref_name))
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn rule(pattern: &str, signers: Option<&[[u8; 32]]>, ff: bool) -> RefRule {
99        RefRule {
100            pattern: RefPattern::parse(pattern).unwrap(),
101            allowed_signers: signers.map(|keys| keys.iter().copied().collect()),
102            fast_forward_only: ff,
103        }
104    }
105
106    #[test]
107    fn overlapping_signer_rules_intersect_and_packmaps_follow_their_head() {
108        let (a, b, c) = ([1; 32], [2; 32], [3; 32]);
109        let policy = RefPolicy::new(vec![
110            rule("refs/heads/*", Some(&[a, b]), false),
111            rule("refs/heads/main", Some(&[b, c]), true),
112        ]);
113        assert!(policy.validate().is_ok());
114        assert!(policy.signer_allowed("refs/heads/main", Some(&b)));
115        assert!(!policy.signer_allowed("refs/heads/main", Some(&a)));
116        assert!(!policy.signer_allowed("refs/heads/main", Some(&c)));
117        assert!(!policy.signer_allowed("refs/heads/main", None));
118        assert!(!policy.signer_allowed("refs/mkit/packmap/main", Some(&a)));
119        assert!(policy.signer_allowed("refs/mkit/packmap/main", Some(&b)));
120        assert!(policy.signer_allowed("refs/tags/v1", None));
121        assert!(policy.fast_forward_only("refs/heads/main"));
122        assert!(!policy.fast_forward_only("refs/mkit/packmap/main"));
123        assert!(!policy.fast_forward_only("refs/heads/dev"));
124        // A signer-free rule never denies a missing signer.
125        let ff_only = RefPolicy::new(vec![rule("refs/heads/main", None, true)]);
126        assert!(ff_only.signer_allowed("refs/heads/main", None));
127    }
128
129    #[test]
130    fn a_pattern_naming_a_packmap_ref_is_refused_at_startup() {
131        let policy = RefPolicy::new(vec![RefRule {
132            pattern: RefPattern::Exact("refs/mkit/packmap/main".into()),
133            allowed_signers: None,
134            fast_forward_only: true,
135        }]);
136        assert!(policy.validate().is_err());
137    }
138}