Skip to main content

modkit_security/
access_scope.rs

1use std::fmt;
2use uuid::Uuid;
3
4/// A scalar value for scope filtering.
5///
6/// Used in [`ScopeFilter`] predicates to represent typed values.
7/// JSON conversion happens at the PDP/PEP boundary (see the PEP compiler),
8/// not inside the security model.
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub enum ScopeValue {
11    /// UUID value (tenant IDs, resource IDs, etc.)
12    Uuid(Uuid),
13    /// String value (status, GTS type IDs, etc.)
14    String(String),
15    /// Integer value.
16    Int(i64),
17    /// Boolean value.
18    Bool(bool),
19}
20
21impl ScopeValue {
22    /// Try to extract a UUID from this value.
23    ///
24    /// Returns `Some` for `ScopeValue::Uuid` directly, and for
25    /// `ScopeValue::String` if the string is a valid UUID.
26    #[must_use]
27    pub fn as_uuid(&self) -> Option<Uuid> {
28        match self {
29            Self::Uuid(u) => Some(*u),
30            Self::String(s) => Uuid::parse_str(s).ok(),
31            Self::Int(_) | Self::Bool(_) => None,
32        }
33    }
34}
35
36impl fmt::Display for ScopeValue {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Uuid(u) => write!(f, "{u}"),
40            Self::String(s) => write!(f, "{s}"),
41            Self::Int(n) => write!(f, "{n}"),
42            Self::Bool(b) => write!(f, "{b}"),
43        }
44    }
45}
46
47impl From<Uuid> for ScopeValue {
48    #[inline]
49    fn from(u: Uuid) -> Self {
50        Self::Uuid(u)
51    }
52}
53
54impl From<&Uuid> for ScopeValue {
55    #[inline]
56    fn from(u: &Uuid) -> Self {
57        Self::Uuid(*u)
58    }
59}
60
61impl From<String> for ScopeValue {
62    #[inline]
63    fn from(s: String) -> Self {
64        Self::String(s)
65    }
66}
67
68impl From<&str> for ScopeValue {
69    #[inline]
70    fn from(s: &str) -> Self {
71        Self::String(s.to_owned())
72    }
73}
74
75impl From<i64> for ScopeValue {
76    #[inline]
77    fn from(n: i64) -> Self {
78        Self::Int(n)
79    }
80}
81
82impl From<bool> for ScopeValue {
83    #[inline]
84    fn from(b: bool) -> Self {
85        Self::Bool(b)
86    }
87}
88
89/// Well-known authorization property names.
90///
91/// These constants are shared between the PEP compiler and the ORM condition
92/// builder (`ScopableEntity::resolve_property()`), ensuring a single source of
93/// truth for property names.
94pub mod pep_properties {
95    /// Tenant-ownership property. Typically maps to the `tenant_id` column.
96    pub const OWNER_TENANT_ID: &str = "owner_tenant_id";
97
98    /// Resource identity property. Typically maps to the primary key column.
99    pub const RESOURCE_ID: &str = "id";
100
101    /// Owner (user) identity property. Typically maps to an `owner_id` column.
102    pub const OWNER_ID: &str = "owner_id";
103}
104
105/// Well-known resource-group table and column names for subquery construction.
106///
107/// Used by the `SecureORM` condition builder to translate `InGroup`/`InGroupSubtree`
108/// scope filters into SQL subqueries without depending on entity types.
109///
110/// **Note:** These tables are canonical to the RG module's database.
111/// `resource_group_membership` is not projected to domain services.
112/// `InGroup`/`InGroupSubtree` predicates are only executable within the RG module.
113pub mod rg_tables {
114    /// Membership table (RG-internal, not projected to domain services).
115    pub const MEMBERSHIP_TABLE: &str = "resource_group_membership";
116    /// Column in membership table: the resource's external ID.
117    pub const MEMBERSHIP_RESOURCE_ID: &str = "resource_id";
118    /// Column in membership table: the group the resource belongs to.
119    pub const MEMBERSHIP_GROUP_ID: &str = "group_id";
120
121    /// Closure table for group hierarchy.
122    pub const CLOSURE_TABLE: &str = "resource_group_closure";
123    /// Column in closure table: the ancestor group.
124    pub const CLOSURE_ANCESTOR_ID: &str = "ancestor_id";
125    /// Column in closure table: the descendant group.
126    pub const CLOSURE_DESCENDANT_ID: &str = "descendant_id";
127}
128
129/// A single scope filter — a typed predicate on a named resource property.
130///
131/// The property name (e.g., `"owner_tenant_id"`, `"id"`) is an authorization
132/// concept. Mapping to DB columns is done by `ScopableEntity::resolve_property()`.
133///
134/// Variants mirror the predicate types from the PDP response:
135/// - [`ScopeFilter::Eq`] — equality (`property = value`)
136/// - [`ScopeFilter::In`] — set membership (`property IN (values)`)
137/// - [`ScopeFilter::InGroup`] — group membership subquery
138/// - [`ScopeFilter::InGroupSubtree`] — group subtree subquery
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum ScopeFilter {
141    /// Equality: `property = value`.
142    Eq(EqScopeFilter),
143    /// Set membership: `property IN (values)`.
144    In(InScopeFilter),
145    /// Group membership: `property IN (SELECT resource_id FROM membership WHERE group_id IN (group_ids))`.
146    InGroup(InGroupScopeFilter),
147    /// Group subtree: `property IN (SELECT resource_id FROM membership WHERE group_id IN (SELECT descendant_id FROM closure WHERE ancestor_id IN (ancestor_ids)))`.
148    InGroupSubtree(InGroupSubtreeScopeFilter),
149}
150
151/// Equality scope filter: `property = value`.
152#[derive(Clone, Debug, PartialEq, Eq, Hash)]
153pub struct EqScopeFilter {
154    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
155    property: String,
156    /// The value to match.
157    value: ScopeValue,
158}
159
160/// Set membership scope filter: `property IN (values)`.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct InScopeFilter {
163    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
164    property: String,
165    /// The set of values to match against.
166    values: Vec<ScopeValue>,
167}
168
169impl EqScopeFilter {
170    /// Create an equality scope filter.
171    #[must_use]
172    pub fn new(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
173        Self {
174            property: property.into(),
175            value: value.into(),
176        }
177    }
178
179    /// The authorization property name.
180    #[inline]
181    #[must_use]
182    pub fn property(&self) -> &str {
183        &self.property
184    }
185
186    /// The filter value.
187    #[inline]
188    #[must_use]
189    pub fn value(&self) -> &ScopeValue {
190        &self.value
191    }
192}
193
194impl InScopeFilter {
195    /// Create a set membership scope filter.
196    #[must_use]
197    pub fn new(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
198        Self {
199            property: property.into(),
200            values,
201        }
202    }
203
204    /// Create from an iterator of convertible values.
205    #[must_use]
206    pub fn from_values<V: Into<ScopeValue>>(
207        property: impl Into<String>,
208        values: impl IntoIterator<Item = V>,
209    ) -> Self {
210        Self {
211            property: property.into(),
212            values: values.into_iter().map(Into::into).collect(),
213        }
214    }
215
216    /// The authorization property name.
217    #[inline]
218    #[must_use]
219    pub fn property(&self) -> &str {
220        &self.property
221    }
222
223    /// The filter values.
224    #[inline]
225    #[must_use]
226    pub fn values(&self) -> &[ScopeValue] {
227        &self.values
228    }
229}
230
231/// Group membership scope filter.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct InGroupScopeFilter {
234    property: String,
235    group_ids: Vec<ScopeValue>,
236}
237
238impl InGroupScopeFilter {
239    /// Create a group membership scope filter.
240    #[must_use]
241    pub fn new(property: impl Into<String>, group_ids: Vec<ScopeValue>) -> Self {
242        Self {
243            property: property.into(),
244            group_ids,
245        }
246    }
247
248    /// The authorization property name.
249    #[inline]
250    #[must_use]
251    pub fn property(&self) -> &str {
252        &self.property
253    }
254
255    /// The group IDs.
256    #[inline]
257    #[must_use]
258    pub fn group_ids(&self) -> &[ScopeValue] {
259        &self.group_ids
260    }
261}
262
263/// Group subtree scope filter.
264#[derive(Clone, Debug, PartialEq, Eq)]
265pub struct InGroupSubtreeScopeFilter {
266    property: String,
267    ancestor_ids: Vec<ScopeValue>,
268}
269
270impl InGroupSubtreeScopeFilter {
271    /// Create a group subtree scope filter.
272    #[must_use]
273    pub fn new(property: impl Into<String>, ancestor_ids: Vec<ScopeValue>) -> Self {
274        Self {
275            property: property.into(),
276            ancestor_ids,
277        }
278    }
279
280    /// The authorization property name.
281    #[inline]
282    #[must_use]
283    pub fn property(&self) -> &str {
284        &self.property
285    }
286
287    /// The ancestor group IDs.
288    #[inline]
289    #[must_use]
290    pub fn ancestor_ids(&self) -> &[ScopeValue] {
291        &self.ancestor_ids
292    }
293}
294
295impl ScopeFilter {
296    /// Create an equality filter (`property = value`).
297    #[must_use]
298    pub fn eq(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
299        Self::Eq(EqScopeFilter::new(property, value))
300    }
301
302    /// Create a set membership filter (`property IN (values)`).
303    #[must_use]
304    pub fn r#in(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
305        Self::In(InScopeFilter::new(property, values))
306    }
307
308    /// Create a set membership filter from UUID values (convenience).
309    #[must_use]
310    pub fn in_uuids(property: impl Into<String>, uuids: Vec<Uuid>) -> Self {
311        Self::In(InScopeFilter::new(
312            property,
313            uuids.into_iter().map(ScopeValue::Uuid).collect(),
314        ))
315    }
316
317    /// Create a group membership filter.
318    #[must_use]
319    pub fn in_group(property: impl Into<String>, group_ids: Vec<ScopeValue>) -> Self {
320        Self::InGroup(InGroupScopeFilter::new(property, group_ids))
321    }
322
323    /// Create a group subtree filter.
324    #[must_use]
325    pub fn in_group_subtree(property: impl Into<String>, ancestor_ids: Vec<ScopeValue>) -> Self {
326        Self::InGroupSubtree(InGroupSubtreeScopeFilter::new(property, ancestor_ids))
327    }
328
329    /// The authorization property name.
330    #[must_use]
331    pub fn property(&self) -> &str {
332        match self {
333            Self::Eq(f) => f.property(),
334            Self::In(f) => f.property(),
335            Self::InGroup(f) => f.property(),
336            Self::InGroupSubtree(f) => f.property(),
337        }
338    }
339
340    /// Collect direct-match values as a slice-like view for iteration.
341    ///
342    /// For `Eq`, returns a single-element slice; for `In`, returns the values slice.
343    /// For `InGroup`/`InGroupSubtree`, returns empty — those are subquery parameters,
344    /// not resource property values. The actual matching happens in SQL via
345    /// [`secure::scope_to_condition`].
346    #[must_use]
347    pub fn values(&self) -> ScopeFilterValues<'_> {
348        match self {
349            Self::Eq(f) => ScopeFilterValues::Single(&f.value),
350            Self::In(f) => ScopeFilterValues::Multiple(&f.values),
351            Self::InGroup(_) | Self::InGroupSubtree(_) => ScopeFilterValues::Multiple(&[]),
352        }
353    }
354
355    /// Extract filter values as UUIDs, skipping non-UUID entries.
356    ///
357    /// Useful when the caller knows the property holds UUID values
358    /// (e.g., `owner_tenant_id`, `id`).
359    #[must_use]
360    pub fn uuid_values(&self) -> Vec<Uuid> {
361        self.values()
362            .iter()
363            .filter_map(ScopeValue::as_uuid)
364            .collect()
365    }
366}
367
368/// Iterator adapter for [`ScopeFilter::values()`].
369///
370/// Provides a uniform way to iterate over filter values regardless of
371/// whether the filter is `Eq` (single value) or `In` (multiple values).
372#[derive(Clone, Debug)]
373pub enum ScopeFilterValues<'a> {
374    /// Single value from an `Eq` filter.
375    Single(&'a ScopeValue),
376    /// Multiple values from an `In` filter.
377    Multiple(&'a [ScopeValue]),
378}
379
380impl<'a> ScopeFilterValues<'a> {
381    /// Returns an iterator over the values.
382    #[must_use]
383    pub fn iter(&self) -> ScopeFilterValuesIter<'a> {
384        match self {
385            Self::Single(v) => ScopeFilterValuesIter::Single(Some(v)),
386            Self::Multiple(vs) => ScopeFilterValuesIter::Multiple(vs.iter()),
387        }
388    }
389
390    /// Returns `true` if any value matches the given predicate.
391    #[must_use]
392    pub fn contains(&self, value: &ScopeValue) -> bool {
393        self.iter().any(|v| v == value)
394    }
395}
396
397impl<'a> IntoIterator for ScopeFilterValues<'a> {
398    type Item = &'a ScopeValue;
399    type IntoIter = ScopeFilterValuesIter<'a>;
400
401    fn into_iter(self) -> Self::IntoIter {
402        self.iter()
403    }
404}
405
406impl<'a> IntoIterator for &ScopeFilterValues<'a> {
407    type Item = &'a ScopeValue;
408    type IntoIter = ScopeFilterValuesIter<'a>;
409
410    fn into_iter(self) -> Self::IntoIter {
411        self.iter()
412    }
413}
414
415/// Iterator over [`ScopeFilterValues`].
416pub enum ScopeFilterValuesIter<'a> {
417    /// Yields a single value.
418    Single(Option<&'a ScopeValue>),
419    /// Yields from a slice.
420    Multiple(std::slice::Iter<'a, ScopeValue>),
421}
422
423impl<'a> Iterator for ScopeFilterValuesIter<'a> {
424    type Item = &'a ScopeValue;
425
426    fn next(&mut self) -> Option<Self::Item> {
427        match self {
428            Self::Single(v) => v.take(),
429            Self::Multiple(iter) => iter.next(),
430        }
431    }
432}
433
434/// A conjunction (AND) of scope filters — one access path.
435///
436/// All filters within a constraint must match simultaneously for a row
437/// to be accessible via this path.
438#[derive(Clone, Debug, PartialEq)]
439pub struct ScopeConstraint {
440    filters: Vec<ScopeFilter>,
441}
442
443impl ScopeConstraint {
444    /// Create a new scope constraint from a list of filters.
445    #[must_use]
446    pub fn new(filters: Vec<ScopeFilter>) -> Self {
447        Self { filters }
448    }
449
450    /// The filters in this constraint (AND-ed together).
451    #[inline]
452    #[must_use]
453    pub fn filters(&self) -> &[ScopeFilter] {
454        &self.filters
455    }
456
457    /// Returns `true` if this constraint has no filters.
458    #[inline]
459    #[must_use]
460    pub fn is_empty(&self) -> bool {
461        self.filters.is_empty()
462    }
463}
464
465/// A disjunction (OR) of scope constraints defining what data is accessible.
466///
467/// Each constraint is an independent access path (OR-ed). Filters within a
468/// constraint are AND-ed. An unconstrained scope bypasses row-level filtering.
469///
470/// # Examples
471///
472/// ```
473/// use modkit_security::access_scope::{AccessScope, ScopeConstraint, ScopeFilter, pep_properties};
474/// use uuid::Uuid;
475///
476/// // deny-all (default)
477/// let scope = AccessScope::deny_all();
478/// assert!(scope.is_deny_all());
479///
480/// // single tenant
481/// let tid = Uuid::new_v4();
482/// let scope = AccessScope::for_tenant(tid);
483/// assert!(!scope.is_deny_all());
484/// assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, tid));
485/// ```
486#[derive(Clone, Debug, PartialEq)]
487pub struct AccessScope {
488    constraints: Vec<ScopeConstraint>,
489    unconstrained: bool,
490}
491
492impl Default for AccessScope {
493    /// Default is deny-all: no constraints and not unconstrained.
494    fn default() -> Self {
495        Self::deny_all()
496    }
497}
498
499impl AccessScope {
500    // ── Constructors ────────────────────────────────────────────────
501
502    /// Create an access scope from a list of constraints (OR-ed).
503    #[must_use]
504    pub fn from_constraints(constraints: Vec<ScopeConstraint>) -> Self {
505        Self {
506            constraints,
507            unconstrained: false,
508        }
509    }
510
511    /// Create an access scope with a single constraint.
512    #[must_use]
513    pub fn single(constraint: ScopeConstraint) -> Self {
514        Self::from_constraints(vec![constraint])
515    }
516
517    /// Create an "allow all" (unconstrained) scope.
518    ///
519    /// This represents a legitimate PDP decision with no row-level filtering.
520    /// Not a bypass — it's a valid authorization outcome.
521    #[must_use]
522    pub fn allow_all() -> Self {
523        Self {
524            constraints: Vec::new(),
525            unconstrained: true,
526        }
527    }
528
529    /// Create a "deny all" scope (no access).
530    #[must_use]
531    pub fn deny_all() -> Self {
532        Self {
533            constraints: Vec::new(),
534            unconstrained: false,
535        }
536    }
537
538    // ── Convenience constructors ────────────────────────────────────
539
540    /// Create a scope for a set of tenant IDs.
541    #[must_use]
542    pub fn for_tenants(ids: Vec<Uuid>) -> Self {
543        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
544            pep_properties::OWNER_TENANT_ID,
545            ids,
546        )]))
547    }
548
549    /// Create a scope for a single tenant ID.
550    #[must_use]
551    pub fn for_tenant(id: Uuid) -> Self {
552        Self::for_tenants(vec![id])
553    }
554
555    /// Create a scope for a set of resource IDs.
556    #[must_use]
557    pub fn for_resources(ids: Vec<Uuid>) -> Self {
558        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
559            pep_properties::RESOURCE_ID,
560            ids,
561        )]))
562    }
563
564    /// Create a scope for a single resource ID.
565    #[must_use]
566    pub fn for_resource(id: Uuid) -> Self {
567        Self::for_resources(vec![id])
568    }
569
570    // ── Accessors ───────────────────────────────────────────────────
571
572    /// The constraints in this scope (OR-ed).
573    #[inline]
574    #[must_use]
575    pub fn constraints(&self) -> &[ScopeConstraint] {
576        &self.constraints
577    }
578
579    /// Returns `true` if this scope is unconstrained (allow-all).
580    #[inline]
581    #[must_use]
582    pub fn is_unconstrained(&self) -> bool {
583        self.unconstrained
584    }
585
586    /// Returns `true` if this scope denies all access.
587    ///
588    /// A scope is deny-all when it is not unconstrained and has no constraints.
589    #[must_use]
590    pub fn is_deny_all(&self) -> bool {
591        !self.unconstrained && self.constraints.is_empty()
592    }
593
594    /// Collect all values for a given property across all constraints.
595    #[must_use]
596    pub fn all_values_for(&self, property: &str) -> Vec<&ScopeValue> {
597        let mut result = Vec::new();
598        for constraint in &self.constraints {
599            for filter in constraint.filters() {
600                if filter.property() == property {
601                    result.extend(filter.values());
602                }
603            }
604        }
605        result
606    }
607
608    /// Collect all UUID values for a given property across all constraints.
609    ///
610    /// Convenience wrapper — skips non-UUID values.
611    #[must_use]
612    pub fn all_uuid_values_for(&self, property: &str) -> Vec<Uuid> {
613        let mut result = Vec::new();
614        for constraint in &self.constraints {
615            for filter in constraint.filters() {
616                if filter.property() == property {
617                    result.extend(filter.uuid_values());
618                }
619            }
620        }
621        result
622    }
623
624    /// Check if any constraint has a filter matching the given property and value.
625    #[must_use]
626    pub fn contains_value(&self, property: &str, value: &ScopeValue) -> bool {
627        self.constraints.iter().any(|c| {
628            c.filters()
629                .iter()
630                .any(|f| f.property() == property && f.values().contains(value))
631        })
632    }
633
634    /// Check if any constraint has a filter matching the given property and UUID.
635    ///
636    /// Matches both `ScopeValue::Uuid` and `ScopeValue::String` variants so
637    /// that UUID-as-string values are treated consistently with
638    /// [`AccessScope::all_uuid_values_for`], which also parses strings via
639    /// [`ScopeValue::as_uuid`].
640    #[must_use]
641    pub fn contains_uuid(&self, property: &str, id: Uuid) -> bool {
642        self.constraints.iter().any(|c| {
643            c.filters().iter().any(|f| {
644                f.property() == property && f.values().iter().any(|v| v.as_uuid() == Some(id))
645            })
646        })
647    }
648
649    /// Check if any constraint references the given property.
650    #[must_use]
651    pub fn has_property(&self, property: &str) -> bool {
652        self.constraints
653            .iter()
654            .any(|c| c.filters().iter().any(|f| f.property() == property))
655    }
656
657    /// Create a new scope retaining only `owner_tenant_id` filters.
658    ///
659    /// Useful for entities declared with `no_owner` (e.g., messages, reactions),
660    /// where `owner_id` constraints cannot be resolved and would cause fail-closed
661    /// deny-all behaviour.
662    ///
663    /// - Unconstrained scopes become deny-all (fail-closed).
664    /// - Constraints that contain no `owner_tenant_id` filter are dropped entirely.
665    /// - If all constraints are dropped, the result is deny-all.
666    #[must_use]
667    pub fn tenant_only(&self) -> Self {
668        self.retain_properties(&[pep_properties::OWNER_TENANT_ID])
669    }
670
671    /// Create a new scope retaining only `owner_tenant_id` and `owner_id` filters.
672    ///
673    /// Useful for entities that have both tenant and owner columns but no
674    /// resource-level constraints (e.g., reactions scoped to the acting user).
675    ///
676    /// - Unconstrained scopes become deny-all (fail-closed).
677    /// - Constraints that contain none of the retained properties are dropped.
678    /// - If all constraints are dropped, the result is deny-all.
679    #[must_use]
680    pub fn tenant_and_owner(&self) -> Self {
681        self.retain_properties(&[pep_properties::OWNER_TENANT_ID, pep_properties::OWNER_ID])
682    }
683
684    /// Create a new scope that guarantees an `owner_id` equality filter
685    /// matching exactly the supplied `owner_id` is present in every constraint.
686    ///
687    /// **Intersection semantics**: if a constraint already contains an
688    /// `owner_id` filter, the supplied value must be among its values —
689    /// otherwise the constraint is dropped. When it matches, the filter is
690    /// narrowed to exactly that single value.
691    ///
692    /// - **Unconstrained** → single constraint with only the `owner_id` filter.
693    /// - **Deny-all** → stays deny-all.
694    /// - **No existing owner filter** → `owner_id` is injected.
695    /// - **Existing owner filter containing `owner_id`** → narrowed to `Eq`.
696    /// - **Existing owner filter NOT containing `owner_id`** → constraint dropped
697    ///   (constraints use OR semantics, so dropping one narrows access; dropping
698    ///   all yields deny-all).
699    ///
700    /// Use this as a defence-in-depth measure for user-owned resources when
701    /// the PDP may not always return `owner_id` constraints or may return a
702    /// broader set than the current subject.
703    #[must_use]
704    pub fn ensure_owner(&self, owner_id: Uuid) -> Self {
705        if self.is_deny_all() {
706            return Self::deny_all();
707        }
708
709        let owner_filter = ScopeFilter::eq(pep_properties::OWNER_ID, owner_id);
710
711        if self.unconstrained {
712            return Self::single(ScopeConstraint::new(vec![owner_filter]));
713        }
714
715        let constraints = self
716            .constraints
717            .iter()
718            .filter_map(|c| {
719                let owner_filters: Vec<&ScopeFilter> = c
720                    .filters()
721                    .iter()
722                    .filter(|f| f.property() == pep_properties::OWNER_ID)
723                    .collect();
724
725                if owner_filters.is_empty() {
726                    let mut filters = c.filters().to_vec();
727                    filters.push(owner_filter.clone());
728                    return Some(ScopeConstraint::new(filters));
729                }
730
731                // Intersection semantics: ALL owner_id predicates must contain
732                // the supplied owner_id, otherwise the constraint is dropped.
733                let all_match = owner_filters
734                    .iter()
735                    .all(|f| f.values().iter().any(|v| v.as_uuid() == Some(owner_id)));
736                if !all_match {
737                    return None;
738                }
739
740                // Fast path: single Eq already matches → constraint unchanged.
741                if owner_filters.len() == 1 && matches!(owner_filters[0], ScopeFilter::Eq(_)) {
742                    return Some(c.clone());
743                }
744
745                // Replace all owner_id filters with a single Eq.
746                let mut filters: Vec<ScopeFilter> = c
747                    .filters()
748                    .iter()
749                    .filter(|f| f.property() != pep_properties::OWNER_ID)
750                    .cloned()
751                    .collect();
752                filters.push(owner_filter.clone());
753                Some(ScopeConstraint::new(filters))
754            })
755            .collect();
756
757        Self::from_constraints(constraints)
758    }
759
760    /// Internal helper: build a new scope keeping only filters whose property
761    /// is in the given whitelist.
762    fn retain_properties(&self, properties: &[&str]) -> Self {
763        if self.unconstrained {
764            return Self::deny_all();
765        }
766
767        let constraints = self
768            .constraints
769            .iter()
770            .filter_map(|c| {
771                let kept: Vec<ScopeFilter> = c
772                    .filters()
773                    .iter()
774                    .filter(|f| properties.contains(&f.property()))
775                    .cloned()
776                    .collect();
777
778                if kept.is_empty() {
779                    None
780                } else {
781                    Some(ScopeConstraint::new(kept))
782                }
783            })
784            .collect();
785
786        Self::from_constraints(constraints)
787    }
788}
789
790#[cfg(test)]
791#[cfg_attr(coverage_nightly, coverage(off))]
792mod tests {
793    use super::*;
794    use uuid::Uuid;
795
796    const T1: &str = "11111111-1111-1111-1111-111111111111";
797    const T2: &str = "22222222-2222-2222-2222-222222222222";
798
799    fn uid(s: &str) -> Uuid {
800        Uuid::parse_str(s).unwrap()
801    }
802
803    // --- ScopeFilter::Eq ---
804
805    #[test]
806    fn scope_filter_eq_constructor() {
807        let f = ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1));
808        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
809        assert!(matches!(f, ScopeFilter::Eq(_)));
810        assert!(f.values().contains(&ScopeValue::Uuid(uid(T1))));
811    }
812
813    #[test]
814    fn all_values_for_works_with_eq() {
815        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
816            pep_properties::OWNER_TENANT_ID,
817            uid(T1),
818        )]));
819        assert_eq!(
820            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
821            &[uid(T1)]
822        );
823    }
824
825    #[test]
826    fn all_values_for_works_with_mixed_eq_and_in() {
827        let scope = AccessScope::from_constraints(vec![
828            ScopeConstraint::new(vec![ScopeFilter::eq(
829                pep_properties::OWNER_TENANT_ID,
830                uid(T1),
831            )]),
832            ScopeConstraint::new(vec![ScopeFilter::in_uuids(
833                pep_properties::OWNER_TENANT_ID,
834                vec![uid(T2)],
835            )]),
836        ]);
837        let values = scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID);
838        assert_eq!(values, &[uid(T1), uid(T2)]);
839    }
840
841    #[test]
842    fn contains_value_works_with_eq() {
843        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
844            pep_properties::OWNER_TENANT_ID,
845            uid(T1),
846        )]));
847        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
848        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
849    }
850
851    // --- tenant_only ---
852
853    #[test]
854    fn tenant_only_strips_owner_id() {
855        let scope = AccessScope::single(ScopeConstraint::new(vec![
856            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
857            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
858        ]));
859
860        let tenant_scope = scope.tenant_only();
861        assert!(tenant_scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
862        assert!(!tenant_scope.has_property(pep_properties::OWNER_ID));
863    }
864
865    #[test]
866    fn tenant_only_unconstrained_becomes_deny_all() {
867        let scope = AccessScope::allow_all();
868        let tenant_scope = scope.tenant_only();
869        assert!(tenant_scope.is_deny_all());
870    }
871
872    #[test]
873    fn tenant_only_deny_all_when_no_tenant_filters() {
874        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
875            pep_properties::OWNER_ID,
876            uid(T1),
877        )]));
878
879        let tenant_scope = scope.tenant_only();
880        assert!(tenant_scope.is_deny_all());
881    }
882
883    #[test]
884    fn tenant_only_on_deny_all_stays_deny_all() {
885        let scope = AccessScope::deny_all();
886        let tenant_scope = scope.tenant_only();
887        assert!(tenant_scope.is_deny_all());
888    }
889
890    // --- tenant_and_owner ---
891
892    #[test]
893    fn tenant_and_owner_keeps_both_properties() {
894        let scope = AccessScope::single(ScopeConstraint::new(vec![
895            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
896            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
897            ScopeFilter::eq(pep_properties::RESOURCE_ID, uid(T1)),
898        ]));
899
900        let narrowed = scope.tenant_and_owner();
901        assert!(narrowed.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
902        assert!(narrowed.contains_uuid(pep_properties::OWNER_ID, uid(T2)));
903        assert!(!narrowed.has_property(pep_properties::RESOURCE_ID));
904    }
905
906    #[test]
907    fn tenant_and_owner_unconstrained_becomes_deny_all() {
908        let scope = AccessScope::allow_all();
909        assert!(scope.tenant_and_owner().is_deny_all());
910    }
911
912    #[test]
913    fn tenant_and_owner_deny_all_when_no_matching_filters() {
914        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
915            pep_properties::RESOURCE_ID,
916            uid(T1),
917        )]));
918        assert!(scope.tenant_and_owner().is_deny_all());
919    }
920
921    // --- ensure_owner ---
922
923    #[test]
924    fn ensure_owner_adds_owner_when_missing() {
925        let scope = AccessScope::for_tenant(uid(T1));
926        let owner_id = uid(T2);
927
928        let scoped = scope.ensure_owner(owner_id);
929        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
930        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
931    }
932
933    #[test]
934    fn ensure_owner_keeps_existing_owner() {
935        let existing_owner = uid(T2);
936        let scope = AccessScope::single(ScopeConstraint::new(vec![
937            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
938            ScopeFilter::eq(pep_properties::OWNER_ID, existing_owner),
939        ]));
940
941        let scoped = scope.ensure_owner(existing_owner);
942        assert_eq!(
943            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
944            &[existing_owner]
945        );
946    }
947
948    #[test]
949    fn ensure_owner_on_unconstrained_creates_owner_scope() {
950        let scope = AccessScope::allow_all();
951        let owner_id = uid(T1);
952
953        let scoped = scope.ensure_owner(owner_id);
954        assert!(!scoped.is_unconstrained());
955        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
956    }
957
958    #[test]
959    fn ensure_owner_on_deny_all_stays_deny_all() {
960        let scope = AccessScope::deny_all();
961        let scoped = scope.ensure_owner(uid(T1));
962        assert!(scoped.is_deny_all());
963    }
964
965    #[test]
966    fn ensure_owner_narrows_existing_owner_to_subject() {
967        let user_a = uid(T1);
968        let user_b = uid(T2);
969        let scope = AccessScope::single(ScopeConstraint::new(vec![
970            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
971            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![user_a, user_b]),
972        ]));
973
974        let scoped = scope.ensure_owner(user_a);
975        assert_eq!(
976            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
977            &[user_a],
978            "Must narrow to exactly the subject's owner_id"
979        );
980        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
981    }
982
983    #[test]
984    fn ensure_owner_drops_constraint_when_subject_not_in_pdp() {
985        let user_x = uid(T1);
986        let user_y = uid(T2);
987        let scope = AccessScope::single(ScopeConstraint::new(vec![
988            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
989            ScopeFilter::eq(pep_properties::OWNER_ID, user_x),
990        ]));
991
992        let scoped = scope.ensure_owner(user_y);
993        assert!(
994            scoped.is_deny_all(),
995            "Must be deny-all when subject not in PDP's owner set"
996        );
997    }
998
999    #[test]
1000    fn ensure_owner_checks_all_owner_filters_in_constraint() {
1001        let alice = uid(T1);
1002        let bob = uid(T2);
1003        // Contrived: two owner_id filters in one constraint.
1004        // alice is in the first but not the second → must be dropped.
1005        let scope = AccessScope::single(ScopeConstraint::new(vec![
1006            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![alice, bob]),
1007            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![bob]),
1008        ]));
1009
1010        let scoped = scope.ensure_owner(alice);
1011        assert!(
1012            scoped.is_deny_all(),
1013            "Must deny when subject is missing from any owner_id filter"
1014        );
1015
1016        // bob is in both → should pass and narrow to Eq.
1017        let scoped = scope.ensure_owner(bob);
1018        assert!(!scoped.is_deny_all());
1019        assert_eq!(
1020            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1021            &[bob],
1022            "Must narrow to single Eq for the matching owner"
1023        );
1024    }
1025
1026    #[test]
1027    fn ensure_owner_multi_constraint_keeps_only_matching() {
1028        let alice = uid(T1);
1029        let bob = uid(T2);
1030        let tenant = uid(T1);
1031
1032        // Constraint 1: tenant + alice → matches alice
1033        let c1 = ScopeConstraint::new(vec![
1034            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
1035            ScopeFilter::eq(pep_properties::OWNER_ID, alice),
1036        ]);
1037        // Constraint 2: tenant + bob → does NOT match alice
1038        let c2 = ScopeConstraint::new(vec![
1039            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
1040            ScopeFilter::eq(pep_properties::OWNER_ID, bob),
1041        ]);
1042
1043        let scope = AccessScope::from_constraints(vec![c1, c2]);
1044        let scoped = scope.ensure_owner(alice);
1045
1046        assert!(
1047            !scoped.is_deny_all(),
1048            "Must not be deny-all - one constraint matches"
1049        );
1050        assert_eq!(
1051            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1052            &[alice],
1053            "Must keep only the constraint matching alice"
1054        );
1055        assert!(
1056            scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant),
1057            "Tenant filter must be preserved"
1058        );
1059    }
1060
1061    // --- ScopeFilter::InGroup ---
1062
1063    #[test]
1064    fn scope_filter_in_group_constructor() {
1065        let f = ScopeFilter::in_group(
1066            pep_properties::OWNER_TENANT_ID,
1067            vec![ScopeValue::Uuid(uid(T1))],
1068        );
1069        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
1070        assert!(matches!(f, ScopeFilter::InGroup(_)));
1071        assert_eq!(f.values().iter().count(), 0);
1072    }
1073
1074    // --- ScopeFilter::InGroupSubtree ---
1075
1076    #[test]
1077    fn scope_filter_in_group_subtree_constructor() {
1078        let f = ScopeFilter::in_group_subtree(
1079            pep_properties::OWNER_TENANT_ID,
1080            vec![ScopeValue::Uuid(uid(T1))],
1081        );
1082        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
1083        assert!(matches!(f, ScopeFilter::InGroupSubtree(_)));
1084        assert_eq!(f.values().iter().count(), 0);
1085    }
1086
1087    #[test]
1088    fn in_group_scope_contains_uuid_returns_false() {
1089        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_group(
1090            pep_properties::OWNER_TENANT_ID,
1091            vec![ScopeValue::Uuid(uid(T1))],
1092        )]));
1093        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1094    }
1095
1096    #[test]
1097    fn in_group_subtree_scope_contains_uuid_returns_false() {
1098        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_group_subtree(
1099            pep_properties::OWNER_TENANT_ID,
1100            vec![ScopeValue::Uuid(uid(T1))],
1101        )]));
1102        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1103    }
1104
1105    // --- contains_uuid string matching ---
1106
1107    #[test]
1108    fn contains_uuid_matches_string_variant() {
1109        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1110            pep_properties::OWNER_TENANT_ID,
1111            ScopeValue::String(T1.to_owned()),
1112        )]));
1113        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1114        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
1115    }
1116
1117    #[test]
1118    fn contains_uuid_does_not_match_invalid_string() {
1119        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1120            pep_properties::OWNER_TENANT_ID,
1121            ScopeValue::String("not-a-uuid".to_owned()),
1122        )]));
1123        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1124    }
1125}