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