1use std::collections::{BTreeMap, BTreeSet};
4
5use super::model::{AccessControlConfig, AccessEffect, AccessRule};
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct AccessPrincipal {
10 pub subject: Option<String>,
14 pub groups: BTreeSet<String>,
16}
17
18impl AccessPrincipal {
19 pub fn anonymous() -> Self {
20 Self::default()
21 }
22}
23
24#[derive(Debug, Clone, Copy, Default)]
28pub struct AccessResource<'a> {
29 pub identity: Option<&'a str>,
31 pub agent_id: Option<&'a str>,
33 pub role: Option<&'a str>,
35 pub labels: Option<&'a BTreeMap<String, String>>,
38}
39
40impl<'a> AccessResource<'a> {
41 pub fn none() -> Self {
42 Self::default()
43 }
44
45 pub fn for_identity(identity: &'a str) -> Self {
46 Self {
47 identity: Some(identity),
48 ..Self::default()
49 }
50 }
51
52 fn is_present(&self) -> bool {
53 self.identity.is_some() || self.agent_id.is_some()
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum AccessDecision {
60 Allow,
61 Deny { reason: String },
62}
63
64impl AccessDecision {
65 pub fn is_allow(&self) -> bool {
66 matches!(self, Self::Allow)
67 }
68
69 pub fn reason(&self) -> Option<&str> {
70 match self {
71 Self::Allow => None,
72 Self::Deny { reason } => Some(reason),
73 }
74 }
75}
76
77fn action_matches(pattern: &str, action: &str) -> bool {
78 if pattern == "*" || pattern == action {
79 return true;
80 }
81 pattern.strip_suffix(".*").is_some_and(|prefix| {
82 action
83 .strip_prefix(prefix)
84 .is_some_and(|rest| rest.starts_with('.'))
85 })
86}
87
88fn subject_matches(rule: &AccessRule, principal: &AccessPrincipal) -> bool {
89 if rule.subjects.is_empty() && rule.groups.is_empty() {
90 return true;
91 }
92 if let Some(subject) = principal.subject.as_deref()
99 && rule
100 .subjects
101 .iter()
102 .any(|candidate| candidate == "*" || candidate == subject)
103 {
104 return true;
105 }
106 rule.groups
107 .iter()
108 .any(|group| principal.groups.contains(group))
109}
110
111fn resource_matches(rule: &AccessRule, resource: &AccessResource<'_>) -> bool {
112 if !rule.has_resource_selector() {
113 return true;
114 }
115 if !resource.is_present() {
117 return false;
118 }
119 if !rule.agents.is_empty() {
120 let agent_listed = rule.agents.iter().any(|candidate| {
121 candidate == "*"
122 || resource
123 .identity
124 .is_some_and(|identity| identity == candidate)
125 || resource
126 .agent_id
127 .is_some_and(|agent_id| agent_id == candidate)
128 });
129 if !agent_listed {
130 return false;
131 }
132 }
133 if !rule.roles.is_empty() {
134 let role_listed = resource.role.is_some_and(|role| {
135 rule.roles
136 .iter()
137 .any(|candidate| candidate == "*" || candidate == role)
138 });
139 if !role_listed {
140 return false;
141 }
142 }
143 if !rule.match_labels.is_empty() {
144 let Some(labels) = resource.labels else {
145 return false;
147 };
148 let all_labels_match = rule
149 .match_labels
150 .iter()
151 .all(|(key, value)| labels.get(key) == Some(value));
152 if !all_labels_match {
153 return false;
154 }
155 }
156 true
157}
158
159pub fn evaluate_access(
165 config: &AccessControlConfig,
166 principal: &AccessPrincipal,
167 action: &str,
168 resource: &AccessResource<'_>,
169) -> AccessDecision {
170 evaluate_access_lineage(config, principal, action, std::slice::from_ref(resource))
171}
172
173pub(crate) fn evaluate_access_lineage(
181 config: &AccessControlConfig,
182 principal: &AccessPrincipal,
183 action: &str,
184 resources: &[AccessResource<'_>],
185) -> AccessDecision {
186 if !config.enabled {
187 return AccessDecision::Allow;
188 }
189 if let Some(subject) = principal.subject.as_deref()
190 && config.admins.iter().any(|admin| admin == subject)
191 {
192 return AccessDecision::Allow;
193 }
194
195 let mut allowed = false;
196 for rule in &config.rules {
197 let matches = rule
198 .actions
199 .iter()
200 .any(|pattern| action_matches(pattern, action))
201 && subject_matches(rule, principal)
202 && resources
203 .iter()
204 .any(|resource| resource_matches(rule, resource));
205 if !matches {
206 continue;
207 }
208 match rule.effect {
209 AccessEffect::Deny => {
210 return AccessDecision::Deny {
211 reason: format!("denied by rule {}", rule.id),
212 };
213 }
214 AccessEffect::Allow => allowed = true,
215 }
216 }
217 if allowed {
218 AccessDecision::Allow
219 } else {
220 AccessDecision::Deny {
221 reason: format!("no rule allows {action}"),
222 }
223 }
224}
225
226pub(crate) fn principal_may_perform(
233 config: &AccessControlConfig,
234 principal: &AccessPrincipal,
235 action: &str,
236) -> bool {
237 if !config.enabled {
238 return true;
239 }
240 config.rules.iter().any(|rule| {
241 matches!(rule.effect, AccessEffect::Allow)
242 && rule
243 .actions
244 .iter()
245 .any(|pattern| action_matches(pattern, action))
246 && subject_matches(rule, principal)
247 })
248}
249
250pub(crate) fn groups_for_subject(config: &AccessControlConfig, subject: &str) -> BTreeSet<String> {
252 config
253 .groups
254 .iter()
255 .filter(|(_, group)| group.members.iter().any(|member| member == subject))
256 .map(|(name, _)| name.clone())
257 .collect()
258}
259
260#[cfg(test)]
261#[allow(clippy::expect_used, clippy::unwrap_used)]
262mod tests {
263 use super::*;
264 use crate::access::model::AccessGroup;
265
266 fn config_with_rules(rules: Vec<AccessRule>) -> AccessControlConfig {
267 AccessControlConfig {
268 enabled: true,
269 admins: vec!["root@example.test".to_string()],
270 groups: BTreeMap::from([(
271 "ops".to_string(),
272 AccessGroup {
273 description: None,
274 members: vec!["alice@example.test".to_string()],
275 },
276 )]),
277 rules,
278 }
279 }
280
281 fn principal(subject: &str, groups: &[&str]) -> AccessPrincipal {
282 AccessPrincipal {
283 subject: Some(subject.to_string()),
284 groups: groups.iter().map(ToString::to_string).collect(),
285 }
286 }
287
288 fn allow_rule(id: &str) -> AccessRule {
289 AccessRule {
290 id: id.to_string(),
291 ..AccessRule::default()
292 }
293 }
294
295 #[test]
296 fn disabled_config_allows_everything() {
297 let config = AccessControlConfig::default();
298 let decision = evaluate_access(
299 &config,
300 &AccessPrincipal::anonymous(),
301 "access.admin",
302 &AccessResource::none(),
303 );
304 assert!(decision.is_allow());
305 }
306
307 #[test]
308 fn enabled_config_denies_by_default() {
309 let config = config_with_rules(vec![]);
310 let decision = evaluate_access(
311 &config,
312 &principal("bob@example.test", &[]),
313 "agent.view",
314 &AccessResource::for_identity("identity:ops-lead"),
315 );
316 assert!(!decision.is_allow());
317 }
318
319 #[test]
320 fn admins_bypass_rules() {
321 let config = config_with_rules(vec![]);
322 let decision = evaluate_access(
323 &config,
324 &principal("root@example.test", &[]),
325 "access.admin",
326 &AccessResource::none(),
327 );
328 assert!(decision.is_allow());
329 }
330
331 #[test]
332 fn group_can_view_all_but_send_to_one() {
333 let mut view_rule = allow_rule("ops-view-all");
334 view_rule.groups = vec!["ops".to_string()];
335 view_rule.actions = vec!["agent.view".to_string()];
336 let mut send_rule = allow_rule("ops-send-lead");
337 send_rule.groups = vec!["ops".to_string()];
338 send_rule.actions = vec!["agent.send".to_string()];
339 send_rule.agents = vec!["identity:ops-lead".to_string()];
340 let config = config_with_rules(vec![view_rule, send_rule]);
341 let alice = principal("alice@example.test", &["ops"]);
342
343 let can_view_any = evaluate_access(
344 &config,
345 &alice,
346 "agent.view",
347 &AccessResource::for_identity("identity:scout-1"),
348 );
349 assert!(can_view_any.is_allow());
350 let can_send_lead = evaluate_access(
351 &config,
352 &alice,
353 "agent.send",
354 &AccessResource::for_identity("identity:ops-lead"),
355 );
356 assert!(can_send_lead.is_allow());
357 let cannot_send_other = evaluate_access(
358 &config,
359 &alice,
360 "agent.send",
361 &AccessResource::for_identity("identity:scout-1"),
362 );
363 assert!(!cannot_send_other.is_allow());
364 }
365
366 #[test]
367 fn deny_overrides_allow() {
368 let mut allow_all = allow_rule("everyone-views");
369 allow_all.actions = vec!["agent.view".to_string()];
370 let mut deny_secret = allow_rule("hide-secret");
371 deny_secret.effect = AccessEffect::Deny;
372 deny_secret.actions = vec!["agent.*".to_string()];
373 deny_secret.agents = vec!["identity:secret".to_string()];
374 let config = config_with_rules(vec![allow_all, deny_secret]);
375 let bob = principal("bob@example.test", &[]);
376
377 assert!(
378 evaluate_access(
379 &config,
380 &bob,
381 "agent.view",
382 &AccessResource::for_identity("identity:scout-1"),
383 )
384 .is_allow()
385 );
386 assert!(
387 !evaluate_access(
388 &config,
389 &bob,
390 "agent.view",
391 &AccessResource::for_identity("identity:secret"),
392 )
393 .is_allow()
394 );
395 }
396
397 #[test]
398 fn agent_and_role_selectors_support_wildcard() {
399 let mut agents_wildcard = allow_rule("view-any-agent");
400 agents_wildcard.actions = vec!["agent.view".to_string()];
401 agents_wildcard.agents = vec!["*".to_string()];
402 let mut roles_wildcard = allow_rule("send-any-role");
403 roles_wildcard.actions = vec!["agent.send".to_string()];
404 roles_wildcard.roles = vec!["*".to_string()];
405 let config = config_with_rules(vec![agents_wildcard, roles_wildcard]);
406 let bob = principal("bob@example.test", &[]);
407
408 assert!(
409 evaluate_access(
410 &config,
411 &bob,
412 "agent.view",
413 &AccessResource::for_identity("identity:anyone"),
414 )
415 .is_allow()
416 );
417 let with_role = AccessResource {
419 identity: Some("identity:anyone"),
420 role: Some("scout"),
421 ..AccessResource::default()
422 };
423 assert!(evaluate_access(&config, &bob, "agent.send", &with_role).is_allow());
424 assert!(
426 !evaluate_access(
427 &config,
428 &bob,
429 "agent.send",
430 &AccessResource::for_identity("identity:anyone"),
431 )
432 .is_allow()
433 );
434 }
435
436 #[test]
437 fn label_selectors_require_known_labels() {
438 let mut rule = allow_rule("payments-only");
439 rule.actions = vec!["agent.view".to_string()];
440 rule.match_labels = BTreeMap::from([("org".to_string(), "payments".to_string())]);
441 let config = config_with_rules(vec![rule]);
442 let bob = principal("bob@example.test", &[]);
443
444 let labels = BTreeMap::from([("org".to_string(), "payments".to_string())]);
445 let with_labels = AccessResource {
446 identity: Some("identity:pay-1"),
447 labels: Some(&labels),
448 ..AccessResource::default()
449 };
450 assert!(evaluate_access(&config, &bob, "agent.view", &with_labels).is_allow());
451
452 let unknown_labels = AccessResource::for_identity("identity:pay-1");
453 assert!(!evaluate_access(&config, &bob, "agent.view", &unknown_labels).is_allow());
454 }
455
456 #[test]
457 fn resourceless_checks_only_match_unconstrained_rules() {
458 let mut constrained = allow_rule("constrained-decide");
459 constrained.actions = vec!["gating.decide".to_string()];
460 constrained.agents = vec!["identity:ops-lead".to_string()];
461 let config = config_with_rules(vec![constrained]);
462 let bob = principal("bob@example.test", &[]);
463 assert!(
464 !evaluate_access(&config, &bob, "gating.decide", &AccessResource::none()).is_allow()
465 );
466
467 let mut unconstrained = allow_rule("decide");
468 unconstrained.actions = vec!["gating.decide".to_string()];
469 let config = config_with_rules(vec![unconstrained]);
470 assert!(
471 evaluate_access(&config, &bob, "gating.decide", &AccessResource::none()).is_allow()
472 );
473 }
474
475 #[test]
476 fn anonymous_matches_only_unconstrained_subjects() {
477 let mut open_rule = allow_rule("everyone-views");
478 open_rule.actions = vec!["agent.view".to_string()];
479 let mut named_rule = allow_rule("alice-sends");
480 named_rule.subjects = vec!["alice@example.test".to_string()];
481 named_rule.actions = vec!["agent.send".to_string()];
482 let config = config_with_rules(vec![open_rule, named_rule]);
483 let anonymous = AccessPrincipal::anonymous();
484
485 assert!(
486 evaluate_access(
487 &config,
488 &anonymous,
489 "agent.view",
490 &AccessResource::for_identity("identity:scout-1"),
491 )
492 .is_allow()
493 );
494 assert!(
495 !evaluate_access(
496 &config,
497 &anonymous,
498 "agent.send",
499 &AccessResource::for_identity("identity:scout-1"),
500 )
501 .is_allow()
502 );
503 }
504
505 #[test]
506 fn wildcard_subject_matches_authenticated_but_not_anonymous() {
507 let mut star = allow_rule("any-authenticated");
508 star.subjects = vec!["*".to_string()];
509 star.actions = vec!["agent.view".to_string()];
510 let config = config_with_rules(vec![star]);
511
512 assert!(
514 evaluate_access(
515 &config,
516 &principal("carol@example.test", &[]),
517 "agent.view",
518 &AccessResource::for_identity("identity:scout-1"),
519 )
520 .is_allow()
521 );
522 assert!(
524 !evaluate_access(
525 &config,
526 &AccessPrincipal::anonymous(),
527 "agent.view",
528 &AccessResource::for_identity("identity:scout-1"),
529 )
530 .is_allow()
531 );
532 }
533
534 #[test]
535 fn groups_resolve_from_config() {
536 let config = config_with_rules(vec![]);
537 let groups = groups_for_subject(&config, "alice@example.test");
538 assert!(groups.contains("ops"));
539 assert!(groups_for_subject(&config, "bob@example.test").is_empty());
540 }
541}