Skip to main content

authz_resolver_sdk/pep/
enforcer.rs

1//! Policy Enforcement Point (`PEP`) object.
2//!
3//! [`PolicyEnforcer`] encapsulates the full PEP flow:
4//! build evaluation request → call PDP → compile constraints to `AccessScope`.
5//!
6//! Constructed once during service initialisation with the `AuthZ` client.
7//! The resource type is supplied per call via a [`ResourceType`] descriptor,
8//! so a single enforcer can serve all resource types in a service.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use modkit_security::{AccessScope, SecurityContext};
14
15use super::IntoPropertyValue;
16use uuid::Uuid;
17
18use crate::api::AuthZResolverClient;
19use crate::error::AuthZResolverError;
20use crate::models::{
21    Action, BarrierMode, Capability, EvaluationRequest, EvaluationRequestContext, Resource,
22    Subject, TenantContext, TenantMode,
23};
24use crate::pep::compiler::{ConstraintCompileError, compile_to_access_scope};
25
26/// Error from the PEP enforcement flow.
27#[derive(Debug, thiserror::Error)]
28pub enum EnforcerError {
29    /// The PDP explicitly denied access.
30    #[error("access denied by PDP")]
31    Denied {
32        /// Optional deny reason from the PDP.
33        deny_reason: Option<crate::models::DenyReason>,
34    },
35
36    /// The `AuthZ` evaluation RPC failed.
37    #[error("authorization evaluation failed: {0}")]
38    EvaluationFailed(#[from] AuthZResolverError),
39
40    /// Constraint compilation failed (missing or unsupported constraints).
41    #[error("constraint compilation failed: {0}")]
42    CompileFailed(#[from] ConstraintCompileError),
43}
44
45/// Per-request evaluation parameters for advanced authorization scenarios.
46///
47/// Used with [`PolicyEnforcer::access_scope_with()`] when the simple
48/// [`PolicyEnforcer::access_scope()`] defaults don't suffice (ABAC resource
49/// properties, custom tenant mode, barrier bypass, etc.).
50///
51/// All fields default to "not overridden" — only set what you need.
52///
53/// # Examples
54///
55/// ```ignore
56/// use authz_resolver_sdk::pep::{AccessRequest, PolicyEnforcer, ResourceType};
57///
58/// // CREATE with target tenant + resource properties (constrained scope)
59/// let scope = enforcer.access_scope_with(
60///     &ctx, &RESOURCE, "create", None,
61///     &AccessRequest::new()
62///         .context_tenant_id(target_tenant_id)
63///         .tenant_mode(TenantMode::RootOnly)
64///         .resource_property(pep_properties::OWNER_TENANT_ID, target_tenant_id),
65/// ).await?;
66///
67/// // Billing — ignore barriers (constrained scope)
68/// let scope = enforcer.access_scope_with(
69///     &ctx, &RESOURCE, "list", None,
70///     &AccessRequest::new().barrier_mode(BarrierMode::Ignore),
71/// ).await?;
72/// ```
73#[derive(Debug, Clone, Default)]
74pub struct AccessRequest {
75    resource_properties: HashMap<String, serde_json::Value>,
76    tenant_context: Option<TenantContext>,
77    require_constraints: Option<bool>,
78}
79
80impl AccessRequest {
81    /// Create a new empty access request (all defaults).
82    #[must_use]
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Add a single resource property for ABAC evaluation.
88    #[must_use]
89    pub fn resource_property(
90        mut self,
91        key: impl Into<String>,
92        value: impl IntoPropertyValue,
93    ) -> Self {
94        self.resource_properties
95            .insert(key.into(), value.into_filter_value());
96        self
97    }
98
99    /// Set all resource properties at once (replaces any previously set).
100    #[must_use]
101    pub fn resource_properties(mut self, props: HashMap<String, serde_json::Value>) -> Self {
102        self.resource_properties = props;
103        self
104    }
105
106    /// Override the context tenant ID (default: subject's tenant).
107    #[must_use]
108    pub fn context_tenant_id(mut self, id: Uuid) -> Self {
109        self.tenant_context.get_or_insert_default().root_id = Some(id);
110        self
111    }
112
113    /// Override the tenant hierarchy mode (default: `Subtree`).
114    #[must_use]
115    pub fn tenant_mode(mut self, mode: TenantMode) -> Self {
116        self.tenant_context.get_or_insert_default().mode = mode;
117        self
118    }
119
120    /// Override the barrier enforcement mode (default: `Respect`).
121    #[must_use]
122    pub fn barrier_mode(mut self, mode: BarrierMode) -> Self {
123        self.tenant_context.get_or_insert_default().barrier_mode = mode;
124        self
125    }
126
127    /// Set a tenant status filter (e.g., `["active"]`).
128    #[must_use]
129    pub fn tenant_status(mut self, statuses: Vec<String>) -> Self {
130        self.tenant_context.get_or_insert_default().tenant_status = Some(statuses);
131        self
132    }
133
134    /// Set the entire tenant context at once.
135    #[must_use]
136    pub fn tenant_context(mut self, tc: TenantContext) -> Self {
137        self.tenant_context = Some(tc);
138        self
139    }
140
141    /// Override the `require_constraints` flag (default: `true`).
142    ///
143    /// When `false`, the PDP is told that constraints are optional.
144    /// If the PDP returns no constraints, the resulting scope is
145    /// `allow_all()` (no row-level filtering). If the PDP still returns
146    /// constraints, they are compiled normally.
147    ///
148    /// Primary use cases:
149    /// - **GET with prefetch**: if scope is unconstrained, return the
150    ///   prefetched entity directly; otherwise do a scoped re-read.
151    /// - **CREATE**: if scope is unconstrained, skip insert validation;
152    ///   otherwise validate the insert against the scope.
153    #[must_use]
154    pub fn require_constraints(mut self, require: bool) -> Self {
155        self.require_constraints = Some(require);
156        self
157    }
158}
159
160/// Static descriptor for a resource type and its supported constraint properties.
161///
162/// Passed per call to [`PolicyEnforcer`] methods so a single enforcer can
163/// serve multiple resource types within one service.
164#[derive(Debug, Clone, Copy)]
165pub struct ResourceType {
166    /// Dotted resource type name (e.g. `"gts.x.core.users.user.v1~"`).
167    pub name: &'static str,
168    /// Properties the PEP can compile from PDP constraints.
169    pub supported_properties: &'static [&'static str],
170}
171
172/// Policy Enforcement Point.
173///
174/// Holds the `AuthZ` client and optional PEP capabilities.
175/// Constructed once during service init; cloneable and cheap to pass
176/// around (`Arc` inside). The resource type is supplied per call via
177/// [`ResourceType`].
178///
179/// # Example
180///
181/// ```ignore
182/// use authz_resolver_sdk::pep::{PolicyEnforcer, ResourceType};
183/// use modkit_security::pep_properties;
184///
185/// const USER: ResourceType = ResourceType {
186///     name: "gts.x.core.users.user.v1~",
187///     supported_properties: &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID],
188/// };
189///
190/// let enforcer = PolicyEnforcer::new(authz.clone());
191///
192/// // All CRUD operations return AccessScope (PDP always returns constraints)
193/// let scope = enforcer.access_scope(&ctx, &USER, "get", Some(id)).await?;
194/// let scope = enforcer.access_scope(&ctx, &USER, "create", None).await?;
195/// ```
196#[derive(Clone)]
197pub struct PolicyEnforcer {
198    authz: Arc<dyn AuthZResolverClient>,
199    capabilities: Vec<Capability>,
200}
201
202impl PolicyEnforcer {
203    /// Create a new enforcer.
204    pub fn new(authz: Arc<dyn AuthZResolverClient>) -> Self {
205        Self {
206            authz,
207            capabilities: Vec::new(),
208        }
209    }
210
211    /// Set PEP capabilities advertised to the PDP.
212    #[must_use]
213    pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
214        self.capabilities = capabilities;
215        self
216    }
217
218    // ── Low-level: build request only ────────────────────────────────
219
220    /// Build an evaluation request using the subject's tenant as context tenant
221    /// and default settings.
222    #[must_use]
223    pub fn build_request(
224        &self,
225        ctx: &SecurityContext,
226        resource: &ResourceType,
227        action: &str,
228        resource_id: Option<Uuid>,
229        require_constraints: bool,
230    ) -> EvaluationRequest {
231        self.build_request_with(
232            ctx,
233            resource,
234            action,
235            resource_id,
236            require_constraints,
237            &AccessRequest::default(),
238        )
239    }
240
241    /// Build an evaluation request with per-request overrides from [`AccessRequest`].
242    #[must_use]
243    pub fn build_request_with(
244        &self,
245        ctx: &SecurityContext,
246        resource: &ResourceType,
247        action: &str,
248        resource_id: Option<Uuid>,
249        require_constraints: bool,
250        request: &AccessRequest,
251    ) -> EvaluationRequest {
252        // Pass through the caller's tenant context as-is.
253        // If no context_tenant_id was set, the PDP determines it by its own rules.
254        let tenant_context = request.tenant_context.clone();
255
256        // Put subject's tenant_id into properties per AuthZEN spec
257        let mut subject_properties = HashMap::new();
258        subject_properties.insert(
259            "tenant_id".to_owned(),
260            serde_json::Value::String(ctx.subject_tenant_id().to_string()),
261        );
262
263        let bearer_token = ctx.bearer_token().cloned();
264
265        EvaluationRequest {
266            subject: Subject {
267                id: ctx.subject_id(),
268                subject_type: ctx.subject_type().map(ToOwned::to_owned),
269                properties: subject_properties,
270            },
271            action: Action {
272                name: action.to_owned(),
273            },
274            resource: Resource {
275                resource_type: resource.name.to_owned(),
276                id: resource_id,
277                properties: request.resource_properties.clone(),
278            },
279            context: EvaluationRequestContext {
280                tenant_context,
281                token_scopes: ctx.token_scopes().to_vec(),
282                require_constraints,
283                capabilities: self.capabilities.clone(),
284                supported_properties: resource
285                    .supported_properties
286                    .iter()
287                    .map(|s| (*s).to_owned())
288                    .collect(),
289                bearer_token,
290            },
291        }
292    }
293
294    // ── High-level: full PEP flow (all CRUD operations) ─────────────
295
296    /// Execute the full PEP flow with constraints: build request → evaluate
297    /// → compile constraints to `AccessScope`.
298    ///
299    /// Always sets `require_constraints=true`. PDP returns constraints for
300    /// all CRUD operations (GET, LIST, UPDATE, DELETE, CREATE).
301    ///
302    /// # Errors
303    ///
304    /// - [`EnforcerError::EvaluationFailed`] if the PDP call fails
305    /// - [`EnforcerError::CompileFailed`] if constraint compilation fails (denied, missing, etc.)
306    pub async fn access_scope(
307        &self,
308        ctx: &SecurityContext,
309        resource: &ResourceType,
310        action: &str,
311        resource_id: Option<Uuid>,
312    ) -> Result<AccessScope, EnforcerError> {
313        self.access_scope_with(
314            ctx,
315            resource,
316            action,
317            resource_id,
318            &AccessRequest::default(),
319        )
320        .await
321    }
322
323    /// Execute the full PEP flow with constraints and per-request overrides.
324    ///
325    /// Uses `require_constraints` from [`AccessRequest`] (default: `true`).
326    /// When `false`, the PDP may return no constraints; the resulting scope
327    /// is `allow_all()`. When `true`, empty constraints trigger a compile error.
328    ///
329    /// # Errors
330    ///
331    /// - [`EnforcerError::EvaluationFailed`] if the PDP call fails
332    /// - [`EnforcerError::CompileFailed`] if constraint compilation fails (denied, missing, etc.)
333    pub async fn access_scope_with(
334        &self,
335        ctx: &SecurityContext,
336        resource: &ResourceType,
337        action: &str,
338        resource_id: Option<Uuid>,
339        request: &AccessRequest,
340    ) -> Result<AccessScope, EnforcerError> {
341        let require = request.require_constraints.unwrap_or(true);
342        let eval_request =
343            self.build_request_with(ctx, resource, action, resource_id, require, request);
344        let response = self.authz.evaluate(eval_request).await?;
345
346        // Check decision first: if denied, return error immediately
347        // without attempting constraint compilation.
348        if !response.decision {
349            return Err(EnforcerError::Denied {
350                deny_reason: response.context.deny_reason,
351            });
352        }
353
354        Ok(compile_to_access_scope(
355            &response,
356            require,
357            resource.supported_properties,
358        )?)
359    }
360}
361
362impl std::fmt::Debug for PolicyEnforcer {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("PolicyEnforcer")
365            .field("capabilities", &self.capabilities)
366            .finish_non_exhaustive()
367    }
368}
369
370#[cfg(test)]
371#[cfg_attr(coverage_nightly, coverage(off))]
372mod tests {
373    use async_trait::async_trait;
374
375    use super::*;
376    use crate::constraints::{Constraint, InPredicate, Predicate};
377    use crate::models::{EvaluationResponse, EvaluationResponseContext};
378    use modkit_security::pep_properties;
379
380    fn uuid(s: &str) -> Uuid {
381        Uuid::parse_str(s).expect("valid test UUID")
382    }
383
384    const TENANT: &str = "11111111-1111-1111-1111-111111111111";
385    const SUBJECT: &str = "22222222-2222-2222-2222-222222222222";
386    const RESOURCE: &str = "33333333-3333-3333-3333-333333333333";
387
388    /// Mock that returns `decision=true` with a tenant constraint from
389    /// the request's `TenantContext.root_id` (always returns constraints,
390    /// regardless of `require_constraints`).
391    struct AllowAllMock;
392
393    #[async_trait]
394    impl AuthZResolverClient for AllowAllMock {
395        async fn evaluate(
396            &self,
397            req: EvaluationRequest,
398        ) -> Result<EvaluationResponse, AuthZResolverError> {
399            let constraints = if let Some(ref tc) = req.context.tenant_context {
400                if let Some(root_id) = tc.root_id {
401                    vec![Constraint {
402                        predicates: vec![Predicate::In(InPredicate::new(
403                            pep_properties::OWNER_TENANT_ID,
404                            [root_id],
405                        ))],
406                    }]
407                } else {
408                    vec![]
409                }
410            } else {
411                vec![]
412            };
413            Ok(EvaluationResponse {
414                decision: true,
415                context: EvaluationResponseContext {
416                    constraints,
417                    ..Default::default()
418                },
419            })
420        }
421    }
422
423    /// Mock that always returns `decision=false` with an optional deny reason.
424    struct DenyMock {
425        deny_reason: Option<crate::models::DenyReason>,
426    }
427
428    impl DenyMock {
429        fn new() -> Self {
430            Self { deny_reason: None }
431        }
432
433        fn with_reason(error_code: &str, details: Option<&str>) -> Self {
434            Self {
435                deny_reason: Some(crate::models::DenyReason {
436                    error_code: error_code.to_owned(),
437                    details: details.map(ToOwned::to_owned),
438                }),
439            }
440        }
441    }
442
443    #[async_trait]
444    impl AuthZResolverClient for DenyMock {
445        async fn evaluate(
446            &self,
447            _req: EvaluationRequest,
448        ) -> Result<EvaluationResponse, AuthZResolverError> {
449            Ok(EvaluationResponse {
450                decision: false,
451                context: EvaluationResponseContext {
452                    deny_reason: self.deny_reason.clone(),
453                    ..Default::default()
454                },
455            })
456        }
457    }
458
459    /// Mock that always returns an RPC error.
460    struct FailMock;
461
462    #[async_trait]
463    impl AuthZResolverClient for FailMock {
464        async fn evaluate(
465            &self,
466            _req: EvaluationRequest,
467        ) -> Result<EvaluationResponse, AuthZResolverError> {
468            Err(AuthZResolverError::Internal("boom".to_owned()))
469        }
470    }
471
472    fn test_ctx() -> SecurityContext {
473        SecurityContext::builder()
474            .subject_id(uuid(SUBJECT))
475            .subject_tenant_id(uuid(TENANT))
476            .build()
477            .unwrap()
478    }
479
480    const TEST_RESOURCE: ResourceType = ResourceType {
481        name: "gts.x.core.users.user.v1~",
482        supported_properties: &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID],
483    };
484
485    fn enforcer(mock: impl AuthZResolverClient + 'static) -> PolicyEnforcer {
486        PolicyEnforcer::new(Arc::new(mock))
487    }
488
489    // ── build_request ────────────────────────────────────────────────
490
491    #[test]
492    fn build_request_populates_fields() {
493        let e = enforcer(AllowAllMock);
494        let ctx = test_ctx();
495        let req = e.build_request(&ctx, &TEST_RESOURCE, "get", Some(uuid(RESOURCE)), true);
496
497        assert_eq!(req.resource.resource_type, "gts.x.core.users.user.v1~");
498        assert_eq!(req.action.name, "get");
499        assert_eq!(req.resource.id, Some(uuid(RESOURCE)));
500        assert!(req.context.require_constraints);
501        // No explicit context_tenant_id → tenant_context is None (PDP decides)
502        assert!(req.context.tenant_context.is_none());
503    }
504
505    #[test]
506    fn build_request_with_overrides_tenant() {
507        let custom_tenant = uuid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
508        let e = enforcer(AllowAllMock);
509        let ctx = test_ctx();
510        let req = e.build_request_with(
511            &ctx,
512            &TEST_RESOURCE,
513            "list",
514            None,
515            false,
516            &AccessRequest::new().context_tenant_id(custom_tenant),
517        );
518
519        assert_eq!(
520            req.context
521                .tenant_context
522                .as_ref()
523                .and_then(|tc| tc.root_id),
524            Some(custom_tenant),
525        );
526        assert!(!req.context.require_constraints);
527    }
528
529    // ── access_scope ─────────────────────────────────────────────────
530
531    #[tokio::test]
532    async fn access_scope_no_explicit_tenant_returns_compile_error() {
533        let e = enforcer(AllowAllMock);
534        let ctx = test_ctx();
535        // No explicit context_tenant_id → mock returns empty constraints
536        // → require_constraints=true → CompileFailed
537        let result = e
538            .access_scope(&ctx, &TEST_RESOURCE, "get", Some(uuid(RESOURCE)))
539            .await;
540
541        assert!(matches!(result, Err(EnforcerError::CompileFailed(_))));
542    }
543
544    #[tokio::test]
545    async fn access_scope_with_explicit_tenant_returns_scope() {
546        let e = enforcer(AllowAllMock);
547        let ctx = test_ctx();
548        let scope = e
549            .access_scope_with(
550                &ctx,
551                &TEST_RESOURCE,
552                "get",
553                Some(uuid(RESOURCE)),
554                &AccessRequest::new().context_tenant_id(uuid(TENANT)),
555            )
556            .await
557            .expect("should succeed");
558
559        assert_eq!(
560            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
561            &[uuid(TENANT)]
562        );
563    }
564
565    #[tokio::test]
566    async fn access_scope_with_for_create() {
567        let e = enforcer(AllowAllMock);
568        let ctx = test_ctx();
569        let scope = e
570            .access_scope_with(
571                &ctx,
572                &TEST_RESOURCE,
573                "create",
574                None,
575                &AccessRequest::new()
576                    .context_tenant_id(uuid(TENANT))
577                    .tenant_mode(TenantMode::RootOnly),
578            )
579            .await
580            .expect("should succeed");
581
582        assert_eq!(
583            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
584            &[uuid(TENANT)]
585        );
586    }
587
588    #[tokio::test]
589    async fn access_scope_denied_returns_denied_error() {
590        let e = enforcer(DenyMock::new());
591        let ctx = test_ctx();
592        let result = e.access_scope(&ctx, &TEST_RESOURCE, "get", None).await;
593
594        assert!(matches!(
595            result,
596            Err(EnforcerError::Denied { deny_reason: None })
597        ));
598    }
599
600    #[tokio::test]
601    async fn access_scope_denied_with_reason() {
602        let e = enforcer(DenyMock::with_reason(
603            "INSUFFICIENT_PERMISSIONS",
604            Some("Missing admin role"),
605        ));
606        let ctx = test_ctx();
607        let result = e.access_scope(&ctx, &TEST_RESOURCE, "get", None).await;
608
609        match result {
610            Err(EnforcerError::Denied { deny_reason }) => {
611                let reason = deny_reason.expect("should have deny_reason");
612                assert_eq!(reason.error_code, "INSUFFICIENT_PERMISSIONS");
613                assert_eq!(reason.details.as_deref(), Some("Missing admin role"));
614            }
615            other => panic!("Expected Denied with reason, got: {other:?}"),
616        }
617    }
618
619    #[tokio::test]
620    async fn access_scope_evaluation_failure() {
621        let e = enforcer(FailMock);
622        let ctx = test_ctx();
623        let result = e.access_scope(&ctx, &TEST_RESOURCE, "get", None).await;
624
625        assert!(matches!(result, Err(EnforcerError::EvaluationFailed(_))));
626    }
627
628    #[tokio::test]
629    async fn access_scope_anonymous_no_tenant_returns_compile_error() {
630        let e = enforcer(AllowAllMock);
631        let ctx = SecurityContext::anonymous();
632        // No explicit tenant context → mock returns empty constraints → CompileFailed
633        let result = e.access_scope(&ctx, &TEST_RESOURCE, "list", None).await;
634
635        assert!(matches!(result, Err(EnforcerError::CompileFailed(_))));
636    }
637
638    // ── builder methods ──────────────────────────────────────────────
639
640    #[test]
641    fn with_capabilities() {
642        let e = enforcer(AllowAllMock).with_capabilities(vec![Capability::TenantHierarchy]);
643
644        assert_eq!(e.capabilities, vec![Capability::TenantHierarchy]);
645    }
646
647    #[test]
648    fn debug_impl() {
649        let e = enforcer(AllowAllMock);
650        let dbg = format!("{e:?}");
651        assert!(dbg.contains("PolicyEnforcer"));
652    }
653
654    // ── AccessRequest builder ─────────────────────────────────────────
655
656    #[test]
657    fn access_request_default_is_empty() {
658        let req = AccessRequest::new();
659        assert!(req.resource_properties.is_empty());
660        assert!(req.tenant_context.is_none());
661    }
662
663    #[test]
664    fn access_request_builder_chain() {
665        let tid = uuid(TENANT);
666        let req = AccessRequest::new()
667            .resource_property(pep_properties::OWNER_TENANT_ID, tid)
668            .context_tenant_id(tid)
669            .tenant_mode(TenantMode::RootOnly)
670            .barrier_mode(BarrierMode::Ignore)
671            .tenant_status(vec!["active".to_owned()]);
672
673        assert_eq!(req.resource_properties.len(), 1);
674        let tc = req.tenant_context.as_ref().expect("tenant context");
675        assert_eq!(tc.root_id, Some(tid));
676        assert_eq!(tc.mode, TenantMode::RootOnly);
677        assert_eq!(tc.barrier_mode, BarrierMode::Ignore);
678        assert_eq!(tc.tenant_status, Some(vec!["active".to_owned()]));
679    }
680
681    #[test]
682    fn access_request_tenant_context_setter() {
683        let tid = uuid(TENANT);
684        let req = AccessRequest::new().tenant_context(TenantContext {
685            mode: TenantMode::RootOnly,
686            root_id: Some(tid),
687            ..Default::default()
688        });
689
690        let tc = req.tenant_context.as_ref().expect("tenant context");
691        assert_eq!(tc.root_id, Some(tid));
692        assert_eq!(tc.mode, TenantMode::RootOnly);
693        assert_eq!(tc.barrier_mode, BarrierMode::Respect);
694    }
695
696    #[test]
697    fn access_request_resource_properties_replaces() {
698        let mut props = HashMap::new();
699        props.insert("a".to_owned(), serde_json::json!("1"));
700        props.insert("b".to_owned(), serde_json::json!("2"));
701
702        let req = AccessRequest::new()
703            .resource_property("old_key", serde_json::json!("old"))
704            .resource_properties(props);
705
706        assert_eq!(req.resource_properties.len(), 2);
707        assert!(!req.resource_properties.contains_key("old_key"));
708    }
709
710    #[test]
711    fn into_property_value_implementations() {
712        let uuid_val = uuid(TENANT);
713        let req = AccessRequest::new()
714            .resource_property("uuid_prop", uuid_val)
715            .resource_property("uuid_ref_prop", uuid_val)
716            .resource_property("string_prop", "test".to_owned())
717            .resource_property("str_prop", "test")
718            .resource_property("int_prop", 42i64)
719            .resource_property("json_prop", serde_json::json!({"key": "value"}));
720
721        assert_eq!(req.resource_properties.len(), 6);
722        assert_eq!(
723            req.resource_properties.get("uuid_prop"),
724            Some(&serde_json::json!(uuid_val.to_string())),
725        );
726        assert_eq!(
727            req.resource_properties.get("string_prop"),
728            Some(&serde_json::json!("test")),
729        );
730        assert_eq!(
731            req.resource_properties.get("int_prop"),
732            Some(&serde_json::json!(42)),
733        );
734    }
735
736    // ── build_request_with ────────────────────────────────────────────
737
738    #[test]
739    fn build_request_with_applies_resource_properties() {
740        let e = enforcer(AllowAllMock);
741        let ctx = test_ctx();
742        let tid = uuid(TENANT);
743        let req = e.build_request_with(
744            &ctx,
745            &TEST_RESOURCE,
746            "create",
747            None,
748            false,
749            &AccessRequest::new().resource_property(pep_properties::OWNER_TENANT_ID, tid),
750        );
751
752        assert_eq!(
753            req.resource.properties.get(pep_properties::OWNER_TENANT_ID),
754            Some(&serde_json::json!(tid.to_string())),
755        );
756    }
757
758    #[test]
759    fn build_request_with_applies_tenant_mode_and_barrier() {
760        let e = enforcer(AllowAllMock);
761        let ctx = test_ctx();
762        let req = e.build_request_with(
763            &ctx,
764            &TEST_RESOURCE,
765            "list",
766            None,
767            true,
768            &AccessRequest::new()
769                .tenant_mode(TenantMode::RootOnly)
770                .barrier_mode(BarrierMode::Ignore)
771                .tenant_status(vec!["active".to_owned()]),
772        );
773
774        let tc = req.context.tenant_context.as_ref().expect("tenant context");
775        assert_eq!(tc.mode, TenantMode::RootOnly);
776        assert_eq!(tc.barrier_mode, BarrierMode::Ignore);
777        assert_eq!(tc.tenant_status, Some(vec!["active".to_owned()]));
778    }
779
780    #[test]
781    fn build_request_with_default_has_no_tenant_context() {
782        let e = enforcer(AllowAllMock);
783        let ctx = test_ctx();
784        let req = e.build_request_with(
785            &ctx,
786            &TEST_RESOURCE,
787            "get",
788            None,
789            true,
790            &AccessRequest::default(),
791        );
792
793        // No explicit context_tenant_id → tenant_context is None (PDP decides)
794        assert!(req.context.tenant_context.is_none());
795    }
796
797    // ── access_scope_with ─────────────────────────────────────────────
798
799    #[tokio::test]
800    async fn access_scope_with_custom_tenant() {
801        let custom_tenant = uuid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
802        let e = enforcer(AllowAllMock);
803        let ctx = test_ctx();
804        let scope = e
805            .access_scope_with(
806                &ctx,
807                &TEST_RESOURCE,
808                "list",
809                None,
810                &AccessRequest::new().context_tenant_id(custom_tenant),
811            )
812            .await
813            .expect("should succeed");
814
815        assert_eq!(
816            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
817            &[custom_tenant]
818        );
819    }
820
821    #[tokio::test]
822    async fn access_scope_with_resource_properties() {
823        let e = enforcer(AllowAllMock);
824        let ctx = test_ctx();
825        let scope = e
826            .access_scope_with(
827                &ctx,
828                &TEST_RESOURCE,
829                "get",
830                None,
831                &AccessRequest::new()
832                    .resource_property(
833                        pep_properties::OWNER_TENANT_ID,
834                        serde_json::json!(uuid(TENANT).to_string()),
835                    )
836                    .context_tenant_id(uuid(TENANT))
837                    .tenant_mode(TenantMode::RootOnly),
838            )
839            .await
840            .expect("should succeed");
841
842        assert_eq!(
843            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
844            &[uuid(TENANT)]
845        );
846    }
847
848    // ── request builder internals ────────────────────────────────────
849
850    #[test]
851    fn builds_request_with_all_fields() {
852        const USERS_RESOURCE: ResourceType = ResourceType {
853            name: "gts.x.core.users.user.v1~",
854            supported_properties: &[pep_properties::OWNER_TENANT_ID],
855        };
856
857        let context_tenant_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
858        let subject_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap();
859        let subject_tenant_id = Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap();
860        let resource_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap();
861
862        let ctx = SecurityContext::builder()
863            .subject_id(subject_id)
864            .subject_tenant_id(subject_tenant_id)
865            .subject_type("user")
866            .token_scopes(vec!["admin".to_owned()])
867            .bearer_token("test-token".to_owned())
868            .build()
869            .unwrap();
870
871        let e = PolicyEnforcer::new(Arc::new(AllowAllMock))
872            .with_capabilities(vec![Capability::TenantHierarchy]);
873
874        let access_req = AccessRequest::new().tenant_context(TenantContext {
875            root_id: Some(context_tenant_id),
876            ..Default::default()
877        });
878
879        let request = e.build_request_with(
880            &ctx,
881            &USERS_RESOURCE,
882            "get",
883            Some(resource_id),
884            true,
885            &access_req,
886        );
887
888        assert_eq!(request.subject.id, subject_id);
889        assert_eq!(
890            request.subject.properties.get("tenant_id").unwrap(),
891            &serde_json::Value::String(subject_tenant_id.to_string())
892        );
893        assert_eq!(request.subject.subject_type.as_deref(), Some("user"));
894        assert_eq!(request.action.name, "get");
895        assert_eq!(request.resource.resource_type, "gts.x.core.users.user.v1~");
896        assert_eq!(request.resource.id, Some(resource_id));
897        assert!(request.context.require_constraints);
898        assert_eq!(
899            request.context.tenant_context.as_ref().unwrap().root_id,
900            Some(context_tenant_id)
901        );
902        assert_eq!(request.context.token_scopes, vec!["admin"]);
903        assert_eq!(
904            request.context.capabilities,
905            vec![Capability::TenantHierarchy]
906        );
907        assert!(request.context.bearer_token.is_some());
908        assert_eq!(
909            request.context.supported_properties,
910            vec![pep_properties::OWNER_TENANT_ID]
911        );
912    }
913
914    #[test]
915    fn builds_request_without_tenant_context() {
916        let ctx = SecurityContext::anonymous();
917
918        let e = enforcer(AllowAllMock);
919
920        let request = e.build_request_with(
921            &ctx,
922            &TEST_RESOURCE,
923            "create",
924            None,
925            false,
926            &AccessRequest::default(),
927        );
928
929        assert!(request.context.tenant_context.is_none());
930        assert!(!request.context.require_constraints);
931        assert_eq!(request.resource.id, None);
932        assert!(request.context.capabilities.is_empty());
933        assert!(request.context.bearer_token.is_none());
934    }
935
936    #[test]
937    fn applies_resource_properties() {
938        let tenant_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
939        let ctx = SecurityContext::builder()
940            .subject_id(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap())
941            .subject_tenant_id(tenant_id)
942            .build()
943            .unwrap();
944
945        let e = enforcer(AllowAllMock);
946        let access_req = AccessRequest::new()
947            .resource_property(
948                pep_properties::OWNER_TENANT_ID,
949                serde_json::Value::String(tenant_id.to_string()),
950            )
951            .context_tenant_id(tenant_id);
952
953        let request =
954            e.build_request_with(&ctx, &TEST_RESOURCE, "create", None, false, &access_req);
955
956        assert_eq!(
957            request
958                .resource
959                .properties
960                .get(pep_properties::OWNER_TENANT_ID),
961            Some(&serde_json::Value::String(tenant_id.to_string())),
962        );
963    }
964
965    #[test]
966    fn applies_tenant_mode_and_barrier_mode() {
967        let tenant_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
968        let ctx = SecurityContext::builder()
969            .subject_id(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap())
970            .subject_tenant_id(tenant_id)
971            .build()
972            .unwrap();
973
974        let e = enforcer(AllowAllMock);
975        let access_req = AccessRequest::new().tenant_context(TenantContext {
976            mode: TenantMode::RootOnly,
977            root_id: Some(tenant_id),
978            barrier_mode: BarrierMode::Ignore,
979            tenant_status: Some(vec!["active".to_owned()]),
980        });
981
982        let request = e.build_request_with(&ctx, &TEST_RESOURCE, "list", None, true, &access_req);
983
984        let tc = request.context.tenant_context.as_ref().unwrap();
985        assert_eq!(tc.mode, TenantMode::RootOnly);
986        assert_eq!(tc.barrier_mode, BarrierMode::Ignore);
987        assert_eq!(tc.tenant_status, Some(vec!["active".to_owned()]));
988    }
989
990    #[test]
991    fn no_implicit_fallback_to_subject_tenant_id() {
992        let subject_tenant = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
993        let ctx = SecurityContext::builder()
994            .subject_id(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap())
995            .subject_tenant_id(subject_tenant)
996            .build()
997            .unwrap();
998
999        let e = enforcer(AllowAllMock);
1000
1001        // No tenant_context provided — PDP decides, no implicit fallback
1002        let request = e.build_request_with(
1003            &ctx,
1004            &TEST_RESOURCE,
1005            "list",
1006            None,
1007            true,
1008            &AccessRequest::default(),
1009        );
1010
1011        assert!(request.context.tenant_context.is_none());
1012    }
1013
1014    #[test]
1015    fn explicit_root_id_overrides_subject_tenant() {
1016        let subject_tenant = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
1017        let explicit_tenant = Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap();
1018        let ctx = SecurityContext::builder()
1019            .subject_id(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap())
1020            .subject_tenant_id(subject_tenant)
1021            .build()
1022            .unwrap();
1023
1024        let e = enforcer(AllowAllMock);
1025        let access_req = AccessRequest::new().context_tenant_id(explicit_tenant);
1026
1027        let request = e.build_request_with(&ctx, &TEST_RESOURCE, "get", None, true, &access_req);
1028
1029        let tc = request.context.tenant_context.as_ref().unwrap();
1030        assert_eq!(tc.root_id, Some(explicit_tenant));
1031    }
1032}