Skip to main content

authz_resolver_sdk/pep/
enforcer.rs

1// Updated: 2026-04-14 by Constructor Tech
2//! Policy Enforcement Point (`PEP`) object.
3//!
4//! [`PolicyEnforcer`] encapsulates the full PEP flow:
5//! build evaluation request → call PDP → compile constraints to `AccessScope`.
6//!
7//! Constructed once during service initialisation with the `AuthZ` client.
8//! The resource type is supplied per call via a [`ResourceType`] descriptor,
9//! so a single enforcer can serve all resource types in a service.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use modkit_security::{AccessScope, SecurityContext};
15
16use super::IntoPropertyValue;
17use uuid::Uuid;
18
19use crate::api::AuthZResolverClient;
20use crate::error::AuthZResolverError;
21use crate::models::{
22    Action, BarrierMode, Capability, EvaluationRequest, EvaluationRequestContext, Resource,
23    Subject, TenantContext, TenantMode,
24};
25use crate::pep::compiler::{ConstraintCompileError, compile_to_access_scope};
26
27/// Error from the PEP enforcement flow.
28#[derive(Debug, thiserror::Error)]
29pub enum EnforcerError {
30    /// The PDP explicitly denied access.
31    #[error("access denied by PDP")]
32    Denied {
33        /// Optional deny reason from the PDP.
34        deny_reason: Option<crate::models::DenyReason>,
35    },
36
37    /// The `AuthZ` evaluation RPC failed.
38    #[error("authorization evaluation failed: {0}")]
39    EvaluationFailed(#[from] AuthZResolverError),
40
41    /// Constraint compilation failed (missing or unsupported constraints).
42    #[error("constraint compilation failed: {0}")]
43    CompileFailed(#[from] ConstraintCompileError),
44}
45
46/// Per-request evaluation parameters for advanced authorization scenarios.
47///
48/// Used with [`PolicyEnforcer::access_scope_with()`] when the simple
49/// [`PolicyEnforcer::access_scope()`] defaults don't suffice (ABAC resource
50/// properties, custom tenant mode, barrier bypass, etc.).
51///
52/// All fields default to "not overridden" - only set what you need.
53///
54/// # Examples
55///
56/// ```ignore
57/// use authz_resolver_sdk::pep::{AccessRequest, PolicyEnforcer, ResourceType};
58///
59/// // CREATE with target tenant + resource properties (constrained scope)
60/// let scope = enforcer.access_scope_with(
61///     &ctx, &RESOURCE, "create", None,
62///     &AccessRequest::new()
63///         .context_tenant_id(target_tenant_id)
64///         .tenant_mode(TenantMode::RootOnly)
65///         .resource_property(pep_properties::OWNER_TENANT_ID, target_tenant_id),
66/// ).await?;
67///
68/// // Billing - ignore barriers (constrained scope)
69/// let scope = enforcer.access_scope_with(
70///     &ctx, &RESOURCE, "list", None,
71///     &AccessRequest::new().barrier_mode(BarrierMode::Ignore),
72/// ).await?;
73/// ```
74#[derive(Debug, Clone, Default)]
75pub struct AccessRequest {
76    resource_properties: HashMap<String, serde_json::Value>,
77    tenant_context: Option<TenantContext>,
78    require_constraints: Option<bool>,
79}
80
81impl AccessRequest {
82    /// Create a new empty access request (all defaults).
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Add a single resource property for ABAC evaluation.
89    #[must_use]
90    pub fn resource_property(
91        mut self,
92        key: impl Into<String>,
93        value: impl IntoPropertyValue,
94    ) -> Self {
95        self.resource_properties
96            .insert(key.into(), value.into_filter_value());
97        self
98    }
99
100    /// Set all resource properties at once (replaces any previously set).
101    #[must_use]
102    pub fn resource_properties(mut self, props: HashMap<String, serde_json::Value>) -> Self {
103        self.resource_properties = props;
104        self
105    }
106
107    /// Override the context tenant ID (default: subject's tenant).
108    #[must_use]
109    pub fn context_tenant_id(mut self, id: Uuid) -> Self {
110        self.tenant_context.get_or_insert_default().root_id = Some(id);
111        self
112    }
113
114    /// Override the tenant hierarchy mode (default: `Subtree`).
115    #[must_use]
116    pub fn tenant_mode(mut self, mode: TenantMode) -> Self {
117        self.tenant_context.get_or_insert_default().mode = mode;
118        self
119    }
120
121    /// Override the barrier enforcement mode (default: `Respect`).
122    #[must_use]
123    pub fn barrier_mode(mut self, mode: BarrierMode) -> Self {
124        self.tenant_context.get_or_insert_default().barrier_mode = mode;
125        self
126    }
127
128    /// Set a tenant status filter (e.g., `["active"]`).
129    #[must_use]
130    pub fn tenant_status(mut self, statuses: Vec<String>) -> Self {
131        self.tenant_context.get_or_insert_default().tenant_status = Some(statuses);
132        self
133    }
134
135    /// Set the entire tenant context at once.
136    #[must_use]
137    pub fn tenant_context(mut self, tc: TenantContext) -> Self {
138        self.tenant_context = Some(tc);
139        self
140    }
141
142    /// Override the `require_constraints` flag (default: `true`).
143    ///
144    /// When `false`, the PDP is told that constraints are optional.
145    /// If the PDP returns no constraints, the resulting scope is
146    /// `allow_all()` (no row-level filtering). If the PDP still returns
147    /// constraints, they are compiled normally.
148    ///
149    /// Primary use cases:
150    /// - **GET with prefetch**: if scope is unconstrained, return the
151    ///   prefetched entity directly; otherwise do a scoped re-read.
152    /// - **CREATE**: if scope is unconstrained, skip insert validation;
153    ///   otherwise validate the insert against the scope.
154    #[must_use]
155    pub fn require_constraints(mut self, require: bool) -> Self {
156        self.require_constraints = Some(require);
157        self
158    }
159}
160
161/// Static descriptor for a resource type and its supported constraint properties.
162///
163/// Passed per call to [`PolicyEnforcer`] methods so a single enforcer can
164/// serve multiple resource types within one service.
165#[derive(Debug, Clone, Copy)]
166pub struct ResourceType {
167    /// Dotted resource type name (e.g. `"gts.x.core.users.user.v1~"`).
168    pub name: &'static str,
169    /// Properties the PEP can compile from PDP constraints.
170    pub supported_properties: &'static [&'static str],
171}
172
173/// Policy Enforcement Point.
174///
175/// Holds the `AuthZ` client and optional PEP capabilities.
176/// Constructed once during service init; cloneable and cheap to pass
177/// around (`Arc` inside). The resource type is supplied per call via
178/// [`ResourceType`].
179///
180/// # Example
181///
182/// ```ignore
183/// use authz_resolver_sdk::pep::{PolicyEnforcer, ResourceType};
184/// use modkit_security::pep_properties;
185///
186/// const USER: ResourceType = ResourceType {
187///     name: "gts.x.core.users.user.v1~",
188///     supported_properties: &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID],
189/// };
190///
191/// let enforcer = PolicyEnforcer::new(authz.clone());
192///
193/// // All CRUD operations return AccessScope (PDP always returns constraints)
194/// let scope = enforcer.access_scope(&ctx, &USER, "get", Some(id)).await?;
195/// let scope = enforcer.access_scope(&ctx, &USER, "create", None).await?;
196/// ```
197#[derive(Clone)]
198pub struct PolicyEnforcer {
199    authz: Arc<dyn AuthZResolverClient>,
200    capabilities: Vec<Capability>,
201}
202
203impl PolicyEnforcer {
204    /// Create a new enforcer.
205    pub fn new(authz: Arc<dyn AuthZResolverClient>) -> Self {
206        Self {
207            authz,
208            capabilities: Vec::new(),
209        }
210    }
211
212    /// Set PEP capabilities advertised to the PDP.
213    #[must_use]
214    pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
215        self.capabilities = capabilities;
216        self
217    }
218
219    // ── Low-level: build request only ────────────────────────────────
220
221    /// Build an evaluation request using the subject's tenant as context tenant
222    /// and default settings.
223    #[must_use]
224    pub fn build_request(
225        &self,
226        ctx: &SecurityContext,
227        resource: &ResourceType,
228        action: &str,
229        resource_id: Option<Uuid>,
230        require_constraints: bool,
231    ) -> EvaluationRequest {
232        self.build_request_with(
233            ctx,
234            resource,
235            action,
236            resource_id,
237            require_constraints,
238            &AccessRequest::default(),
239        )
240    }
241
242    /// Build an evaluation request with per-request overrides from [`AccessRequest`].
243    #[must_use]
244    pub fn build_request_with(
245        &self,
246        ctx: &SecurityContext,
247        resource: &ResourceType,
248        action: &str,
249        resource_id: Option<Uuid>,
250        require_constraints: bool,
251        request: &AccessRequest,
252    ) -> EvaluationRequest {
253        // Pass through the caller's tenant context as-is.
254        // If no context_tenant_id was set, the PDP determines it by its own rules
255        // (e.g. falling back to subject.properties["tenant_id"]).
256        let tenant_context = request.tenant_context.clone();
257
258        // Put subject's tenant_id into properties per AuthZEN spec
259        let mut subject_properties = HashMap::new();
260        subject_properties.insert(
261            "tenant_id".to_owned(),
262            serde_json::Value::String(ctx.subject_tenant_id().to_string()),
263        );
264
265        let bearer_token = ctx.bearer_token().cloned();
266
267        EvaluationRequest {
268            subject: Subject {
269                id: ctx.subject_id(),
270                subject_type: ctx.subject_type().map(ToOwned::to_owned),
271                properties: subject_properties,
272            },
273            action: Action {
274                name: action.to_owned(),
275            },
276            resource: Resource {
277                resource_type: resource.name.to_owned(),
278                id: resource_id,
279                properties: request.resource_properties.clone(),
280            },
281            context: EvaluationRequestContext {
282                tenant_context,
283                token_scopes: ctx.token_scopes().to_vec(),
284                require_constraints,
285                capabilities: self.capabilities.clone(),
286                supported_properties: resource
287                    .supported_properties
288                    .iter()
289                    .map(|s| (*s).to_owned())
290                    .collect(),
291                bearer_token,
292            },
293        }
294    }
295
296    // ── High-level: full PEP flow (all CRUD operations) ─────────────
297
298    /// Execute the full PEP flow with constraints: build request → evaluate
299    /// → compile constraints to `AccessScope`.
300    ///
301    /// Always sets `require_constraints=true`. PDP returns constraints for
302    /// all CRUD operations (GET, LIST, UPDATE, DELETE, CREATE).
303    ///
304    /// # Errors
305    ///
306    /// - [`EnforcerError::EvaluationFailed`] if the PDP call fails
307    /// - [`EnforcerError::CompileFailed`] if constraint compilation fails (denied, missing, etc.)
308    pub async fn access_scope(
309        &self,
310        ctx: &SecurityContext,
311        resource: &ResourceType,
312        action: &str,
313        resource_id: Option<Uuid>,
314    ) -> Result<AccessScope, EnforcerError> {
315        self.access_scope_with(
316            ctx,
317            resource,
318            action,
319            resource_id,
320            &AccessRequest::default(),
321        )
322        .await
323    }
324
325    /// Execute the full PEP flow with constraints and per-request overrides.
326    ///
327    /// Uses `require_constraints` from [`AccessRequest`] (default: `true`).
328    /// When `false`, the PDP may return no constraints; the resulting scope
329    /// is `allow_all()`. When `true`, empty constraints trigger a compile error.
330    ///
331    /// # Errors
332    ///
333    /// - [`EnforcerError::EvaluationFailed`] if the PDP call fails
334    /// - [`EnforcerError::CompileFailed`] if constraint compilation fails (denied, missing, etc.)
335    pub async fn access_scope_with(
336        &self,
337        ctx: &SecurityContext,
338        resource: &ResourceType,
339        action: &str,
340        resource_id: Option<Uuid>,
341        request: &AccessRequest,
342    ) -> Result<AccessScope, EnforcerError> {
343        let require = request.require_constraints.unwrap_or(true);
344        let eval_request =
345            self.build_request_with(ctx, resource, action, resource_id, require, request);
346        let response = self.authz.evaluate(eval_request).await?;
347
348        // Check decision first: if denied, return error immediately
349        // without attempting constraint compilation.
350        if !response.decision {
351            return Err(EnforcerError::Denied {
352                deny_reason: response.context.deny_reason,
353            });
354        }
355
356        Ok(compile_to_access_scope(
357            &response,
358            require,
359            resource.supported_properties,
360        )?)
361    }
362}
363
364impl std::fmt::Debug for PolicyEnforcer {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.debug_struct("PolicyEnforcer")
367            .field("capabilities", &self.capabilities)
368            .finish_non_exhaustive()
369    }
370}
371
372#[cfg(test)]
373#[path = "enforcer_tests.rs"]
374mod enforcer_tests;