Skip to main content

void_core/collab/manifest/
policy.rs

1//! Write policy matching for contributor manifests.
2//!
3//! Determines if a signer is authorized to push to a given ref path
4//! based on write policies and delegations.
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::support::pathspec::Pathspec;
9
10use super::keys::SigningPubKey;
11use super::types::Manifest;
12
13// ============================================================================
14// AuthResult — Result of an authorization check
15// ============================================================================
16
17/// Result of checking write authorization.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AuthResult {
20    /// Access allowed via direct policy.
21    Allowed,
22    /// Access allowed via delegation from another contributor.
23    Delegated {
24        /// The signing key of the delegator.
25        from: SigningPubKey,
26    },
27    /// Access denied.
28    Denied,
29}
30
31impl AuthResult {
32    /// Returns true if access is allowed (directly or via delegation).
33    pub fn is_allowed(&self) -> bool {
34        matches!(self, AuthResult::Allowed | AuthResult::Delegated { .. })
35    }
36}
37
38// ============================================================================
39// Policy checking
40// ============================================================================
41
42/// Check if a signer has write access to a ref path.
43///
44/// Checks in order:
45/// 1. Is the signer the repository owner? (always allowed)
46/// 2. Does any write_policy pattern match and include the signer?
47/// 3. Does any valid delegation grant access?
48///
49/// # Arguments
50/// * `manifest` - The contributor manifest.
51/// * `signer` - The signing key of the user attempting to push.
52/// * `ref_path` - The ref path being pushed to (e.g., "refs/heads/main").
53pub fn check_write_access(
54    manifest: &Manifest,
55    signer: &SigningPubKey,
56    ref_path: &str,
57) -> AuthResult {
58    // Owner always has access — checks both the repo owner key directly
59    // and the identity key via the owner delegation certificate
60    if manifest.is_owner_or_identity(signer) {
61        return AuthResult::Allowed;
62    }
63
64    // Check direct write policies
65    for (pattern, allowed_signers) in &manifest.write_policy {
66        if matches_ref_pattern(pattern, ref_path) && allowed_signers.contains(signer) {
67            return AuthResult::Allowed;
68        }
69    }
70
71    // Check delegations
72    let now = current_timestamp();
73    for delegation in &manifest.delegations {
74        if &delegation.to != signer {
75            continue;
76        }
77
78        if !delegation.is_valid_at(now) {
79            continue;
80        }
81
82        if matches_ref_pattern(&delegation.scope, ref_path) {
83            // Verify delegator has access to this scope
84            let delegator_access = check_delegator_access(manifest, &delegation.from, ref_path);
85            if delegator_access {
86                return AuthResult::Delegated {
87                    from: delegation.from.clone(),
88                };
89            }
90        }
91    }
92
93    AuthResult::Denied
94}
95
96/// Check if a delegator has access to delegate for a given ref path.
97///
98/// A user can only delegate permissions they themselves have.
99fn check_delegator_access(manifest: &Manifest, delegator: &SigningPubKey, ref_path: &str) -> bool {
100    // Owner can delegate anything — checks both repo owner key and identity key
101    if manifest.is_owner_or_identity(delegator) {
102        return true;
103    }
104
105    // Check if delegator has direct access
106    for (pattern, allowed_signers) in &manifest.write_policy {
107        if matches_ref_pattern(pattern, ref_path) && allowed_signers.contains(delegator) {
108            return true;
109        }
110    }
111
112    false
113}
114
115/// Match a ref pattern against a ref path.
116///
117/// Uses glob-style matching via Pathspec.
118fn matches_ref_pattern(pattern: &str, ref_path: &str) -> bool {
119    // Exact match is always allowed
120    if pattern == ref_path {
121        return true;
122    }
123
124    // Use Pathspec for glob matching
125    match Pathspec::new(&[pattern]) {
126        Ok(ps) => ps.matches(ref_path),
127        Err(_) => false,
128    }
129}
130
131/// Get the default contributor namespace for a signing key.
132///
133/// Contributors get a personal namespace under `refs/heads/contrib/<key_prefix>/`
134/// where they can push freely without explicit policy entries.
135///
136/// # Arguments
137/// * `signer` - The signing key of the contributor.
138///
139/// # Returns
140/// The ref pattern for the contributor's namespace.
141pub fn default_contributor_namespace(signer: &SigningPubKey) -> String {
142    let prefix = &signer.to_hex()[..16]; // First 8 bytes as hex
143    format!("refs/heads/contrib/{}/", prefix)
144}
145
146/// Check if a signer has access to their default contributor namespace.
147///
148/// This is a convenience function that checks the standard namespace pattern.
149pub fn check_contributor_namespace_access(
150    _manifest: &Manifest,
151    signer: &SigningPubKey,
152    ref_path: &str,
153) -> bool {
154    let namespace = default_contributor_namespace(signer);
155
156    // Check if the ref is under the contributor's namespace
157    if ref_path.starts_with(&namespace) {
158        // Contributors always have access to their own namespace
159        // (unless explicitly denied, which we don't implement yet)
160        return true;
161    }
162
163    false
164}
165
166/// Get current Unix timestamp.
167fn current_timestamp() -> u64 {
168    SystemTime::now()
169        .duration_since(UNIX_EPOCH)
170        .map(|d| d.as_secs())
171        .unwrap_or(0)
172}
173
174// ============================================================================
175// Tests
176// ============================================================================
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::collab::manifest::types::Delegation;
182
183    fn test_signing_key(val: u8) -> SigningPubKey {
184        SigningPubKey::from_bytes([val; 32])
185    }
186
187    fn test_manifest_with_policy() -> Manifest {
188        let owner = test_signing_key(0xaa);
189        let alice = test_signing_key(0xbb);
190
191        let mut manifest = Manifest::new(owner, None);
192
193        // Alice can push to main
194        manifest
195            .write_policy
196            .insert("refs/heads/main".to_string(), vec![alice.clone()]);
197
198        // Alice can push to any feature branch
199        manifest
200            .write_policy
201            .insert("refs/heads/feature/*".to_string(), vec![alice]);
202
203        manifest
204    }
205
206    #[test]
207    fn owner_always_allowed() {
208        let owner = test_signing_key(0xaa);
209        let manifest = Manifest::new(owner, None);
210
211        let result = check_write_access(&manifest, &owner, "refs/heads/main");
212        assert_eq!(result, AuthResult::Allowed);
213
214        let result = check_write_access(&manifest, &owner, "refs/heads/anything");
215        assert_eq!(result, AuthResult::Allowed);
216    }
217
218    #[test]
219    fn non_contributor_denied() {
220        let manifest = test_manifest_with_policy();
221        let stranger = test_signing_key(0xcc);
222
223        let result = check_write_access(&manifest, &stranger, "refs/heads/main");
224        assert_eq!(result, AuthResult::Denied);
225    }
226
227    #[test]
228    fn direct_policy_match() {
229        let manifest = test_manifest_with_policy();
230        let alice = test_signing_key(0xbb);
231
232        // Exact match
233        let result = check_write_access(&manifest, &alice, "refs/heads/main");
234        assert_eq!(result, AuthResult::Allowed);
235    }
236
237    #[test]
238    fn glob_policy_match() {
239        let manifest = test_manifest_with_policy();
240        let alice = test_signing_key(0xbb);
241
242        // Glob match
243        let result = check_write_access(&manifest, &alice, "refs/heads/feature/new-thing");
244        assert_eq!(result, AuthResult::Allowed);
245
246        // Should not match non-feature branches
247        let result = check_write_access(&manifest, &alice, "refs/heads/develop");
248        assert_eq!(result, AuthResult::Denied);
249    }
250
251    #[test]
252    fn delegation_grants_access() {
253        let owner = test_signing_key(0xaa);
254        let bob = test_signing_key(0xcc);
255
256        let mut manifest = Manifest::new(owner, None);
257
258        // Owner delegates main branch access to Bob
259        manifest.delegations.push(Delegation {
260            from: owner.clone(),
261            to: bob.clone(),
262            scope: "refs/heads/main".to_string(),
263            granted_at: 0,
264            expires_at: None, // No expiry
265            signature: vec![0; 64],
266        });
267
268        let result = check_write_access(&manifest, &bob, "refs/heads/main");
269        assert!(matches!(result, AuthResult::Delegated { .. }));
270
271        // Bob still can't push to other branches
272        let result = check_write_access(&manifest, &bob, "refs/heads/develop");
273        assert_eq!(result, AuthResult::Denied);
274    }
275
276    #[test]
277    fn expired_delegation_denied() {
278        let owner = test_signing_key(0xaa);
279        let bob = test_signing_key(0xcc);
280
281        let mut manifest = Manifest::new(owner, None);
282
283        // Expired delegation
284        manifest.delegations.push(Delegation {
285            from: owner,
286            to: bob.clone(),
287            scope: "refs/heads/main".to_string(),
288            granted_at: 0,
289            expires_at: Some(1), // Expired long ago
290            signature: vec![0; 64],
291        });
292
293        let result = check_write_access(&manifest, &bob, "refs/heads/main");
294        assert_eq!(result, AuthResult::Denied);
295    }
296
297    #[test]
298    fn matches_ref_pattern_exact() {
299        assert!(matches_ref_pattern("refs/heads/main", "refs/heads/main"));
300        assert!(!matches_ref_pattern("refs/heads/main", "refs/heads/develop"));
301    }
302
303    #[test]
304    fn matches_ref_pattern_glob() {
305        assert!(matches_ref_pattern(
306            "refs/heads/feature/*",
307            "refs/heads/feature/foo"
308        ));
309        assert!(matches_ref_pattern(
310            "refs/heads/feature/*",
311            "refs/heads/feature/bar"
312        ));
313        assert!(!matches_ref_pattern(
314            "refs/heads/feature/*",
315            "refs/heads/main"
316        ));
317    }
318
319    #[test]
320    fn matches_ref_pattern_recursive_glob() {
321        assert!(matches_ref_pattern(
322            "refs/heads/**",
323            "refs/heads/feature/deep/nested"
324        ));
325    }
326
327    #[test]
328    fn default_contributor_namespace_format() {
329        let signer = test_signing_key(0xab);
330        let ns = default_contributor_namespace(&signer);
331
332        assert!(ns.starts_with("refs/heads/contrib/"));
333        assert!(ns.ends_with("/"));
334        assert!(ns.contains("abababab")); // First 8 bytes repeated
335    }
336
337    #[test]
338    fn contributor_namespace_access() {
339        let signer = test_signing_key(0xab);
340        let manifest = Manifest::new(test_signing_key(0xaa), None);
341
342        let ns = default_contributor_namespace(&signer);
343        let ref_in_ns = format!("{}feature/test", ns);
344
345        assert!(check_contributor_namespace_access(
346            &manifest,
347            &signer,
348            &ref_in_ns
349        ));
350        assert!(!check_contributor_namespace_access(
351            &manifest,
352            &signer,
353            "refs/heads/main"
354        ));
355    }
356
357    #[test]
358    fn auth_result_is_allowed() {
359        assert!(AuthResult::Allowed.is_allowed());
360        assert!(AuthResult::Delegated {
361            from: test_signing_key(0xaa)
362        }
363        .is_allowed());
364        assert!(!AuthResult::Denied.is_allowed());
365    }
366}