1use std::sync::Arc;
21
22use authz_resolver_sdk::{
23 BarrierMode as AuthzBarrierMode, Constraint, EvaluationRequest, EvaluationResponse,
24 EvaluationResponseContext, InGroupPredicate, InGroupSubtreePredicate, InPredicate, Predicate,
25 TenantMode,
26};
27use modkit_security::{SecurityContext, pep_properties};
28use tenant_resolver_sdk::{
29 BarrierMode, GetDescendantsOptions, IsAncestorOptions, TenantId, TenantResolverClient,
30 TenantResolverError, TenantStatus,
31};
32use tracing::{debug, info, warn};
33use uuid::Uuid;
34
35#[modkit_macros::domain_model]
39pub struct Service {
40 tr: Arc<dyn TenantResolverClient>,
41}
42
43impl Service {
44 pub fn new(tr: Arc<dyn TenantResolverClient>) -> Self {
45 Self { tr }
46 }
47
48 #[allow(clippy::cognitive_complexity)]
55 pub async fn evaluate(&self, request: &EvaluationRequest) -> EvaluationResponse {
56 info!(
57 action = %request.action.name,
58 resource_type = %request.resource.resource_type,
59 "tr-authz: evaluate called"
60 );
61
62 let Some(subject_tid) = Self::read_uuid(&request.subject.properties, "tenant_id") else {
65 warn!("tr-authz: subject tenant_id missing or unparseable -- deny");
66 return Self::deny();
67 };
68 if subject_tid == Uuid::nil() {
69 warn!("tr-authz: subject tenant_id is nil -- deny");
70 return Self::deny();
71 }
72
73 let tc = request.context.tenant_context.as_ref();
74 let root_id = tc.and_then(|t| t.root_id);
75 let mode = tc.map(|t| t.mode.clone()).unwrap_or_default();
76 let barrier_mode =
77 Self::tr_barrier_mode(tc.map_or(AuthzBarrierMode::default(), |t| t.barrier_mode));
78
79 let tenant_statuses = match tc.and_then(|t| t.tenant_status.as_deref()) {
83 None => Vec::new(),
84 Some(strs) => match Self::parse_tenant_statuses(strs) {
85 Ok(v) => v,
86 Err(bad) => {
87 warn!(%bad, "tr-authz: unknown tenant_status value -- deny");
88 return Self::deny();
89 }
90 },
91 };
92
93 let ctx = SecurityContext::anonymous();
108
109 let mut response = if request.resource.id.is_some() {
110 let Some(owner_tid) = Self::read_uuid(
112 &request.resource.properties,
113 pep_properties::OWNER_TENANT_ID,
114 ) else {
115 warn!(
116 "tr-authz: single-resource request missing owner_tenant_id in properties -- deny"
117 );
118 return Self::deny();
119 };
120 self.evaluate_single(&ctx, subject_tid, owner_tid, root_id, &mode, barrier_mode)
121 .await
122 } else {
123 self.evaluate_list(
124 &ctx,
125 subject_tid,
126 root_id,
127 &mode,
128 barrier_mode,
129 &tenant_statuses,
130 )
131 .await
132 };
133
134 if response.decision
139 && Self::append_group_predicates(&mut response, &request.resource.properties).is_err()
140 {
141 warn!("tr-authz: malformed group scoping properties -- deny");
142 return Self::deny();
143 }
144
145 response
146 }
147
148 #[allow(clippy::cognitive_complexity)]
151 async fn evaluate_single(
152 &self,
153 ctx: &SecurityContext,
154 subject: Uuid,
155 owner: Uuid,
156 root_id: Option<Uuid>,
157 mode: &TenantMode,
158 barrier_mode: BarrierMode,
159 ) -> EvaluationResponse {
160 match (root_id, mode) {
161 (Some(root), TenantMode::RootOnly) => {
162 if owner != root {
164 warn!(%owner, %root, "R1: owner_tenant_id != root_id -- deny");
165 return Self::deny();
166 }
167 if !self.is_in_subtree(ctx, subject, root, barrier_mode).await {
168 warn!(%subject, %root, "R1: subject is not an ancestor of root_id -- deny");
169 return Self::deny();
170 }
171 debug!(rule = "R1", %owner, "tr-authz: allow");
172 Self::allow_eq(owner)
173 }
174 (Some(root), TenantMode::Subtree) => {
175 if !self.is_in_subtree(ctx, root, owner, barrier_mode).await {
177 warn!(%owner, %root, "R2: owner is not in root_id subtree -- deny");
178 return Self::deny();
179 }
180 if !self.is_in_subtree(ctx, subject, root, barrier_mode).await {
181 warn!(%subject, %root, "R2: subject is not an ancestor of root_id -- deny");
182 return Self::deny();
183 }
184 debug!(rule = "R2", %owner, "tr-authz: allow");
185 Self::allow_eq(owner)
186 }
187 (None, TenantMode::RootOnly) => {
188 if owner != subject {
190 warn!(%owner, %subject, "R3: owner_tenant_id != subject tenant -- deny");
191 return Self::deny();
192 }
193 debug!(rule = "R3", %owner, "tr-authz: allow");
194 Self::allow_eq(owner)
195 }
196 (None, TenantMode::Subtree) => {
197 if !self.is_in_subtree(ctx, subject, owner, barrier_mode).await {
199 warn!(%owner, %subject, "R4: owner is not in subject subtree -- deny");
200 return Self::deny();
201 }
202 debug!(rule = "R4", %owner, "tr-authz: allow");
203 Self::allow_eq(owner)
204 }
205 }
206 }
207
208 #[allow(clippy::cognitive_complexity)]
211 async fn evaluate_list(
212 &self,
213 ctx: &SecurityContext,
214 subject: Uuid,
215 root_id: Option<Uuid>,
216 mode: &TenantMode,
217 barrier_mode: BarrierMode,
218 tenant_statuses: &[TenantStatus],
219 ) -> EvaluationResponse {
220 match (root_id, mode) {
221 (Some(root), TenantMode::RootOnly) => {
222 if !self.is_in_subtree(ctx, subject, root, barrier_mode).await {
225 warn!(%subject, %root, "R5: subject is not an ancestor of root_id -- deny");
226 return Self::deny();
227 }
228 debug!(rule = "R5", %root, "tr-authz: allow");
229 Self::allow_eq(root)
230 }
231 (Some(root), TenantMode::Subtree) => {
232 if !self.is_in_subtree(ctx, subject, root, barrier_mode).await {
235 warn!(%subject, %root, "R6: subject is not an ancestor of root_id -- deny");
236 return Self::deny();
237 }
238 match self
239 .resolve_subtree(ctx, root, barrier_mode, tenant_statuses)
240 .await
241 {
242 Ok(ids) if !ids.is_empty() => {
243 debug!(rule = "R6", %root, visible = ids.len(), "tr-authz: allow");
244 Self::allow_in(ids)
245 }
246 Ok(_) => {
247 warn!(%root, "R6: empty descendants -- deny");
248 Self::deny()
249 }
250 Err(e) => {
251 warn!(error = %e, %root, "R6: TR failure -- deny");
252 Self::deny()
253 }
254 }
255 }
256 (None, TenantMode::RootOnly) => {
257 debug!(rule = "R7", %subject, "tr-authz: allow");
259 Self::allow_eq(subject)
260 }
261 (None, TenantMode::Subtree) => {
262 match self
264 .resolve_subtree(ctx, subject, barrier_mode, tenant_statuses)
265 .await
266 {
267 Ok(ids) if !ids.is_empty() => {
268 debug!(rule = "R8", %subject, visible = ids.len(), "tr-authz: allow");
269 Self::allow_in(ids)
270 }
271 Ok(_) => {
272 warn!(%subject, "R8: empty descendants -- deny");
273 Self::deny()
274 }
275 Err(e) => {
276 warn!(error = %e, %subject, "R8: TR failure -- deny");
277 Self::deny()
278 }
279 }
280 }
281 }
282 }
283
284 async fn is_in_subtree(
289 &self,
290 ctx: &SecurityContext,
291 anchor: Uuid,
292 candidate: Uuid,
293 barrier_mode: BarrierMode,
294 ) -> bool {
295 if anchor == candidate {
296 return true;
297 }
298 match self
299 .tr
300 .is_ancestor(
301 ctx,
302 TenantId(anchor),
303 TenantId(candidate),
304 &IsAncestorOptions { barrier_mode },
305 )
306 .await
307 {
308 Ok(v) => v,
309 Err(e) => {
310 warn!(error = %e, %anchor, %candidate, "is_ancestor failed -- treat as false");
311 false
312 }
313 }
314 }
315
316 async fn resolve_subtree(
322 &self,
323 ctx: &SecurityContext,
324 tenant_id: Uuid,
325 barrier_mode: BarrierMode,
326 tenant_statuses: &[TenantStatus],
327 ) -> Result<Vec<Uuid>, String> {
328 let response = self
329 .tr
330 .get_descendants(
331 ctx,
332 TenantId(tenant_id),
333 &GetDescendantsOptions {
334 status: tenant_statuses.to_vec(),
335 barrier_mode,
336 max_depth: None,
337 },
338 )
339 .await
340 .map_err(|e| match e {
341 TenantResolverError::TenantNotFound { .. } => {
342 format!("Tenant {tenant_id} not found")
343 }
344 other => format!("TR error: {other}"),
345 })?;
346
347 let mut visible = Vec::with_capacity(response.descendants.len() + 1);
348 visible.push(response.tenant.id.0);
349 visible.extend(response.descendants.iter().map(|t| t.id.0));
350 Ok(visible)
351 }
352
353 fn allow_eq(tenant_id: Uuid) -> EvaluationResponse {
356 Self::allow(vec![Predicate::In(InPredicate::new(
357 pep_properties::OWNER_TENANT_ID,
358 [tenant_id],
359 ))])
360 }
361
362 fn allow_in(tenant_ids: Vec<Uuid>) -> EvaluationResponse {
363 Self::allow(vec![Predicate::In(InPredicate::new(
364 pep_properties::OWNER_TENANT_ID,
365 tenant_ids,
366 ))])
367 }
368
369 fn allow(predicates: Vec<Predicate>) -> EvaluationResponse {
370 EvaluationResponse {
371 decision: true,
372 context: EvaluationResponseContext {
373 constraints: vec![Constraint { predicates }],
374 ..Default::default()
375 },
376 }
377 }
378
379 fn deny() -> EvaluationResponse {
380 EvaluationResponse {
381 decision: false,
382 context: EvaluationResponseContext::default(),
383 }
384 }
385
386 fn append_group_predicates(
393 response: &mut EvaluationResponse,
394 props: &std::collections::HashMap<String, serde_json::Value>,
395 ) -> Result<(), ()> {
396 let Some(Constraint { predicates }) = response.context.constraints.get_mut(0) else {
397 return Ok(());
398 };
399 if let Some(group_ids) = props.get("group_ids") {
400 let ids = Self::parse_uuid_array(group_ids).ok_or(())?;
401 if !ids.is_empty() {
402 predicates.push(Predicate::InGroup(InGroupPredicate::new("id", ids)));
403 }
404 }
405 if let Some(ancestor_ids) = props.get("ancestor_group_ids") {
406 let ids = Self::parse_uuid_array(ancestor_ids).ok_or(())?;
407 if !ids.is_empty() {
408 predicates.push(Predicate::InGroupSubtree(InGroupSubtreePredicate::new(
409 "id", ids,
410 )));
411 }
412 }
413 Ok(())
414 }
415
416 fn read_uuid(
419 props: &std::collections::HashMap<String, serde_json::Value>,
420 key: &str,
421 ) -> Option<Uuid> {
422 props
423 .get(key)
424 .and_then(|v| v.as_str())
425 .and_then(|s| Uuid::parse_str(s).ok())
426 }
427
428 fn parse_uuid_array(value: &serde_json::Value) -> Option<Vec<Uuid>> {
433 let arr = value.as_array()?;
434 arr.iter()
435 .map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok()))
436 .collect()
437 }
438
439 fn parse_tenant_statuses(statuses: &[String]) -> Result<Vec<TenantStatus>, String> {
447 statuses
448 .iter()
449 .map(|s| match s.as_str() {
450 "active" => Ok(TenantStatus::Active),
451 "suspended" => Ok(TenantStatus::Suspended),
452 "deleted" => Ok(TenantStatus::Deleted),
453 other => Err(other.to_owned()),
454 })
455 .collect()
456 }
457
458 fn tr_barrier_mode(mode: AuthzBarrierMode) -> BarrierMode {
459 match mode {
460 AuthzBarrierMode::Respect => BarrierMode::Respect,
461 AuthzBarrierMode::Ignore => BarrierMode::Ignore,
462 }
463 }
464}
465
466#[cfg(test)]
467#[path = "service_tests.rs"]
468mod service_tests;