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/// A single scope filter — a typed predicate on a named resource property.
106///
107/// The property name (e.g., `"owner_tenant_id"`, `"id"`) is an authorization
108/// concept. Mapping to DB columns is done by `ScopableEntity::resolve_property()`.
109///
110/// Variants mirror the predicate types from the PDP response:
111/// - [`ScopeFilter::Eq`] — equality (`property = value`)
112/// - [`ScopeFilter::In`] — set membership (`property IN (values)`)
113///
114/// ## Future extensions
115///
116/// Additional filter types (`in_tenant_subtree`, `in_group`,
117/// `in_group_subtree`) are planned. See the authorization design document
118/// (`docs/arch/authorization/DESIGN.md`) for the full predicate taxonomy.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum ScopeFilter {
121    /// Equality: `property = value`.
122    Eq(EqScopeFilter),
123    /// Set membership: `property IN (values)`.
124    In(InScopeFilter),
125}
126
127/// Equality scope filter: `property = value`.
128#[derive(Clone, Debug, PartialEq, Eq, Hash)]
129pub struct EqScopeFilter {
130    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
131    property: String,
132    /// The value to match.
133    value: ScopeValue,
134}
135
136/// Set membership scope filter: `property IN (values)`.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct InScopeFilter {
139    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
140    property: String,
141    /// The set of values to match against.
142    values: Vec<ScopeValue>,
143}
144
145impl EqScopeFilter {
146    /// Create an equality scope filter.
147    #[must_use]
148    pub fn new(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
149        Self {
150            property: property.into(),
151            value: value.into(),
152        }
153    }
154
155    /// The authorization property name.
156    #[inline]
157    #[must_use]
158    pub fn property(&self) -> &str {
159        &self.property
160    }
161
162    /// The filter value.
163    #[inline]
164    #[must_use]
165    pub fn value(&self) -> &ScopeValue {
166        &self.value
167    }
168}
169
170impl InScopeFilter {
171    /// Create a set membership scope filter.
172    #[must_use]
173    pub fn new(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
174        Self {
175            property: property.into(),
176            values,
177        }
178    }
179
180    /// Create from an iterator of convertible values.
181    #[must_use]
182    pub fn from_values<V: Into<ScopeValue>>(
183        property: impl Into<String>,
184        values: impl IntoIterator<Item = V>,
185    ) -> Self {
186        Self {
187            property: property.into(),
188            values: values.into_iter().map(Into::into).collect(),
189        }
190    }
191
192    /// The authorization property name.
193    #[inline]
194    #[must_use]
195    pub fn property(&self) -> &str {
196        &self.property
197    }
198
199    /// The filter values.
200    #[inline]
201    #[must_use]
202    pub fn values(&self) -> &[ScopeValue] {
203        &self.values
204    }
205}
206
207impl ScopeFilter {
208    /// Create an equality filter (`property = value`).
209    #[must_use]
210    pub fn eq(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
211        Self::Eq(EqScopeFilter::new(property, value))
212    }
213
214    /// Create a set membership filter (`property IN (values)`).
215    #[must_use]
216    pub fn r#in(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
217        Self::In(InScopeFilter::new(property, values))
218    }
219
220    /// Create a set membership filter from UUID values (convenience).
221    #[must_use]
222    pub fn in_uuids(property: impl Into<String>, uuids: Vec<Uuid>) -> Self {
223        Self::In(InScopeFilter::new(
224            property,
225            uuids.into_iter().map(ScopeValue::Uuid).collect(),
226        ))
227    }
228
229    /// The authorization property name.
230    #[must_use]
231    pub fn property(&self) -> &str {
232        match self {
233            Self::Eq(f) => f.property(),
234            Self::In(f) => f.property(),
235        }
236    }
237
238    /// Collect all values as a slice-like view for iteration.
239    ///
240    /// For `Eq`, returns a single-element slice; for `In`, returns the values slice.
241    #[must_use]
242    pub fn values(&self) -> ScopeFilterValues<'_> {
243        match self {
244            Self::Eq(f) => ScopeFilterValues::Single(&f.value),
245            Self::In(f) => ScopeFilterValues::Multiple(&f.values),
246        }
247    }
248
249    /// Extract filter values as UUIDs, skipping non-UUID entries.
250    ///
251    /// Useful when the caller knows the property holds UUID values
252    /// (e.g., `owner_tenant_id`, `id`).
253    #[must_use]
254    pub fn uuid_values(&self) -> Vec<Uuid> {
255        self.values()
256            .iter()
257            .filter_map(ScopeValue::as_uuid)
258            .collect()
259    }
260}
261
262/// Iterator adapter for [`ScopeFilter::values()`].
263///
264/// Provides a uniform way to iterate over filter values regardless of
265/// whether the filter is `Eq` (single value) or `In` (multiple values).
266#[derive(Clone, Debug)]
267pub enum ScopeFilterValues<'a> {
268    /// Single value from an `Eq` filter.
269    Single(&'a ScopeValue),
270    /// Multiple values from an `In` filter.
271    Multiple(&'a [ScopeValue]),
272}
273
274impl<'a> ScopeFilterValues<'a> {
275    /// Returns an iterator over the values.
276    #[must_use]
277    pub fn iter(&self) -> ScopeFilterValuesIter<'a> {
278        match self {
279            Self::Single(v) => ScopeFilterValuesIter::Single(Some(v)),
280            Self::Multiple(vs) => ScopeFilterValuesIter::Multiple(vs.iter()),
281        }
282    }
283
284    /// Returns `true` if any value matches the given predicate.
285    #[must_use]
286    pub fn contains(&self, value: &ScopeValue) -> bool {
287        self.iter().any(|v| v == value)
288    }
289}
290
291impl<'a> IntoIterator for ScopeFilterValues<'a> {
292    type Item = &'a ScopeValue;
293    type IntoIter = ScopeFilterValuesIter<'a>;
294
295    fn into_iter(self) -> Self::IntoIter {
296        self.iter()
297    }
298}
299
300impl<'a> IntoIterator for &ScopeFilterValues<'a> {
301    type Item = &'a ScopeValue;
302    type IntoIter = ScopeFilterValuesIter<'a>;
303
304    fn into_iter(self) -> Self::IntoIter {
305        self.iter()
306    }
307}
308
309/// Iterator over [`ScopeFilterValues`].
310pub enum ScopeFilterValuesIter<'a> {
311    /// Yields a single value.
312    Single(Option<&'a ScopeValue>),
313    /// Yields from a slice.
314    Multiple(std::slice::Iter<'a, ScopeValue>),
315}
316
317impl<'a> Iterator for ScopeFilterValuesIter<'a> {
318    type Item = &'a ScopeValue;
319
320    fn next(&mut self) -> Option<Self::Item> {
321        match self {
322            Self::Single(v) => v.take(),
323            Self::Multiple(iter) => iter.next(),
324        }
325    }
326}
327
328/// A conjunction (AND) of scope filters — one access path.
329///
330/// All filters within a constraint must match simultaneously for a row
331/// to be accessible via this path.
332#[derive(Clone, Debug, PartialEq)]
333pub struct ScopeConstraint {
334    filters: Vec<ScopeFilter>,
335}
336
337impl ScopeConstraint {
338    /// Create a new scope constraint from a list of filters.
339    #[must_use]
340    pub fn new(filters: Vec<ScopeFilter>) -> Self {
341        Self { filters }
342    }
343
344    /// The filters in this constraint (AND-ed together).
345    #[inline]
346    #[must_use]
347    pub fn filters(&self) -> &[ScopeFilter] {
348        &self.filters
349    }
350
351    /// Returns `true` if this constraint has no filters.
352    #[inline]
353    #[must_use]
354    pub fn is_empty(&self) -> bool {
355        self.filters.is_empty()
356    }
357}
358
359/// A disjunction (OR) of scope constraints defining what data is accessible.
360///
361/// Each constraint is an independent access path (OR-ed). Filters within a
362/// constraint are AND-ed. An unconstrained scope bypasses row-level filtering.
363///
364/// # Examples
365///
366/// ```
367/// use modkit_security::access_scope::{AccessScope, ScopeConstraint, ScopeFilter, pep_properties};
368/// use uuid::Uuid;
369///
370/// // deny-all (default)
371/// let scope = AccessScope::deny_all();
372/// assert!(scope.is_deny_all());
373///
374/// // single tenant
375/// let tid = Uuid::new_v4();
376/// let scope = AccessScope::for_tenant(tid);
377/// assert!(!scope.is_deny_all());
378/// assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, tid));
379/// ```
380#[derive(Clone, Debug, PartialEq)]
381pub struct AccessScope {
382    constraints: Vec<ScopeConstraint>,
383    unconstrained: bool,
384}
385
386impl Default for AccessScope {
387    /// Default is deny-all: no constraints and not unconstrained.
388    fn default() -> Self {
389        Self::deny_all()
390    }
391}
392
393impl AccessScope {
394    // ── Constructors ────────────────────────────────────────────────
395
396    /// Create an access scope from a list of constraints (OR-ed).
397    #[must_use]
398    pub fn from_constraints(constraints: Vec<ScopeConstraint>) -> Self {
399        Self {
400            constraints,
401            unconstrained: false,
402        }
403    }
404
405    /// Create an access scope with a single constraint.
406    #[must_use]
407    pub fn single(constraint: ScopeConstraint) -> Self {
408        Self::from_constraints(vec![constraint])
409    }
410
411    /// Create an "allow all" (unconstrained) scope.
412    ///
413    /// This represents a legitimate PDP decision with no row-level filtering.
414    /// Not a bypass — it's a valid authorization outcome.
415    #[must_use]
416    pub fn allow_all() -> Self {
417        Self {
418            constraints: Vec::new(),
419            unconstrained: true,
420        }
421    }
422
423    /// Create a "deny all" scope (no access).
424    #[must_use]
425    pub fn deny_all() -> Self {
426        Self {
427            constraints: Vec::new(),
428            unconstrained: false,
429        }
430    }
431
432    // ── Convenience constructors ────────────────────────────────────
433
434    /// Create a scope for a set of tenant IDs.
435    #[must_use]
436    pub fn for_tenants(ids: Vec<Uuid>) -> Self {
437        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
438            pep_properties::OWNER_TENANT_ID,
439            ids,
440        )]))
441    }
442
443    /// Create a scope for a single tenant ID.
444    #[must_use]
445    pub fn for_tenant(id: Uuid) -> Self {
446        Self::for_tenants(vec![id])
447    }
448
449    /// Create a scope for a set of resource IDs.
450    #[must_use]
451    pub fn for_resources(ids: Vec<Uuid>) -> Self {
452        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
453            pep_properties::RESOURCE_ID,
454            ids,
455        )]))
456    }
457
458    /// Create a scope for a single resource ID.
459    #[must_use]
460    pub fn for_resource(id: Uuid) -> Self {
461        Self::for_resources(vec![id])
462    }
463
464    // ── Accessors ───────────────────────────────────────────────────
465
466    /// The constraints in this scope (OR-ed).
467    #[inline]
468    #[must_use]
469    pub fn constraints(&self) -> &[ScopeConstraint] {
470        &self.constraints
471    }
472
473    /// Returns `true` if this scope is unconstrained (allow-all).
474    #[inline]
475    #[must_use]
476    pub fn is_unconstrained(&self) -> bool {
477        self.unconstrained
478    }
479
480    /// Returns `true` if this scope denies all access.
481    ///
482    /// A scope is deny-all when it is not unconstrained and has no constraints.
483    #[must_use]
484    pub fn is_deny_all(&self) -> bool {
485        !self.unconstrained && self.constraints.is_empty()
486    }
487
488    /// Collect all values for a given property across all constraints.
489    #[must_use]
490    pub fn all_values_for(&self, property: &str) -> Vec<&ScopeValue> {
491        let mut result = Vec::new();
492        for constraint in &self.constraints {
493            for filter in constraint.filters() {
494                if filter.property() == property {
495                    result.extend(filter.values());
496                }
497            }
498        }
499        result
500    }
501
502    /// Collect all UUID values for a given property across all constraints.
503    ///
504    /// Convenience wrapper — skips non-UUID values.
505    #[must_use]
506    pub fn all_uuid_values_for(&self, property: &str) -> Vec<Uuid> {
507        let mut result = Vec::new();
508        for constraint in &self.constraints {
509            for filter in constraint.filters() {
510                if filter.property() == property {
511                    result.extend(filter.uuid_values());
512                }
513            }
514        }
515        result
516    }
517
518    /// Check if any constraint has a filter matching the given property and value.
519    #[must_use]
520    pub fn contains_value(&self, property: &str, value: &ScopeValue) -> bool {
521        self.constraints.iter().any(|c| {
522            c.filters()
523                .iter()
524                .any(|f| f.property() == property && f.values().contains(value))
525        })
526    }
527
528    /// Check if any constraint has a filter matching the given property and UUID.
529    #[must_use]
530    pub fn contains_uuid(&self, property: &str, id: Uuid) -> bool {
531        self.contains_value(property, &ScopeValue::Uuid(id))
532    }
533
534    /// Check if any constraint references the given property.
535    #[must_use]
536    pub fn has_property(&self, property: &str) -> bool {
537        self.constraints
538            .iter()
539            .any(|c| c.filters().iter().any(|f| f.property() == property))
540    }
541
542    /// Create a new scope retaining only `owner_tenant_id` filters.
543    ///
544    /// Useful for entities declared with `no_owner` (e.g., messages, reactions),
545    /// where `owner_id` constraints cannot be resolved and would cause fail-closed
546    /// deny-all behaviour.
547    ///
548    /// - Unconstrained scopes become deny-all (fail-closed).
549    /// - Constraints that contain no `owner_tenant_id` filter are dropped entirely.
550    /// - If all constraints are dropped, the result is deny-all.
551    #[must_use]
552    pub fn tenant_only(&self) -> Self {
553        self.retain_properties(&[pep_properties::OWNER_TENANT_ID])
554    }
555
556    /// Create a new scope retaining only `owner_tenant_id` and `owner_id` filters.
557    ///
558    /// Useful for entities that have both tenant and owner columns but no
559    /// resource-level constraints (e.g., reactions scoped to the acting user).
560    ///
561    /// - Unconstrained scopes become deny-all (fail-closed).
562    /// - Constraints that contain none of the retained properties are dropped.
563    /// - If all constraints are dropped, the result is deny-all.
564    #[must_use]
565    pub fn tenant_and_owner(&self) -> Self {
566        self.retain_properties(&[pep_properties::OWNER_TENANT_ID, pep_properties::OWNER_ID])
567    }
568
569    /// Create a new scope that guarantees an `owner_id` equality filter
570    /// matching exactly the supplied `owner_id` is present in every constraint.
571    ///
572    /// **Intersection semantics**: if a constraint already contains an
573    /// `owner_id` filter, the supplied value must be among its values —
574    /// otherwise the constraint is dropped. When it matches, the filter is
575    /// narrowed to exactly that single value.
576    ///
577    /// - **Unconstrained** → single constraint with only the `owner_id` filter.
578    /// - **Deny-all** → stays deny-all.
579    /// - **No existing owner filter** → `owner_id` is injected.
580    /// - **Existing owner filter containing `owner_id`** → narrowed to `Eq`.
581    /// - **Existing owner filter NOT containing `owner_id`** → constraint dropped
582    ///   (constraints use OR semantics, so dropping one narrows access; dropping
583    ///   all yields deny-all).
584    ///
585    /// Use this as a defence-in-depth measure for user-owned resources when
586    /// the PDP may not always return `owner_id` constraints or may return a
587    /// broader set than the current subject.
588    #[must_use]
589    pub fn ensure_owner(&self, owner_id: Uuid) -> Self {
590        if self.is_deny_all() {
591            return Self::deny_all();
592        }
593
594        let owner_filter = ScopeFilter::eq(pep_properties::OWNER_ID, owner_id);
595
596        if self.unconstrained {
597            return Self::single(ScopeConstraint::new(vec![owner_filter]));
598        }
599
600        let constraints = self
601            .constraints
602            .iter()
603            .filter_map(|c| {
604                let owner_filters: Vec<&ScopeFilter> = c
605                    .filters()
606                    .iter()
607                    .filter(|f| f.property() == pep_properties::OWNER_ID)
608                    .collect();
609
610                if owner_filters.is_empty() {
611                    let mut filters = c.filters().to_vec();
612                    filters.push(owner_filter.clone());
613                    return Some(ScopeConstraint::new(filters));
614                }
615
616                // Intersection semantics: ALL owner_id predicates must contain
617                // the supplied owner_id, otherwise the constraint is dropped.
618                let all_match = owner_filters
619                    .iter()
620                    .all(|f| f.values().iter().any(|v| v.as_uuid() == Some(owner_id)));
621                if !all_match {
622                    return None;
623                }
624
625                // Fast path: single Eq already matches → constraint unchanged.
626                if owner_filters.len() == 1 && matches!(owner_filters[0], ScopeFilter::Eq(_)) {
627                    return Some(c.clone());
628                }
629
630                // Replace all owner_id filters with a single Eq.
631                let mut filters: Vec<ScopeFilter> = c
632                    .filters()
633                    .iter()
634                    .filter(|f| f.property() != pep_properties::OWNER_ID)
635                    .cloned()
636                    .collect();
637                filters.push(owner_filter.clone());
638                Some(ScopeConstraint::new(filters))
639            })
640            .collect();
641
642        Self::from_constraints(constraints)
643    }
644
645    /// Internal helper: build a new scope keeping only filters whose property
646    /// is in the given whitelist.
647    fn retain_properties(&self, properties: &[&str]) -> Self {
648        if self.unconstrained {
649            return Self::deny_all();
650        }
651
652        let constraints = self
653            .constraints
654            .iter()
655            .filter_map(|c| {
656                let kept: Vec<ScopeFilter> = c
657                    .filters()
658                    .iter()
659                    .filter(|f| properties.contains(&f.property()))
660                    .cloned()
661                    .collect();
662
663                if kept.is_empty() {
664                    None
665                } else {
666                    Some(ScopeConstraint::new(kept))
667                }
668            })
669            .collect();
670
671        Self::from_constraints(constraints)
672    }
673}
674
675#[cfg(test)]
676#[cfg_attr(coverage_nightly, coverage(off))]
677mod tests {
678    use super::*;
679    use uuid::Uuid;
680
681    const T1: &str = "11111111-1111-1111-1111-111111111111";
682    const T2: &str = "22222222-2222-2222-2222-222222222222";
683
684    fn uid(s: &str) -> Uuid {
685        Uuid::parse_str(s).unwrap()
686    }
687
688    // --- ScopeFilter::Eq ---
689
690    #[test]
691    fn scope_filter_eq_constructor() {
692        let f = ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1));
693        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
694        assert!(matches!(f, ScopeFilter::Eq(_)));
695        assert!(f.values().contains(&ScopeValue::Uuid(uid(T1))));
696    }
697
698    #[test]
699    fn all_values_for_works_with_eq() {
700        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
701            pep_properties::OWNER_TENANT_ID,
702            uid(T1),
703        )]));
704        assert_eq!(
705            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
706            &[uid(T1)]
707        );
708    }
709
710    #[test]
711    fn all_values_for_works_with_mixed_eq_and_in() {
712        let scope = AccessScope::from_constraints(vec![
713            ScopeConstraint::new(vec![ScopeFilter::eq(
714                pep_properties::OWNER_TENANT_ID,
715                uid(T1),
716            )]),
717            ScopeConstraint::new(vec![ScopeFilter::in_uuids(
718                pep_properties::OWNER_TENANT_ID,
719                vec![uid(T2)],
720            )]),
721        ]);
722        let values = scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID);
723        assert_eq!(values, &[uid(T1), uid(T2)]);
724    }
725
726    #[test]
727    fn contains_value_works_with_eq() {
728        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
729            pep_properties::OWNER_TENANT_ID,
730            uid(T1),
731        )]));
732        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
733        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
734    }
735
736    // --- tenant_only ---
737
738    #[test]
739    fn tenant_only_strips_owner_id() {
740        let scope = AccessScope::single(ScopeConstraint::new(vec![
741            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
742            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
743        ]));
744
745        let tenant_scope = scope.tenant_only();
746        assert!(tenant_scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
747        assert!(!tenant_scope.has_property(pep_properties::OWNER_ID));
748    }
749
750    #[test]
751    fn tenant_only_unconstrained_becomes_deny_all() {
752        let scope = AccessScope::allow_all();
753        let tenant_scope = scope.tenant_only();
754        assert!(tenant_scope.is_deny_all());
755    }
756
757    #[test]
758    fn tenant_only_deny_all_when_no_tenant_filters() {
759        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
760            pep_properties::OWNER_ID,
761            uid(T1),
762        )]));
763
764        let tenant_scope = scope.tenant_only();
765        assert!(tenant_scope.is_deny_all());
766    }
767
768    #[test]
769    fn tenant_only_on_deny_all_stays_deny_all() {
770        let scope = AccessScope::deny_all();
771        let tenant_scope = scope.tenant_only();
772        assert!(tenant_scope.is_deny_all());
773    }
774
775    // --- tenant_and_owner ---
776
777    #[test]
778    fn tenant_and_owner_keeps_both_properties() {
779        let scope = AccessScope::single(ScopeConstraint::new(vec![
780            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
781            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
782            ScopeFilter::eq(pep_properties::RESOURCE_ID, uid(T1)),
783        ]));
784
785        let narrowed = scope.tenant_and_owner();
786        assert!(narrowed.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
787        assert!(narrowed.contains_uuid(pep_properties::OWNER_ID, uid(T2)));
788        assert!(!narrowed.has_property(pep_properties::RESOURCE_ID));
789    }
790
791    #[test]
792    fn tenant_and_owner_unconstrained_becomes_deny_all() {
793        let scope = AccessScope::allow_all();
794        assert!(scope.tenant_and_owner().is_deny_all());
795    }
796
797    #[test]
798    fn tenant_and_owner_deny_all_when_no_matching_filters() {
799        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
800            pep_properties::RESOURCE_ID,
801            uid(T1),
802        )]));
803        assert!(scope.tenant_and_owner().is_deny_all());
804    }
805
806    // --- ensure_owner ---
807
808    #[test]
809    fn ensure_owner_adds_owner_when_missing() {
810        let scope = AccessScope::for_tenant(uid(T1));
811        let owner_id = uid(T2);
812
813        let scoped = scope.ensure_owner(owner_id);
814        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
815        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
816    }
817
818    #[test]
819    fn ensure_owner_keeps_existing_owner() {
820        let existing_owner = uid(T2);
821        let scope = AccessScope::single(ScopeConstraint::new(vec![
822            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
823            ScopeFilter::eq(pep_properties::OWNER_ID, existing_owner),
824        ]));
825
826        let scoped = scope.ensure_owner(existing_owner);
827        assert_eq!(
828            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
829            &[existing_owner]
830        );
831    }
832
833    #[test]
834    fn ensure_owner_on_unconstrained_creates_owner_scope() {
835        let scope = AccessScope::allow_all();
836        let owner_id = uid(T1);
837
838        let scoped = scope.ensure_owner(owner_id);
839        assert!(!scoped.is_unconstrained());
840        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
841    }
842
843    #[test]
844    fn ensure_owner_on_deny_all_stays_deny_all() {
845        let scope = AccessScope::deny_all();
846        let scoped = scope.ensure_owner(uid(T1));
847        assert!(scoped.is_deny_all());
848    }
849
850    #[test]
851    fn ensure_owner_narrows_existing_owner_to_subject() {
852        let user_a = uid(T1);
853        let user_b = uid(T2);
854        let scope = AccessScope::single(ScopeConstraint::new(vec![
855            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
856            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![user_a, user_b]),
857        ]));
858
859        let scoped = scope.ensure_owner(user_a);
860        assert_eq!(
861            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
862            &[user_a],
863            "Must narrow to exactly the subject's owner_id"
864        );
865        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
866    }
867
868    #[test]
869    fn ensure_owner_drops_constraint_when_subject_not_in_pdp() {
870        let user_x = uid(T1);
871        let user_y = uid(T2);
872        let scope = AccessScope::single(ScopeConstraint::new(vec![
873            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
874            ScopeFilter::eq(pep_properties::OWNER_ID, user_x),
875        ]));
876
877        let scoped = scope.ensure_owner(user_y);
878        assert!(
879            scoped.is_deny_all(),
880            "Must be deny-all when subject not in PDP's owner set"
881        );
882    }
883
884    #[test]
885    fn ensure_owner_checks_all_owner_filters_in_constraint() {
886        let alice = uid(T1);
887        let bob = uid(T2);
888        // Contrived: two owner_id filters in one constraint.
889        // alice is in the first but not the second → must be dropped.
890        let scope = AccessScope::single(ScopeConstraint::new(vec![
891            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![alice, bob]),
892            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![bob]),
893        ]));
894
895        let scoped = scope.ensure_owner(alice);
896        assert!(
897            scoped.is_deny_all(),
898            "Must deny when subject is missing from any owner_id filter"
899        );
900
901        // bob is in both → should pass and narrow to Eq.
902        let scoped = scope.ensure_owner(bob);
903        assert!(!scoped.is_deny_all());
904        assert_eq!(
905            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
906            &[bob],
907            "Must narrow to single Eq for the matching owner"
908        );
909    }
910
911    #[test]
912    fn ensure_owner_multi_constraint_keeps_only_matching() {
913        let alice = uid(T1);
914        let bob = uid(T2);
915        let tenant = uid(T1);
916
917        // Constraint 1: tenant + alice → matches alice
918        let c1 = ScopeConstraint::new(vec![
919            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
920            ScopeFilter::eq(pep_properties::OWNER_ID, alice),
921        ]);
922        // Constraint 2: tenant + bob → does NOT match alice
923        let c2 = ScopeConstraint::new(vec![
924            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
925            ScopeFilter::eq(pep_properties::OWNER_ID, bob),
926        ]);
927
928        let scope = AccessScope::from_constraints(vec![c1, c2]);
929        let scoped = scope.ensure_owner(alice);
930
931        assert!(
932            !scoped.is_deny_all(),
933            "Must not be deny-all - one constraint matches"
934        );
935        assert_eq!(
936            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
937            &[alice],
938            "Must keep only the constraint matching alice"
939        );
940        assert!(
941            scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant),
942            "Tenant filter must be preserved"
943        );
944    }
945}