1use crate::cache::{Cache, EffectiveGrant, NoCache};
2use crate::decision::{AccessDecision, AccessExplanation, DenyReason};
3use crate::error::{Error, Result};
4use crate::ids::{PrincipalId, RoleId, TenantId};
5use crate::request::{AuthSubject, ScopeQuery, ScopedAccessRequest, TenantAccessRequest};
6use crate::scope::AccessScope;
7use crate::source::{AuthorizationSource, MembershipStatus, TenantStatus};
8use std::collections::HashSet;
9
10#[derive(Debug, Clone, Eq, PartialEq)]
12pub struct EngineConfig {
13 pub enable_role_hierarchy: bool,
15 pub enable_wildcard: bool,
17 pub max_role_depth: usize,
19}
20
21impl Default for EngineConfig {
22 fn default() -> Self {
23 Self {
24 enable_role_hierarchy: false,
25 enable_wildcard: false,
26 max_role_depth: 16,
27 }
28 }
29}
30
31impl EngineConfig {
32 fn signature(&self) -> String {
34 format!(
35 "rh:{};wc:{};depth:{}",
36 u8::from(self.enable_role_hierarchy),
37 u8::from(self.enable_wildcard),
38 self.max_role_depth
39 )
40 }
41}
42
43#[derive(Debug)]
45pub struct Engine<S, C = NoCache> {
46 source: S,
47 cache: C,
48 config: EngineConfig,
49 config_signature: String,
50}
51
52pub struct EngineBuilder<S, C = NoCache> {
54 source: S,
55 cache: C,
56 config: EngineConfig,
57}
58
59impl<S> EngineBuilder<S, NoCache> {
60 pub fn new(source: S) -> Self {
62 Self {
63 source,
64 cache: NoCache,
65 config: EngineConfig::default(),
66 }
67 }
68}
69
70impl<S, C> EngineBuilder<S, C> {
71 pub fn config(mut self, config: EngineConfig) -> Self {
73 self.config = config;
74 self
75 }
76
77 pub fn enable_role_hierarchy(mut self, on: bool) -> Self {
79 self.config.enable_role_hierarchy = on;
80 self
81 }
82
83 pub fn enable_wildcard(mut self, on: bool) -> Self {
85 self.config.enable_wildcard = on;
86 self
87 }
88
89 pub fn max_role_depth(mut self, depth: usize) -> Self {
91 self.config.max_role_depth = depth;
92 self
93 }
94
95 pub fn cache<C2: Cache>(self, cache: C2) -> EngineBuilder<S, C2> {
97 EngineBuilder {
98 source: self.source,
99 cache,
100 config: self.config,
101 }
102 }
103
104 pub fn build(self) -> Engine<S, C> {
106 let config_signature = self.config.signature();
107 Engine {
108 source: self.source,
109 cache: self.cache,
110 config: self.config,
111 config_signature,
112 }
113 }
114}
115
116impl<S, C> Engine<S, C>
117where
118 S: AuthorizationSource,
119 C: Cache,
120{
121 pub fn config(&self) -> &EngineConfig {
123 &self.config
124 }
125
126 pub async fn accessible_scope(&self, query: ScopeQuery) -> Result<AccessScope> {
128 let (scope, _) = self.resolve_scope(query).await?;
129 Ok(scope)
130 }
131
132 pub async fn can_access_scope(&self, request: ScopedAccessRequest) -> Result<AccessDecision> {
134 Ok(self.explain_access_scope(request).await?.decision)
135 }
136
137 pub async fn can_tenant(&self, request: TenantAccessRequest) -> Result<AccessDecision> {
139 Ok(self.explain_tenant(request).await?.decision)
140 }
141
142 pub async fn explain_access_scope(
144 &self,
145 request: ScopedAccessRequest,
146 ) -> Result<AccessExplanation> {
147 let query = ScopeQuery {
148 subject: request.subject,
149 permission: request.permission,
150 };
151 let (scope, reason) = self.resolve_scope(query).await?;
152 let (decision, reason) = match &scope {
153 AccessScope::None => (
154 AccessDecision::Deny,
155 reason.or(Some(DenyReason::PermissionMissing)),
156 ),
157 AccessScope::Tenant { .. } => (AccessDecision::Allow, None),
158 AccessScope::Paths { .. } if scope.allows_path(&request.target) => {
159 (AccessDecision::Allow, None)
160 }
161 AccessScope::Paths { .. } => (AccessDecision::Deny, Some(DenyReason::ScopeDenied)),
162 };
163 Ok(AccessExplanation {
164 decision,
165 reason,
166 scope,
167 })
168 }
169
170 pub async fn explain_tenant(&self, request: TenantAccessRequest) -> Result<AccessExplanation> {
172 let query = ScopeQuery {
173 subject: request.subject,
174 permission: request.permission,
175 };
176 let (scope, reason) = self.resolve_scope(query).await?;
177 let (decision, reason) = match &scope {
178 AccessScope::Tenant { .. } => (AccessDecision::Allow, None),
179 AccessScope::Paths { .. } => {
180 (AccessDecision::Deny, Some(DenyReason::TargetScopeRequired))
181 }
182 AccessScope::None => (
183 AccessDecision::Deny,
184 reason.or(Some(DenyReason::PermissionMissing)),
185 ),
186 };
187 Ok(AccessExplanation {
188 decision,
189 reason,
190 scope,
191 })
192 }
193
194 pub async fn invalidate_principal(&self, tenant: &TenantId, principal: &PrincipalId) {
196 self.cache.invalidate_principal(tenant, principal).await;
197 }
198
199 pub async fn invalidate_role(&self, tenant: &TenantId, role: &RoleId) {
201 self.cache.invalidate_role(tenant, role).await;
202 }
203
204 pub async fn invalidate_tenant(&self, tenant: &TenantId) {
206 self.cache.invalidate_tenant(tenant).await;
207 }
208
209 pub async fn invalidate_all(&self) {
211 self.cache.invalidate_all().await;
212 }
213
214 async fn resolve_scope(&self, query: ScopeQuery) -> Result<(AccessScope, Option<DenyReason>)> {
216 let tenant = query.subject.tenant.clone();
217 if self.source.tenant_status(&tenant).await? != TenantStatus::Active {
218 return Ok((AccessScope::None, Some(DenyReason::TenantInactive)));
219 }
220 if self.source.membership_status(&query.subject).await? != MembershipStatus::Active {
221 return Ok((AccessScope::None, Some(DenyReason::PrincipalInactive)));
222 }
223
224 let grants = self.effective_grants(&query.subject).await?;
225 let matched_scopes = grants
226 .into_iter()
227 .filter(|grant| {
228 grant
229 .permission
230 .matches(&query.permission, self.config.enable_wildcard)
231 })
232 .map(|grant| grant.scope);
233 let scope = AccessScope::merge(tenant, matched_scopes);
234 let reason = match scope {
235 AccessScope::None => Some(DenyReason::PermissionMissing),
236 _ => None,
237 };
238 Ok((scope, reason))
239 }
240
241 async fn effective_grants(&self, subject: &AuthSubject) -> Result<Vec<EffectiveGrant>> {
243 if let Some(grants) = self
244 .cache
245 .get_effective_grants(&subject.tenant, &subject.principal, &self.config_signature)
246 .await
247 {
248 return Ok(grants);
249 }
250
251 let assignments = self.source.role_assignments(subject).await?;
252 let mut grants = Vec::new();
253 for assignment in assignments {
254 let roles = if self.config.enable_role_hierarchy {
255 self.expand_roles(&subject.tenant, assignment.role.clone())
256 .await?
257 } else {
258 vec![assignment.role]
259 };
260
261 for role in roles {
262 let permissions = self.source.role_permissions(&subject.tenant, &role).await?;
263 grants.extend(permissions.into_iter().map(|permission| {
264 EffectiveGrant::new(role.clone(), permission, assignment.scope.clone())
265 }));
266 }
267 }
268
269 self.cache
270 .set_effective_grants(
271 &subject.tenant,
272 &subject.principal,
273 &self.config_signature,
274 grants.clone(),
275 )
276 .await;
277 Ok(grants)
278 }
279
280 async fn expand_roles(&self, tenant: &TenantId, root: RoleId) -> Result<Vec<RoleId>> {
282 let mut visited = HashSet::new();
283 let mut visiting = HashSet::new();
284 let mut output = Vec::new();
285 self.expand_from(tenant, root, &mut visited, &mut visiting, &mut output)
286 .await?;
287 Ok(output)
288 }
289
290 async fn expand_from(
292 &self,
293 tenant: &TenantId,
294 root: RoleId,
295 visited: &mut HashSet<RoleId>,
296 visiting: &mut HashSet<RoleId>,
297 output: &mut Vec<RoleId>,
298 ) -> Result<()> {
299 visiting.insert(root.clone());
300 output.push(root.clone());
301 let parents = self.source.parent_roles(tenant, &root).await?;
302 let mut stack: Vec<(RoleId, usize, std::vec::IntoIter<RoleId>)> =
303 vec![(root, 0, parents.into_iter())];
304
305 while let Some((current, depth, mut iter)) = stack.pop() {
306 if let Some(parent) = iter.next() {
307 stack.push((current.clone(), depth, iter));
308 let next_depth = depth + 1;
309 if next_depth > self.config.max_role_depth {
310 return Err(Error::RoleDepthExceeded {
311 tenant: tenant.clone(),
312 role: parent,
313 max_depth: self.config.max_role_depth,
314 });
315 }
316 if visiting.contains(&parent) {
317 return Err(Error::RoleCycleDetected {
318 tenant: tenant.clone(),
319 role: parent,
320 });
321 }
322 if visited.contains(&parent) {
323 continue;
324 }
325
326 let parents = self.source.parent_roles(tenant, &parent).await?;
327 visiting.insert(parent.clone());
328 output.push(parent.clone());
329 stack.push((parent, next_depth, parents.into_iter()));
330 continue;
331 }
332
333 visiting.remove(¤t);
334 visited.insert(current);
335 }
336
337 Ok(())
338 }
339}
340
341#[cfg(all(test, feature = "memory-store"))]
342mod tests {
343 use super::*;
344 use crate::memory_source::MemorySource;
345 use crate::{GrantScope, MembershipStatus, Permission, ScopePath, TenantStatus};
346 use futures::executor::block_on;
347
348 fn ids() -> (TenantId, PrincipalId, RoleId) {
350 (
351 TenantId::parse("tenant_1").expect("tenant"),
352 PrincipalId::parse("user_1").expect("principal"),
353 RoleId::parse("reader").expect("role"),
354 )
355 }
356
357 fn active_source(scope: GrantScope, permission: &str) -> (MemorySource, AuthSubject) {
359 let (tenant, principal, role) = ids();
360 let source = MemorySource::new();
361 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
362 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
363 source.add_role_assignment(tenant.clone(), principal.clone(), role.clone(), scope);
364 source.add_role_permission(
365 tenant.clone(),
366 role,
367 Permission::parse(permission).expect("permission"),
368 );
369 (source, AuthSubject::new(tenant, principal))
370 }
371
372 #[test]
373 fn accessible_scope_should_return_tenant_for_tenant_grant() {
374 let (source, subject) = active_source(GrantScope::tenant(), "invoice:read");
375 let engine = EngineBuilder::new(source).build();
376 let scope = block_on(engine.accessible_scope(ScopeQuery {
377 subject,
378 permission: Permission::parse("invoice:read").expect("permission"),
379 }))
380 .expect("scope");
381
382 assert!(matches!(scope, AccessScope::Tenant { .. }));
383 }
384
385 #[test]
386 fn can_tenant_should_deny_path_only_grant() {
387 let root = ScopePath::parse("agent/1").expect("scope path");
388 let (source, subject) = active_source(
389 GrantScope::paths(vec![root]).expect("grant scope"),
390 "invoice:read",
391 );
392 let engine = EngineBuilder::new(source).build();
393 let explanation = block_on(engine.explain_tenant(TenantAccessRequest {
394 subject,
395 permission: Permission::parse("invoice:read").expect("permission"),
396 }))
397 .expect("explanation");
398
399 assert_eq!(explanation.decision, AccessDecision::Deny);
400 assert_eq!(explanation.reason, Some(DenyReason::TargetScopeRequired));
401 }
402
403 #[test]
404 fn can_access_scope_should_allow_descendant_path() {
405 let root = ScopePath::parse("agent/1").expect("scope path");
406 let target = ScopePath::parse("agent/1/store/2").expect("scope path");
407 let (source, subject) = active_source(
408 GrantScope::paths(vec![root]).expect("grant scope"),
409 "invoice:read",
410 );
411 let engine = EngineBuilder::new(source).build();
412 let decision = block_on(engine.can_access_scope(ScopedAccessRequest {
413 subject,
414 permission: Permission::parse("invoice:read").expect("permission"),
415 target,
416 }))
417 .expect("decision");
418
419 assert_eq!(decision, AccessDecision::Allow);
420 }
421
422 #[test]
423 fn wildcard_should_require_config_flag() {
424 let (source, subject) = active_source(GrantScope::tenant(), "invoice:*");
425 let strict_engine = EngineBuilder::new(source.clone()).build();
426 let wildcard_engine = EngineBuilder::new(source).enable_wildcard(true).build();
427 let request = TenantAccessRequest {
428 subject,
429 permission: Permission::parse("invoice:read").expect("permission"),
430 };
431
432 let strict = block_on(strict_engine.can_tenant(request.clone())).expect("decision");
433 let wildcard = block_on(wildcard_engine.can_tenant(request)).expect("decision");
434
435 assert_eq!(strict, AccessDecision::Deny);
436 assert_eq!(wildcard, AccessDecision::Allow);
437 }
438
439 #[test]
440 fn role_hierarchy_should_use_assignment_scope() {
441 let (tenant, principal, child) = ids();
442 let parent = RoleId::parse("parent").expect("role");
443 let root = ScopePath::parse("agent/1").expect("scope path");
444 let source = MemorySource::new();
445 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
446 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
447 source.add_role_assignment(
448 tenant.clone(),
449 principal.clone(),
450 child.clone(),
451 GrantScope::paths(vec![root.clone()]).expect("grant scope"),
452 );
453 source.add_parent_role(tenant.clone(), child, parent.clone());
454 source.add_role_permission(
455 tenant.clone(),
456 parent,
457 Permission::parse("invoice:read").expect("permission"),
458 );
459
460 let engine = EngineBuilder::new(source)
461 .enable_role_hierarchy(true)
462 .build();
463 let scope = block_on(engine.accessible_scope(ScopeQuery {
464 subject: AuthSubject::new(tenant, principal),
465 permission: Permission::parse("invoice:read").expect("permission"),
466 }))
467 .expect("scope");
468
469 assert_eq!(
470 scope,
471 AccessScope::Paths {
472 tenant: TenantId::parse("tenant_1").expect("tenant"),
473 roots: vec![root],
474 }
475 );
476 }
477
478 #[test]
479 fn inactive_tenant_should_return_none_with_reason() {
480 let (tenant, principal, _) = ids();
481 let source = MemorySource::new();
482 source.set_tenant_status(tenant.clone(), TenantStatus::Inactive);
483 let engine = EngineBuilder::new(source).build();
484 let explanation = block_on(engine.explain_tenant(TenantAccessRequest {
485 subject: AuthSubject::new(tenant, principal),
486 permission: Permission::parse("invoice:read").expect("permission"),
487 }))
488 .expect("explanation");
489
490 assert_eq!(explanation.scope, AccessScope::None);
491 assert_eq!(explanation.reason, Some(DenyReason::TenantInactive));
492 }
493
494 #[test]
495 fn inactive_membership_should_return_none_with_reason() {
496 let (tenant, principal, role) = ids();
497 let source = MemorySource::new();
498 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
499 source.set_membership_status(
500 tenant.clone(),
501 principal.clone(),
502 MembershipStatus::Inactive,
503 );
504 source.add_role_assignment(
505 tenant.clone(),
506 principal.clone(),
507 role.clone(),
508 GrantScope::tenant(),
509 );
510 source.add_role_permission(
511 tenant.clone(),
512 role,
513 Permission::parse("invoice:read").expect("permission"),
514 );
515 let engine = EngineBuilder::new(source).build();
516
517 let explanation = block_on(engine.explain_tenant(TenantAccessRequest {
518 subject: AuthSubject::new(tenant, principal),
519 permission: Permission::parse("invoice:read").expect("permission"),
520 }))
521 .expect("explanation");
522
523 assert_eq!(explanation.scope, AccessScope::None);
524 assert_eq!(explanation.reason, Some(DenyReason::PrincipalInactive));
525 }
526
527 #[test]
528 fn no_role_assignment_should_return_none() {
529 let (tenant, principal, _) = ids();
530 let source = MemorySource::new();
531 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
532 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
533 let engine = EngineBuilder::new(source).build();
534
535 let explanation = block_on(engine.explain_tenant(TenantAccessRequest {
536 subject: AuthSubject::new(tenant, principal),
537 permission: Permission::parse("invoice:read").expect("permission"),
538 }))
539 .expect("explanation");
540
541 assert_eq!(explanation.scope, AccessScope::None);
542 assert_eq!(explanation.reason, Some(DenyReason::PermissionMissing));
543 }
544
545 #[test]
546 fn permission_missing_should_deny() {
547 let (source, subject) = active_source(GrantScope::tenant(), "invoice:read");
548 let engine = EngineBuilder::new(source).build();
549
550 let explanation = block_on(engine.explain_tenant(TenantAccessRequest {
551 subject,
552 permission: Permission::parse("invoice:update").expect("permission"),
553 }))
554 .expect("explanation");
555
556 assert_eq!(explanation.decision, AccessDecision::Deny);
557 assert_eq!(explanation.reason, Some(DenyReason::PermissionMissing));
558 }
559
560 #[test]
561 fn can_access_scope_should_deny_outside_roots() {
562 let root = ScopePath::parse("agent/1").expect("scope path");
563 let target = ScopePath::parse("agent/2/store/9").expect("scope path");
564 let (source, subject) = active_source(
565 GrantScope::paths(vec![root]).expect("grant scope"),
566 "invoice:read",
567 );
568 let engine = EngineBuilder::new(source).build();
569
570 let explanation = block_on(engine.explain_access_scope(ScopedAccessRequest {
571 subject,
572 permission: Permission::parse("invoice:read").expect("permission"),
573 target,
574 }))
575 .expect("explanation");
576
577 assert_eq!(explanation.decision, AccessDecision::Deny);
578 assert_eq!(explanation.reason, Some(DenyReason::ScopeDenied));
579 }
580
581 #[test]
582 fn can_tenant_should_allow_tenant_grant() {
583 let (source, subject) = active_source(GrantScope::tenant(), "invoice:read");
584 let engine = EngineBuilder::new(source).build();
585
586 let decision = block_on(engine.can_tenant(TenantAccessRequest {
587 subject,
588 permission: Permission::parse("invoice:read").expect("permission"),
589 }))
590 .expect("decision");
591
592 assert_eq!(decision, AccessDecision::Allow);
593 }
594
595 #[test]
596 fn multiple_path_assignments_should_merge_roots() {
597 let (tenant, principal, first_role) = ids();
598 let second_role = RoleId::parse("reader_2").expect("role");
599 let source = MemorySource::new();
600 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
601 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
602 source.add_role_assignment(
603 tenant.clone(),
604 principal.clone(),
605 first_role.clone(),
606 GrantScope::paths(vec![
607 ScopePath::parse("agent/1/store/1").expect("scope path"),
608 ScopePath::parse("agent/2").expect("scope path"),
609 ])
610 .expect("grant scope"),
611 );
612 source.add_role_assignment(
613 tenant.clone(),
614 principal.clone(),
615 second_role.clone(),
616 GrantScope::paths(vec![ScopePath::parse("agent/1").expect("scope path")])
617 .expect("grant scope"),
618 );
619 let permission = Permission::parse("invoice:read").expect("permission");
620 source.add_role_permission(tenant.clone(), first_role, permission.clone());
621 source.add_role_permission(tenant.clone(), second_role, permission.clone());
622 let engine = EngineBuilder::new(source).build();
623
624 let scope = block_on(engine.accessible_scope(ScopeQuery {
625 subject: AuthSubject::new(tenant.clone(), principal),
626 permission,
627 }))
628 .expect("scope");
629
630 assert_eq!(
631 scope,
632 AccessScope::Paths {
633 tenant,
634 roots: vec![
635 ScopePath::parse("agent/1").expect("scope path"),
636 ScopePath::parse("agent/2").expect("scope path"),
637 ],
638 }
639 );
640 }
641
642 #[test]
643 fn role_cycle_should_return_error() {
644 let (tenant, principal, child) = ids();
645 let parent = RoleId::parse("parent").expect("role");
646 let source = MemorySource::new();
647 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
648 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
649 source.add_role_assignment(
650 tenant.clone(),
651 principal.clone(),
652 child.clone(),
653 GrantScope::tenant(),
654 );
655 source.add_parent_role(tenant.clone(), child.clone(), parent.clone());
656 source.add_parent_role(tenant.clone(), parent, child.clone());
657 let engine = EngineBuilder::new(source)
658 .enable_role_hierarchy(true)
659 .build();
660
661 let err = block_on(engine.accessible_scope(ScopeQuery {
662 subject: AuthSubject::new(tenant.clone(), principal),
663 permission: Permission::parse("invoice:read").expect("permission"),
664 }))
665 .expect_err("must detect cycle");
666
667 assert!(matches!(
668 err,
669 Error::RoleCycleDetected { tenant: ref err_tenant, role }
670 if err_tenant == &tenant && role == child
671 ));
672 }
673
674 #[test]
675 fn role_depth_exceeded_should_return_error() {
676 let (tenant, principal, child) = ids();
677 let parent = RoleId::parse("parent").expect("role");
678 let grandparent = RoleId::parse("grandparent").expect("role");
679 let source = MemorySource::new();
680 source.set_tenant_status(tenant.clone(), TenantStatus::Active);
681 source.set_membership_status(tenant.clone(), principal.clone(), MembershipStatus::Active);
682 source.add_role_assignment(
683 tenant.clone(),
684 principal.clone(),
685 child,
686 GrantScope::tenant(),
687 );
688 source.add_parent_role(
689 tenant.clone(),
690 RoleId::parse("reader").expect("role"),
691 parent.clone(),
692 );
693 source.add_parent_role(tenant.clone(), parent, grandparent.clone());
694 let engine = EngineBuilder::new(source)
695 .enable_role_hierarchy(true)
696 .max_role_depth(1)
697 .build();
698
699 let err = block_on(engine.accessible_scope(ScopeQuery {
700 subject: AuthSubject::new(tenant.clone(), principal),
701 permission: Permission::parse("invoice:read").expect("permission"),
702 }))
703 .expect_err("must enforce max depth");
704
705 assert!(matches!(
706 err,
707 Error::RoleDepthExceeded { tenant: ref err_tenant, role, max_depth: 1 }
708 if err_tenant == &tenant && role == grandparent
709 ));
710 }
711}