secrets_core/
policy.rs

1use crate::spec_compat::{Scope, SecretMeta, Visibility};
2use crate::{errors::Result, types::validate_component};
3use serde::{Deserialize, Serialize};
4
5#[cfg(feature = "schema")]
6use schemars::JsonSchema;
7
8/// Actor attempting to perform operations on secrets.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[cfg_attr(feature = "schema", derive(JsonSchema))]
11pub struct Principal {
12    subject: String,
13    scope: Scope,
14    visibility: Visibility,
15}
16
17impl Principal {
18    /// Construct a principal with validated attributes.
19    pub fn new(subject: impl Into<String>, scope: Scope, visibility: Visibility) -> Result<Self> {
20        let subject = subject.into();
21        validate_component(&subject, "subject")?;
22        Ok(Self {
23            subject,
24            scope,
25            visibility,
26        })
27    }
28
29    /// Subject identifier (for logging/auditing).
30    pub fn subject(&self) -> &str {
31        &self.subject
32    }
33
34    /// Scope attached to the subject.
35    pub fn scope(&self) -> &Scope {
36        &self.scope
37    }
38
39    /// Maximum visibility allowed for the subject.
40    pub fn visibility(&self) -> Visibility {
41        self.visibility
42    }
43}
44
45/// Authorization interface for secret operations.
46pub trait Authorizer {
47    /// Determine whether a secret can be retrieved.
48    fn can_get(&self, principal: &Principal, secret: &SecretMeta) -> bool;
49    /// Determine whether a secret can be created or updated.
50    fn can_put(&self, principal: &Principal, secret: &SecretMeta) -> bool;
51    /// Determine whether a secret may be deleted.
52    fn can_delete(&self, principal: &Principal, secret: &SecretMeta) -> bool;
53    /// Determine whether a secret may be rotated.
54    fn can_rotate(&self, principal: &Principal, secret: &SecretMeta) -> bool;
55}
56
57/// Default policy guard implementing scope- and visibility-based access control.
58#[derive(Debug, Default, Clone, Copy)]
59pub struct PolicyGuard;
60
61impl PolicyGuard {
62    fn evaluate(&self, action: Action, principal: &Principal, secret: &SecretMeta) -> bool {
63        if !principal.scope().matches(secret.scope()) {
64            return false;
65        }
66
67        if !team_allowed(principal.scope(), secret.scope(), secret.visibility) {
68            return false;
69        }
70
71        if !principal.visibility().allows(secret.visibility) {
72            return false;
73        }
74
75        match action {
76            Action::Get => true,
77            Action::Put | Action::Delete | Action::Rotate => {
78                // Restrict tenant-scoped secrets to tenant level actors for mutation.
79                if secret.visibility == Visibility::Tenant {
80                    principal.visibility() == Visibility::Tenant
81                } else {
82                    true
83                }
84            }
85        }
86    }
87}
88
89fn team_allowed(principal_scope: &Scope, secret_scope: &Scope, visibility: Visibility) -> bool {
90    match visibility {
91        Visibility::User | Visibility::Team => principal_scope.team_matches(secret_scope),
92        Visibility::Tenant => {
93            // Tenant scoped secrets ignore team boundaries.
94            true
95        }
96    }
97}
98
99impl Authorizer for PolicyGuard {
100    fn can_get(&self, principal: &Principal, secret: &SecretMeta) -> bool {
101        self.evaluate(Action::Get, principal, secret)
102    }
103
104    fn can_put(&self, principal: &Principal, secret: &SecretMeta) -> bool {
105        self.evaluate(Action::Put, principal, secret)
106    }
107
108    fn can_delete(&self, principal: &Principal, secret: &SecretMeta) -> bool {
109        self.evaluate(Action::Delete, principal, secret)
110    }
111
112    fn can_rotate(&self, principal: &Principal, secret: &SecretMeta) -> bool {
113        self.evaluate(Action::Rotate, principal, secret)
114    }
115}
116
117#[derive(Clone, Copy)]
118enum Action {
119    Get,
120    Put,
121    Delete,
122    Rotate,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::spec_compat::ContentType;
129    use crate::uri::SecretUri;
130
131    fn build_meta(scope: Scope, visibility: Visibility) -> SecretMeta {
132        let uri = SecretUri::new(scope.clone(), "kv", "api-key").unwrap();
133        SecretMeta::new(uri, visibility, ContentType::Opaque)
134    }
135
136    fn principal(
137        subject: &str,
138        env: &str,
139        tenant: &str,
140        team: Option<&str>,
141        visibility: Visibility,
142    ) -> Principal {
143        let scope = Scope::new(
144            env.to_string(),
145            tenant.to_string(),
146            team.map(|t| t.to_string()),
147        )
148        .unwrap();
149        Principal::new(subject.to_string(), scope, visibility).unwrap()
150    }
151
152    #[test]
153    fn acl_positive_cases() {
154        let guard = PolicyGuard;
155
156        let team_scope = Scope::new("prod", "acme", Some("payments".into())).unwrap();
157        let team_meta = build_meta(team_scope.clone(), Visibility::Team);
158        let team_principal = principal("alice", "prod", "acme", Some("payments"), Visibility::Team);
159
160        assert!(guard.can_get(&team_principal, &team_meta));
161        assert!(guard.can_put(&team_principal, &team_meta));
162        assert!(guard.can_delete(&team_principal, &team_meta));
163        assert!(guard.can_rotate(&team_principal, &team_meta));
164
165        let tenant_scope = Scope::new("prod", "acme", None).unwrap();
166        let tenant_meta = build_meta(tenant_scope.clone(), Visibility::Tenant);
167        let tenant_admin = principal("tenant-admin", "prod", "acme", None, Visibility::Tenant);
168
169        assert!(guard.can_get(&tenant_admin, &tenant_meta));
170        assert!(guard.can_put(&tenant_admin, &tenant_meta));
171    }
172
173    #[test]
174    fn acl_negative_cases() {
175        let guard = PolicyGuard;
176        let payments_scope = Scope::new("prod", "acme", Some("payments".into())).unwrap();
177        let billing_scope = Scope::new("prod", "acme", Some("billing".into())).unwrap();
178
179        let payments_meta = build_meta(payments_scope.clone(), Visibility::Team);
180        let billing_principal = principal("bob", "prod", "acme", Some("billing"), Visibility::Team);
181
182        assert!(!guard.can_get(&billing_principal, &payments_meta));
183        assert!(!guard.can_put(&billing_principal, &payments_meta));
184
185        let tenant_meta = build_meta(billing_scope.clone(), Visibility::Tenant);
186        let team_operator = principal("ops", "prod", "acme", Some("billing"), Visibility::Team);
187
188        assert!(!guard.can_put(&team_operator, &tenant_meta));
189        assert!(!guard.can_delete(&team_operator, &tenant_meta));
190        assert!(!guard.can_rotate(&team_operator, &tenant_meta));
191
192        let dev_principal = principal("dave", "dev", "acme", Some("payments"), Visibility::Tenant);
193        assert!(!guard.can_get(&dev_principal, &payments_meta));
194    }
195}