Skip to main content

tr_authz_plugin/domain/
service.rs

1// Created: 2026-04-23 by Constructor Tech
2// Updated: 2026-04-29 by Constructor Tech
3
4//! Service implementation for the TR `AuthZ` resolver plugin.
5//!
6//! Implements the 8 access-check rules (R1–R8) from the tenant-based access
7//! algorithm: each evaluation maps to exactly one rule, depending on three
8//! axes read from the request:
9//!
10//! - **single-resource vs list** — decided by `resource.id`.
11//!   `Some(_)` → single (GET/UPDATE/DELETE); `None` → list/create.
12//! - **explicit target tenant** — `context.tenant_context.root_id`
13//!   (`Some` / `None`).
14//! - **scope mode** — `context.tenant_context.mode`
15//!   (`RootOnly` / `Subtree`; default `Subtree`).
16//!
17//! See `docs/arch/authorization/AUTHZ_USAGE_SCENARIOS.md` for the full matrix
18//! and per-rule HTTP examples.
19
20use 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/// TR-based `AuthZ` resolver service.
36///
37/// Resolves tenant hierarchy via `TenantResolverClient`.
38#[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    /// Evaluate an authorization request.
49    ///
50    /// Branches the request into one of 8 rules (R1–R8) by
51    /// `resource.id.is_some()` × `tenant_context.root_id.is_some()` × `mode`.
52    /// On any failed access check, resolver call failure, or missing required
53    /// field — returns `deny` (fail-closed).
54    #[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        // Subject tenant is required in every rule (R3/R4/R7/R8 use it directly;
63        // R1/R2/R5/R6 use it inside `is_in_subtree`).
64        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        // Parse caller-supplied tenant_status filter once, up-front. Any
80        // unknown status string fails closed (deny) — silently dropping it
81        // would widen the visible-subtree set in R6/R8.
82        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        // tr-authz is a trusted plugin by design and MUST NOT propagate caller
94        // scope into TR calls: the access-check rules (R1/R2/R5/R6) walk from
95        // `subject` toward `root_id`, which routinely lies outside the caller's
96        // visibility — a scope-limited view would hide legitimate ancestors and
97        // yield wrong allow/deny decisions. The plugin must keep its full-tree
98        // visibility either way.
99        //
100        // TODO(https://github.com/cyberfabric/cyberfabric-core/issues/1597):
101        // once the platform S2S authentication subsystem and the gRPC + mTLS
102        // transport land, replace `SecurityContext::anonymous()` here with the
103        // S2S-issued service context identifying this caller as
104        // `tr-authz-plugin`. Anonymous is safe today (in-process trust boundary
105        // between modkit modules) but unsafe over a network boundary — there
106        // is no cryptographic identity on the wire.
107        let ctx = SecurityContext::anonymous();
108
109        let mut response = if request.resource.id.is_some() {
110            // Single-resource: owner_tenant_id is mandatory (PEP must prefetch it).
111            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        // Group predicates are orthogonal to tenant scoping — append only on
135        // allow. If the group property is present but any of its UUIDs are
136        // malformed, the group predicate cannot be compiled; fail-closed to
137        // avoid silently widening scope to tenant-wide access.
138        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    // ── Single-resource branches (R1–R4) ──────────────────────────────────
149
150    #[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                // R1: GET /tasks/{id}?tenant=t2&tenant_mode=root_only
163                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                // R2: GET /tasks/{id}?tenant=t2
176                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                // R3: GET /tasks/{id}?tenant_mode=root_only
189                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                // R4: GET /tasks/{id}
198                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    // ── List / CREATE branches (R5–R8) ────────────────────────────────────
209
210    #[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                // R5: GET /tasks?tenant=t2&tenant_mode=root_only
223                // Subject must be (reflexive) ancestor of root_id.
224                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                // R6: GET /tasks?tenant=t2
233                // Subject must be (reflexive) ancestor of root_id.
234                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                // R7: GET /tasks?tenant_mode=root_only
258                debug!(rule = "R7", %subject, "tr-authz: allow");
259                Self::allow_eq(subject)
260            }
261            (None, TenantMode::Subtree) => {
262                // R8: GET /tasks
263                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    // ── TR helpers ────────────────────────────────────────────────────────
285
286    /// Reflexive "candidate is in the closed subtree rooted at `anchor`".
287    /// Returns `false` on any TR error (fail-closed).
288    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    /// Resolve the closed subtree (root + descendants) as UUIDs.
317    ///
318    /// `tenant_statuses` filters descendants by status (empty = all). Per the
319    /// TR SDK contract (`GetDescendantsOptions::status`), the filter does not
320    /// apply to the starting tenant itself.
321    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    // ── Response builders ────────────────────────────────────────────────
354
355    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    // ── Group predicates (orthogonal to tenant) ──────────────────────────
387
388    /// Returns `Err(())` when a group scoping property is present but cannot
389    /// be parsed as a full `Vec<Uuid>` (e.g. not an array, or contains a
390    /// non-UUID string). Caller maps that to `deny` (fail-closed). Missing
391    /// properties and legitimately empty arrays are `Ok(())`.
392    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    // ── Parsing helpers ──────────────────────────────────────────────────
417
418    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    /// Strict array-of-UUID parse: returns `None` when the JSON value is not
429    /// an array, OR when any element is not a valid UUID string. Callers treat
430    /// `None` as a hard error (fail-closed) rather than silently dropping the
431    /// bad entries, which would widen the resulting access scope.
432    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    /// Map caller-supplied `tenant_status` strings to TR SDK `TenantStatus`.
440    /// Returns the first unrecognized value on failure so the caller can
441    /// fail-closed with a diagnostic — silently dropping unknowns would widen
442    /// the status filter to "all statuses" and leak suspended/deleted tenants.
443    ///
444    /// Accepted values match the SDK's `#[serde(rename_all = "snake_case")]`
445    /// representation of `TenantStatus`: `active`, `suspended`, `deleted`.
446    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;