Skip to main content

secrets_core/
policy.rs

1use serde::{Deserialize, Serialize};
2
3use crate::storage::{StorageBackend, StorageEntry, StorageError};
4
5const POLICY_PREFIX: &str = "sys/policy/";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Capability {
10    Read,
11    Create,
12    Update,
13    Delete,
14    List,
15    Sudo,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PathRule {
20    pub prefix: String,
21    pub capabilities: Vec<Capability>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct Policy {
26    pub name: String,
27    pub rules: Vec<PathRule>,
28}
29
30impl Policy {
31    /// Deny-by-default, longest-prefix-match evaluation.
32    pub fn is_allowed(&self, path: &str, capability: Capability) -> bool {
33        self.rules
34            .iter()
35            .filter(|r| path.starts_with(&r.prefix))
36            .max_by_key(|r| r.prefix.len())
37            .is_some_and(|r| r.capabilities.contains(&capability))
38    }
39}
40
41pub fn evaluate(policies: &[Policy], path: &str, capability: Capability) -> bool {
42    policies.iter().any(|p| p.is_allowed(path, capability))
43}
44
45pub async fn store_policy(storage: &dyn StorageBackend, policy: &Policy) -> Result<(), StorageError> {
46    let path = format!("{POLICY_PREFIX}{}", policy.name);
47    let value = serde_json::to_vec(policy).map_err(|e| StorageError::Backend(e.to_string()))?;
48    storage
49        .put(
50            &path,
51            StorageEntry {
52                value,
53                expires_at: None,
54            },
55        )
56        .await
57}
58
59pub async fn get_policy(
60    storage: &dyn StorageBackend,
61    name: &str,
62) -> Result<Option<Policy>, StorageError> {
63    let path = format!("{POLICY_PREFIX}{name}");
64    let Some(entry) = storage.get(&path).await? else {
65        return Ok(None);
66    };
67    let policy: Policy =
68        serde_json::from_slice(&entry.value).map_err(|e| StorageError::Backend(e.to_string()))?;
69    Ok(Some(policy))
70}
71
72pub async fn delete_policy(storage: &dyn StorageBackend, name: &str) -> Result<(), StorageError> {
73    storage.delete(&format!("{POLICY_PREFIX}{name}")).await
74}
75
76/// Loads named policies, silently skipping any that no longer exist (e.g. a
77/// token referencing a since-deleted policy).
78pub async fn load_policies(
79    storage: &dyn StorageBackend,
80    names: &[String],
81) -> Result<Vec<Policy>, StorageError> {
82    let mut policies = Vec::with_capacity(names.len());
83    for name in names {
84        if let Some(policy) = get_policy(storage, name).await? {
85            policies.push(policy);
86        }
87    }
88    Ok(policies)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn policy(rules: Vec<(&str, Vec<Capability>)>) -> Policy {
96        Policy {
97            name: "test".into(),
98            rules: rules
99                .into_iter()
100                .map(|(prefix, capabilities)| PathRule {
101                    prefix: prefix.to_string(),
102                    capabilities,
103                })
104                .collect(),
105        }
106    }
107
108    #[test]
109    fn deny_by_default() {
110        let p = policy(vec![]);
111        assert!(!p.is_allowed("secret/foo", Capability::Read));
112    }
113
114    #[test]
115    fn longest_prefix_wins() {
116        let p = policy(vec![
117            ("secret/", vec![Capability::Read]),
118            ("secret/admin/", vec![Capability::Sudo]),
119        ]);
120        assert!(p.is_allowed("secret/foo", Capability::Read));
121        assert!(!p.is_allowed("secret/admin/x", Capability::Read));
122        assert!(p.is_allowed("secret/admin/x", Capability::Sudo));
123    }
124
125    #[test]
126    fn evaluate_across_multiple_policies() {
127        let a = policy(vec![("secret/", vec![Capability::Read])]);
128        let b = policy(vec![("sys/", vec![Capability::Sudo])]);
129        assert!(evaluate(&[a.clone(), b.clone()], "secret/foo", Capability::Read));
130        assert!(evaluate(&[a, b], "sys/policy", Capability::Sudo));
131    }
132}