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#[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 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 pub fn subject(&self) -> &str {
31 &self.subject
32 }
33
34 pub fn scope(&self) -> &Scope {
36 &self.scope
37 }
38
39 pub fn visibility(&self) -> Visibility {
41 self.visibility
42 }
43}
44
45pub trait Authorizer {
47 fn can_get(&self, principal: &Principal, secret: &SecretMeta) -> bool;
49 fn can_put(&self, principal: &Principal, secret: &SecretMeta) -> bool;
51 fn can_delete(&self, principal: &Principal, secret: &SecretMeta) -> bool;
53 fn can_rotate(&self, principal: &Principal, secret: &SecretMeta) -> bool;
55}
56
57#[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 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 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}