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;