Skip to main content

toolkit_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/// - [`ScopeFilter::InGroup`] — group membership subquery
114/// - [`ScopeFilter::InGroupSubtree`] — group subtree subquery
115/// - [`ScopeFilter::InTenantSubtree`] — tenant subtree subquery on `tenant_closure`
116///
117/// `#[non_exhaustive]`: this mirrors the PDP's predicate set, which has already
118/// grown to five variants and will grow again. Without it, every new predicate
119/// is a breaking change for every downstream `match`. With it, a consumer must
120/// write a wildcard arm — and **that arm must fail closed**: a filter this build
121/// does not understand is a restriction it cannot apply, so treating it as
122/// "nothing to do" silently drops a narrowing term and widens the grant.
123#[derive(Clone, Debug, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum ScopeFilter {
126    /// Equality: `property = value`.
127    Eq(EqScopeFilter),
128    /// Set membership: `property IN (values)`.
129    In(InScopeFilter),
130    /// Group membership: `property IN (SELECT resource_id FROM membership WHERE group_id IN (group_ids))`.
131    InGroup(InGroupScopeFilter),
132    /// Group subtree: `property IN (SELECT resource_id FROM membership WHERE group_id IN (SELECT descendant_id FROM closure WHERE ancestor_id IN (ancestor_ids)))`.
133    InGroupSubtree(InGroupSubtreeScopeFilter),
134    /// Tenant subtree: `property IN (SELECT descendant_id FROM tenant_closure WHERE ancestor_id = root_tenant_id)`.
135    InTenantSubtree(InTenantSubtreeScopeFilter),
136}
137
138/// Equality scope filter: `property = value`.
139#[derive(Clone, Debug, PartialEq, Eq, Hash)]
140pub struct EqScopeFilter {
141    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
142    property: String,
143    /// The value to match.
144    value: ScopeValue,
145}
146
147/// Set membership scope filter: `property IN (values)`.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct InScopeFilter {
150    /// Authorization property name (e.g., `pep_properties::OWNER_TENANT_ID`).
151    property: String,
152    /// The set of values to match against.
153    values: Vec<ScopeValue>,
154}
155
156impl EqScopeFilter {
157    /// Create an equality scope filter.
158    #[must_use]
159    pub fn new(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
160        Self {
161            property: property.into(),
162            value: value.into(),
163        }
164    }
165
166    /// The authorization property name.
167    #[inline]
168    #[must_use]
169    pub fn property(&self) -> &str {
170        &self.property
171    }
172
173    /// The filter value.
174    #[inline]
175    #[must_use]
176    pub fn value(&self) -> &ScopeValue {
177        &self.value
178    }
179}
180
181impl InScopeFilter {
182    /// Create a set membership scope filter.
183    #[must_use]
184    pub fn new(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
185        Self {
186            property: property.into(),
187            values,
188        }
189    }
190
191    /// Create from an iterator of convertible values.
192    #[must_use]
193    pub fn from_values<V: Into<ScopeValue>>(
194        property: impl Into<String>,
195        values: impl IntoIterator<Item = V>,
196    ) -> Self {
197        Self {
198            property: property.into(),
199            values: values.into_iter().map(Into::into).collect(),
200        }
201    }
202
203    /// The authorization property name.
204    #[inline]
205    #[must_use]
206    pub fn property(&self) -> &str {
207        &self.property
208    }
209
210    /// The filter values.
211    #[inline]
212    #[must_use]
213    pub fn values(&self) -> &[ScopeValue] {
214        &self.values
215    }
216}
217
218/// Group membership scope filter.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub struct InGroupScopeFilter {
221    property: String,
222    group_ids: Vec<ScopeValue>,
223}
224
225impl InGroupScopeFilter {
226    /// Create a group membership scope filter.
227    #[must_use]
228    pub fn new(property: impl Into<String>, group_ids: Vec<ScopeValue>) -> Self {
229        Self {
230            property: property.into(),
231            group_ids,
232        }
233    }
234
235    /// The authorization property name.
236    #[inline]
237    #[must_use]
238    pub fn property(&self) -> &str {
239        &self.property
240    }
241
242    /// The group IDs.
243    #[inline]
244    #[must_use]
245    pub fn group_ids(&self) -> &[ScopeValue] {
246        &self.group_ids
247    }
248}
249
250/// Group subtree scope filter.
251#[derive(Clone, Debug, PartialEq, Eq)]
252pub struct InGroupSubtreeScopeFilter {
253    property: String,
254    ancestor_ids: Vec<ScopeValue>,
255}
256
257impl InGroupSubtreeScopeFilter {
258    /// Create a group subtree scope filter.
259    #[must_use]
260    pub fn new(property: impl Into<String>, ancestor_ids: Vec<ScopeValue>) -> Self {
261        Self {
262            property: property.into(),
263            ancestor_ids,
264        }
265    }
266
267    /// The authorization property name.
268    #[inline]
269    #[must_use]
270    pub fn property(&self) -> &str {
271        &self.property
272    }
273
274    /// The ancestor group IDs.
275    #[inline]
276    #[must_use]
277    pub fn ancestor_ids(&self) -> &[ScopeValue] {
278        &self.ancestor_ids
279    }
280}
281
282/// Tenant subtree scope filter — clamps a property to descendants of a single
283/// root tenant via the `tenant_closure` table.
284///
285/// Compiles to (with `respect_barriers = true`, the default, and an empty
286/// `descendant_status`):
287/// `property IN (SELECT descendant_id FROM tenant_closure
288///   WHERE ancestor_id = root_tenant_id AND barrier = 0)`
289///
290/// With `respect_barriers = false`:
291/// `property IN (SELECT descendant_id FROM tenant_closure
292///   WHERE ancestor_id = root_tenant_id)`
293///
294/// With a non-empty `descendant_status` (each value is the canonical
295/// SMALLINT for a tenant status — see
296/// `tenant_resolver_sdk::TenantStatus::as_smallint`):
297/// `... AND descendant_status IN (...)`
298///
299/// **Heads-up for `tenants`-style entities:** When a property resolves
300/// to the `tenants` row's own primary key (via `pep_properties::RESOURCE_ID`),
301/// the entity must declare the `id` column as a resolvable secured
302/// property. Entities marked with `#[secure(no_resource, ...)]` will
303/// fail-closed at scope resolution time.
304#[derive(Clone, Debug, PartialEq, Eq)]
305pub struct InTenantSubtreeScopeFilter {
306    property: String,
307    root_tenant_id: ScopeValue,
308    respect_barriers: bool,
309    descendant_status: Vec<ScopeValue>,
310}
311
312impl InTenantSubtreeScopeFilter {
313    /// Create a tenant subtree scope filter that respects barriers with no
314    /// status filter.
315    ///
316    /// Equivalent to
317    /// `with_respect_barriers(property, root_tenant_id, true)`.
318    #[must_use]
319    pub fn new(property: impl Into<String>, root_tenant_id: impl Into<ScopeValue>) -> Self {
320        Self::with_respect_barriers(property, root_tenant_id, true)
321    }
322
323    /// Create a tenant subtree scope filter with explicit barrier handling
324    /// and no status filter.
325    #[must_use]
326    pub fn with_respect_barriers(
327        property: impl Into<String>,
328        root_tenant_id: impl Into<ScopeValue>,
329        respect_barriers: bool,
330    ) -> Self {
331        Self::with_descendant_status(property, root_tenant_id, respect_barriers, Vec::new())
332    }
333
334    /// Create a tenant subtree scope filter with explicit barrier handling
335    /// and a (possibly empty) status filter on the descendants. An empty
336    /// list is equivalent to "no status filter".
337    #[must_use]
338    pub fn with_descendant_status(
339        property: impl Into<String>,
340        root_tenant_id: impl Into<ScopeValue>,
341        respect_barriers: bool,
342        descendant_status: Vec<ScopeValue>,
343    ) -> Self {
344        Self {
345            property: property.into(),
346            root_tenant_id: root_tenant_id.into(),
347            respect_barriers,
348            descendant_status,
349        }
350    }
351
352    /// The authorization property name.
353    #[inline]
354    #[must_use]
355    pub fn property(&self) -> &str {
356        &self.property
357    }
358
359    /// The single root tenant ID at which the subtree is anchored.
360    #[inline]
361    #[must_use]
362    pub fn root_tenant_id(&self) -> &ScopeValue {
363        &self.root_tenant_id
364    }
365
366    /// Whether the SQL compilation should clamp the closure subquery
367    /// with `AND barrier = 0` (i.e. stop at self-managed boundaries).
368    #[inline]
369    #[must_use]
370    pub fn respect_barriers(&self) -> bool {
371        self.respect_barriers
372    }
373
374    /// Status filter applied to the descendants reached via the closure.
375    ///
376    /// Empty slice means "no status filter"; otherwise the SQL adds
377    /// `AND descendant_status IN (...)` to the closure subquery. Values
378    /// are expected to be SMALLINT-encoded statuses
379    /// (see `tenant_resolver_sdk::TenantStatus::as_smallint`).
380    #[inline]
381    #[must_use]
382    pub fn descendant_status(&self) -> &[ScopeValue] {
383        &self.descendant_status
384    }
385}
386
387impl ScopeFilter {
388    /// Create an equality filter (`property = value`).
389    #[must_use]
390    pub fn eq(property: impl Into<String>, value: impl Into<ScopeValue>) -> Self {
391        Self::Eq(EqScopeFilter::new(property, value))
392    }
393
394    /// Create a set membership filter (`property IN (values)`).
395    #[must_use]
396    pub fn r#in(property: impl Into<String>, values: Vec<ScopeValue>) -> Self {
397        Self::In(InScopeFilter::new(property, values))
398    }
399
400    /// Create a set membership filter from UUID values (convenience).
401    #[must_use]
402    pub fn in_uuids(property: impl Into<String>, uuids: Vec<Uuid>) -> Self {
403        Self::In(InScopeFilter::new(
404            property,
405            uuids.into_iter().map(ScopeValue::Uuid).collect(),
406        ))
407    }
408
409    /// Create a group membership filter.
410    #[must_use]
411    pub fn in_group(property: impl Into<String>, group_ids: Vec<ScopeValue>) -> Self {
412        Self::InGroup(InGroupScopeFilter::new(property, group_ids))
413    }
414
415    /// Create a group subtree filter.
416    #[must_use]
417    pub fn in_group_subtree(property: impl Into<String>, ancestor_ids: Vec<ScopeValue>) -> Self {
418        Self::InGroupSubtree(InGroupSubtreeScopeFilter::new(property, ancestor_ids))
419    }
420
421    /// Create a tenant subtree filter rooted at a single ancestor tenant.
422    ///
423    /// `descendant_status` is a (possibly empty) list of SMALLINT-encoded
424    /// tenant statuses (see `tenant_resolver_sdk::TenantStatus::as_smallint`);
425    /// when non-empty, the SQL adds `AND descendant_status IN (...)` to
426    /// the closure subquery. Pass `Vec::new()` for "no status filter".
427    #[must_use]
428    pub fn in_tenant_subtree(
429        property: impl Into<String>,
430        root_tenant_id: impl Into<ScopeValue>,
431        respect_barriers: bool,
432        descendant_status: Vec<ScopeValue>,
433    ) -> Self {
434        Self::InTenantSubtree(InTenantSubtreeScopeFilter::with_descendant_status(
435            property,
436            root_tenant_id,
437            respect_barriers,
438            descendant_status,
439        ))
440    }
441
442    /// The authorization property name.
443    #[must_use]
444    pub fn property(&self) -> &str {
445        match self {
446            Self::Eq(f) => f.property(),
447            Self::In(f) => f.property(),
448            Self::InGroup(f) => f.property(),
449            Self::InGroupSubtree(f) => f.property(),
450            Self::InTenantSubtree(f) => f.property(),
451        }
452    }
453
454    /// Collect direct-match values as a slice-like view for iteration.
455    ///
456    /// For `Eq`, returns a single-element slice; for `In`, returns the values slice.
457    /// For `InGroup`/`InGroupSubtree`/`InTenantSubtree`, returns empty — those
458    /// are subquery parameters, not resource property values. The actual
459    /// matching happens in SQL via [`secure::scope_to_condition`].
460    ///
461    /// **Write-path limitation:** Because `InTenantSubtree` returns an empty
462    /// slice here, in-memory helpers such as [`AccessScope::contains_uuid`] and
463    /// [`AccessScope::all_uuid_values_for`] always return negative/empty results
464    /// for this filter variant. Secure-insert paths that validate scope membership
465    /// via these helpers cannot use `InTenantSubtree` as a substitute for
466    /// `allow_all()` without an additional DB-backed tenant-membership check.
467    #[must_use]
468    pub fn values(&self) -> ScopeFilterValues<'_> {
469        match self {
470            Self::Eq(f) => ScopeFilterValues::Single(&f.value),
471            Self::In(f) => ScopeFilterValues::Multiple(&f.values),
472            Self::InGroup(_) | Self::InGroupSubtree(_) | Self::InTenantSubtree(_) => {
473                ScopeFilterValues::Multiple(&[])
474            }
475        }
476    }
477
478    /// Whether this filter can be decided from its values alone.
479    ///
480    /// `false` for the three subquery variants, whose matching happens in SQL.
481    /// [`ScopeFilter::values`] returns an empty view for those, which is
482    /// indistinguishable from an `In` filter that genuinely has no values — so
483    /// a caller deciding membership in memory reads "no match" for a filter
484    /// that does grant access.
485    ///
486    /// Check this first: a filter that is not representable in memory has to be
487    /// resolved against the database, not treated as a negative.
488    #[must_use]
489    pub fn is_representable_in_memory(&self) -> bool {
490        match self {
491            Self::Eq(_) | Self::In(_) => true,
492            Self::InGroup(_) | Self::InGroupSubtree(_) | Self::InTenantSubtree(_) => false,
493        }
494    }
495
496    /// Extract filter values as UUIDs, skipping non-UUID entries.
497    ///
498    /// Useful when the caller knows the property holds UUID values
499    /// (e.g., `owner_tenant_id`, `id`).
500    #[must_use]
501    pub fn uuid_values(&self) -> Vec<Uuid> {
502        self.values()
503            .iter()
504            .filter_map(ScopeValue::as_uuid)
505            .collect()
506    }
507}
508
509/// Iterator adapter for [`ScopeFilter::values()`].
510///
511/// Provides a uniform way to iterate over filter values regardless of
512/// whether the filter is `Eq` (single value) or `In` (multiple values).
513#[derive(Clone, Debug)]
514pub enum ScopeFilterValues<'a> {
515    /// Single value from an `Eq` filter.
516    Single(&'a ScopeValue),
517    /// Multiple values from an `In` filter.
518    Multiple(&'a [ScopeValue]),
519}
520
521impl<'a> ScopeFilterValues<'a> {
522    /// Returns an iterator over the values.
523    #[must_use]
524    pub fn iter(&self) -> ScopeFilterValuesIter<'a> {
525        match self {
526            Self::Single(v) => ScopeFilterValuesIter(ScopeFilterValuesIterInner::Single(Some(v))),
527            Self::Multiple(vs) => {
528                ScopeFilterValuesIter(ScopeFilterValuesIterInner::Multiple(vs.iter()))
529            }
530        }
531    }
532
533    /// Returns `true` if any value matches the given predicate.
534    #[must_use]
535    pub fn contains(&self, value: &ScopeValue) -> bool {
536        self.iter().any(|v| v == value)
537    }
538}
539
540impl<'a> IntoIterator for ScopeFilterValues<'a> {
541    type Item = &'a ScopeValue;
542    type IntoIter = ScopeFilterValuesIter<'a>;
543
544    fn into_iter(self) -> Self::IntoIter {
545        self.iter()
546    }
547}
548
549impl<'a> IntoIterator for &ScopeFilterValues<'a> {
550    type Item = &'a ScopeValue;
551    type IntoIter = ScopeFilterValuesIter<'a>;
552
553    fn into_iter(self) -> Self::IntoIter {
554        self.iter()
555    }
556}
557
558/// Iterator over [`ScopeFilterValues`].
559#[derive(Debug, Clone)]
560pub struct ScopeFilterValuesIter<'a>(ScopeFilterValuesIterInner<'a>);
561
562/// How [`ScopeFilterValuesIter`] is actually yielding values.
563///
564/// Private on purpose: as public variants this put `std::slice::Iter` into the
565/// crate's API, pinning an implementation detail into the contract that a
566/// change of backing collection would then break.
567#[derive(Debug, Clone)]
568enum ScopeFilterValuesIterInner<'a> {
569    /// Yields a single value.
570    Single(Option<&'a ScopeValue>),
571    /// Yields from a slice.
572    Multiple(std::slice::Iter<'a, ScopeValue>),
573}
574
575impl<'a> Iterator for ScopeFilterValuesIter<'a> {
576    type Item = &'a ScopeValue;
577
578    fn next(&mut self) -> Option<Self::Item> {
579        match &mut self.0 {
580            ScopeFilterValuesIterInner::Single(v) => v.take(),
581            ScopeFilterValuesIterInner::Multiple(iter) => iter.next(),
582        }
583    }
584
585    fn size_hint(&self) -> (usize, Option<usize>) {
586        match &self.0 {
587            ScopeFilterValuesIterInner::Single(v) => {
588                let n = usize::from(v.is_some());
589                (n, Some(n))
590            }
591            ScopeFilterValuesIterInner::Multiple(iter) => iter.size_hint(),
592        }
593    }
594}
595
596impl ExactSizeIterator for ScopeFilterValuesIter<'_> {}
597
598/// A conjunction (AND) of scope filters — one access path.
599///
600/// All filters within a constraint must match simultaneously for a row
601/// to be accessible via this path.
602#[derive(Clone, Debug, PartialEq)]
603pub struct ScopeConstraint {
604    filters: Vec<ScopeFilter>,
605}
606
607/// A [`ScopeConstraint`] was built with no filters.
608///
609/// A constraint is a conjunction, so an empty one is an AND over nothing: it
610/// matches every row. As one disjunct of an [`AccessScope`] that makes the whole
611/// scope allow-all, while `is_unconstrained()` and `is_deny_all()` both still
612/// answer `false` — so the scope looks constrained to every caller that asks.
613/// `toolkit-db` compiled exactly this shape to an unconditional `WHERE true`.
614#[derive(Debug, thiserror::Error, PartialEq, Eq)]
615#[error("a scope constraint must carry at least one filter; an empty one matches every row")]
616pub struct EmptyScopeConstraint;
617
618impl ScopeConstraint {
619    /// Create a new scope constraint from a non-empty list of filters.
620    ///
621    /// # Errors
622    ///
623    /// Returns [`EmptyScopeConstraint`] if `filters` is empty. Rejecting here is
624    /// what keeps a predicate-free constraint from reaching a consumer at all:
625    /// the policy compiler builds one of these from whatever predicates a PDP
626    /// returned, and a decision that produced none would otherwise widen into
627    /// an allow-all grant instead of failing closed.
628    pub fn try_new(filters: Vec<ScopeFilter>) -> Result<Self, EmptyScopeConstraint> {
629        if filters.is_empty() {
630            return Err(EmptyScopeConstraint);
631        }
632        Ok(Self { filters })
633    }
634
635    /// Create a new scope constraint from a list of filters known to be
636    /// non-empty.
637    ///
638    /// # Panics
639    ///
640    /// Panics if `filters` is empty. Prefer [`ScopeConstraint::try_new`]
641    /// wherever the list is derived from input rather than written out in
642    /// place; this exists for literals and test fixtures, where an empty list
643    /// is a bug in the caller rather than a condition to handle.
644    #[must_use]
645    pub fn new(filters: Vec<ScopeFilter>) -> Self {
646        assert!(
647            !filters.is_empty(),
648            "a scope constraint must carry at least one filter; an empty one matches every row"
649        );
650        Self { filters }
651    }
652
653    /// The filters in this constraint (AND-ed together).
654    #[inline]
655    #[must_use]
656    pub fn filters(&self) -> &[ScopeFilter] {
657        &self.filters
658    }
659
660    /// Returns `true` if this constraint has no filters.
661    #[inline]
662    #[must_use]
663    pub fn is_empty(&self) -> bool {
664        self.filters.is_empty()
665    }
666}
667
668/// A disjunction (OR) of scope constraints defining what data is accessible.
669///
670/// Each constraint is an independent access path (OR-ed). Filters within a
671/// constraint are AND-ed. An unconstrained scope bypasses row-level filtering.
672///
673/// # Examples
674///
675/// ```
676/// use toolkit_security::access_scope::{AccessScope, ScopeConstraint, ScopeFilter, pep_properties};
677/// use uuid::Uuid;
678///
679/// // deny-all (default)
680/// let scope = AccessScope::deny_all();
681/// assert!(scope.is_deny_all());
682///
683/// // single tenant
684/// let tid = Uuid::new_v4();
685/// let scope = AccessScope::for_tenant(tid);
686/// assert!(!scope.is_deny_all());
687/// assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, tid));
688/// ```
689#[derive(Clone, Debug, PartialEq)]
690pub struct AccessScope {
691    constraints: Vec<ScopeConstraint>,
692    unconstrained: bool,
693}
694
695impl Default for AccessScope {
696    /// Default is deny-all: no constraints and not unconstrained.
697    fn default() -> Self {
698        Self::deny_all()
699    }
700}
701
702impl AccessScope {
703    // ── Constructors ────────────────────────────────────────────────
704
705    /// Create an access scope from a list of constraints (OR-ed).
706    #[must_use]
707    pub fn from_constraints(constraints: Vec<ScopeConstraint>) -> Self {
708        Self {
709            constraints,
710            unconstrained: false,
711        }
712    }
713
714    /// Create an access scope with a single constraint.
715    #[must_use]
716    pub fn single(constraint: ScopeConstraint) -> Self {
717        Self::from_constraints(vec![constraint])
718    }
719
720    /// Create an "allow all" (unconstrained) scope.
721    ///
722    /// This represents a legitimate PDP decision with no row-level filtering.
723    /// Not a bypass — it's a valid authorization outcome.
724    #[must_use]
725    pub fn allow_all() -> Self {
726        Self {
727            constraints: Vec::new(),
728            unconstrained: true,
729        }
730    }
731
732    /// Create a "deny all" scope (no access).
733    #[must_use]
734    pub fn deny_all() -> Self {
735        Self {
736            constraints: Vec::new(),
737            unconstrained: false,
738        }
739    }
740
741    // ── Convenience constructors ────────────────────────────────────
742
743    /// Create a scope for a set of tenant IDs.
744    #[must_use]
745    pub fn for_tenants(ids: Vec<Uuid>) -> Self {
746        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
747            pep_properties::OWNER_TENANT_ID,
748            ids,
749        )]))
750    }
751
752    /// Create a scope for a single tenant ID.
753    #[must_use]
754    pub fn for_tenant(id: Uuid) -> Self {
755        Self::for_tenants(vec![id])
756    }
757
758    /// Create a scope for a set of resource IDs.
759    #[must_use]
760    pub fn for_resources(ids: Vec<Uuid>) -> Self {
761        Self::single(ScopeConstraint::new(vec![ScopeFilter::in_uuids(
762            pep_properties::RESOURCE_ID,
763            ids,
764        )]))
765    }
766
767    /// Create a scope for a single resource ID.
768    #[must_use]
769    pub fn for_resource(id: Uuid) -> Self {
770        Self::for_resources(vec![id])
771    }
772
773    // ── Accessors ───────────────────────────────────────────────────
774
775    /// The constraints in this scope (OR-ed).
776    #[inline]
777    #[must_use]
778    pub fn constraints(&self) -> &[ScopeConstraint] {
779        &self.constraints
780    }
781
782    /// Returns `true` if this scope is unconstrained (allow-all).
783    #[inline]
784    #[must_use]
785    pub fn is_unconstrained(&self) -> bool {
786        self.unconstrained
787    }
788
789    /// Returns `true` if this scope denies all access.
790    ///
791    /// A scope is deny-all when it is not unconstrained and has no constraints.
792    #[must_use]
793    pub fn is_deny_all(&self) -> bool {
794        !self.unconstrained && self.constraints.is_empty()
795    }
796
797    /// Collect all values for a given property across all constraints.
798    ///
799    /// **Reports on the constraint list only.** An allow-all scope has no
800    /// constraints, so this returns an empty `Vec` for it — which means "no
801    /// constraint names this property", never "this scope permits nothing".
802    /// An allow-all scope permits every value, and no finite list can say so.
803    /// Check [`AccessScope::is_unconstrained`] before reading anything into an
804    /// empty result.
805    #[must_use]
806    pub fn all_values_for(&self, property: &str) -> Vec<&ScopeValue> {
807        let mut result = Vec::new();
808        for constraint in &self.constraints {
809            for filter in constraint.filters() {
810                if filter.property() == property {
811                    result.extend(filter.values());
812                }
813            }
814        }
815        result
816    }
817
818    /// Collect all UUID values for a given property across all constraints.
819    ///
820    /// Convenience wrapper — skips non-UUID values.
821    ///
822    /// **Reports on the constraint list only**, with the same caveat as
823    /// [`AccessScope::all_values_for`]: empty on an allow-all scope, which
824    /// permits everything rather than nothing.
825    #[must_use]
826    pub fn all_uuid_values_for(&self, property: &str) -> Vec<Uuid> {
827        let mut result = Vec::new();
828        for constraint in &self.constraints {
829            for filter in constraint.filters() {
830                if filter.property() == property {
831                    result.extend(filter.uuid_values());
832                }
833            }
834        }
835        result
836    }
837
838    /// Whether any filter, in any constraint, names `property` with this UUID.
839    ///
840    /// Matches both `ScopeValue::Uuid` and `ScopeValue::String` variants so
841    /// that UUID-as-string values are treated consistently with
842    /// [`AccessScope::all_uuid_values_for`], which also parses strings via
843    /// [`ScopeValue::as_uuid`].
844    ///
845    /// # This is not an authorization decision
846    ///
847    /// It searches filter *values*. It does not evaluate a constraint, which is
848    /// a conjunction: for a grant of `[owner_tenant_id = A AND owner_id = Alice]`
849    /// this answers `true` for `(owner_tenant_id, A)` even when the row in
850    /// question belongs to Bob. A `true` here means "the scope mentions this
851    /// value somewhere", nothing more.
852    ///
853    /// It also reports on the constraint list alone, so an allow-all scope —
854    /// which has no constraints — answers `false` for a value it permits, and
855    /// a subquery filter (`InGroup`, `InGroupSubtree`, `InTenantSubtree`)
856    /// exposes no in-memory values at all, so it answers `false` for a grant
857    /// that does apply.
858    ///
859    /// Authorize a write by passing the scope to the insert and letting
860    /// `SecureORM` evaluate it — `validate_insert_scope` ANDs across the filters
861    /// of a constraint and ORs across constraints, which is the whole decision.
862    ///
863    /// Not marked `#[deprecated]` yet: the workspace builds with `-D warnings`,
864    /// so the attribute would break the build at all of its current call sites
865    /// at once. It goes on once the three gear gates
866    /// (resource-group, ledger, pricing) have moved to `SecureORM`.
867    #[must_use]
868    pub fn contains_uuid(&self, property: &str, id: Uuid) -> bool {
869        self.constraints.iter().any(|c| {
870            c.filters().iter().any(|f| {
871                f.property() == property && f.values().iter().any(|v| v.as_uuid() == Some(id))
872            })
873        })
874    }
875
876    /// Check if any constraint references the given property.
877    ///
878    /// **Reports on the constraint list only**: an allow-all scope has no
879    /// constraints and so answers `false`, which is not a statement about what
880    /// it permits. Check [`AccessScope::is_unconstrained`] first.
881    #[must_use]
882    pub fn has_property(&self, property: &str) -> bool {
883        self.constraints
884            .iter()
885            .any(|c| c.filters().iter().any(|f| f.property() == property))
886    }
887
888    /// Create a new scope retaining only `owner_tenant_id` filters.
889    ///
890    /// Useful for entities declared with `no_owner` (e.g., messages, reactions),
891    /// where `owner_id` constraints cannot be resolved and would cause fail-closed
892    /// deny-all behaviour.
893    ///
894    /// - Unconstrained scopes become deny-all (fail-closed).
895    /// - Constraints that contain no `owner_tenant_id` filter are dropped entirely.
896    /// - If all constraints are dropped, the result is deny-all.
897    ///
898    /// # This widens the grant, by design — check that you want it
899    ///
900    /// Filters on other properties are **removed from surviving constraints**,
901    /// and a constraint is a conjunction, so dropping one of its terms admits
902    /// everything that term excluded. `[owner_tenant_id = T, id IN (r1)]`
903    /// becomes `owner_tenant_id = T`: one resource turned into the whole tenant.
904    ///
905    /// That is correct for the case this exists for — re-targeting a scope at a
906    /// *different* entity, one with no `owner_id`/`id` column of its own, where
907    /// the removed terms never applied to the rows being filtered. It is wrong
908    /// if you are narrowing a scope for the same entity, and the resulting
909    /// scope must not be the only thing authorizing the access: mini-chat, for
910    /// example, checks the parent chat against the full scope first and only
911    /// then uses `tenant_only()` for its messages.
912    #[must_use]
913    pub fn tenant_only(&self) -> Self {
914        self.retain_properties(&[pep_properties::OWNER_TENANT_ID])
915    }
916
917    /// Create a new scope retaining only `owner_tenant_id` and `owner_id` filters.
918    ///
919    /// Useful for entities that have both tenant and owner columns but no
920    /// resource-level constraints (e.g., reactions scoped to the acting user).
921    ///
922    /// - Unconstrained scopes become deny-all (fail-closed).
923    /// - Constraints that contain neither retained property are dropped.
924    /// - Filters on other properties are **removed from surviving
925    ///   constraints**, which widens them — see the warning on
926    ///   [`AccessScope::tenant_only`]; it applies here in full.
927    /// - If all constraints are dropped, the result is deny-all.
928    #[must_use]
929    pub fn tenant_and_owner(&self) -> Self {
930        self.retain_properties(&[pep_properties::OWNER_TENANT_ID, pep_properties::OWNER_ID])
931    }
932
933    /// Create a new scope that guarantees an `owner_id` equality filter
934    /// matching exactly the supplied `owner_id` is present in every constraint.
935    ///
936    /// **Intersection semantics**: if a constraint already contains an
937    /// `owner_id` filter, the supplied value must be among its values —
938    /// otherwise the constraint is dropped. When it matches, the filter is
939    /// narrowed to exactly that single value.
940    ///
941    /// - **Unconstrained** → single constraint with only the `owner_id` filter.
942    /// - **Deny-all** → stays deny-all.
943    /// - **No existing owner filter** → `owner_id` is injected.
944    /// - **Existing owner filter containing `owner_id`** → narrowed to `Eq`.
945    /// - **Existing owner filter NOT containing `owner_id`** → constraint dropped
946    ///   (constraints use OR semantics, so dropping one narrows access; dropping
947    ///   all yields deny-all).
948    ///
949    /// Use this as a defence-in-depth measure for user-owned resources when
950    /// the PDP may not always return `owner_id` constraints or may return a
951    /// broader set than the current subject.
952    #[must_use]
953    pub fn ensure_owner(&self, owner_id: Uuid) -> Self {
954        if self.is_deny_all() {
955            return Self::deny_all();
956        }
957
958        let owner_filter = ScopeFilter::eq(pep_properties::OWNER_ID, owner_id);
959
960        if self.unconstrained {
961            return Self::single(ScopeConstraint::new(vec![owner_filter]));
962        }
963
964        let constraints = self
965            .constraints
966            .iter()
967            .filter_map(|c| {
968                let owner_filters: Vec<&ScopeFilter> = c
969                    .filters()
970                    .iter()
971                    .filter(|f| f.property() == pep_properties::OWNER_ID)
972                    .collect();
973
974                if owner_filters.is_empty() {
975                    let mut filters = c.filters().to_vec();
976                    filters.push(owner_filter.clone());
977                    return Some(ScopeConstraint::new(filters));
978                }
979
980                // Intersection semantics: ALL owner_id predicates must contain
981                // the supplied owner_id, otherwise the constraint is dropped.
982                let all_match = owner_filters
983                    .iter()
984                    .all(|f| f.values().iter().any(|v| v.as_uuid() == Some(owner_id)));
985                if !all_match {
986                    return None;
987                }
988
989                // Fast path: single Eq already matches → constraint unchanged.
990                if owner_filters.len() == 1 && matches!(owner_filters[0], ScopeFilter::Eq(_)) {
991                    return Some(c.clone());
992                }
993
994                // Replace all owner_id filters with a single Eq.
995                let mut filters: Vec<ScopeFilter> = c
996                    .filters()
997                    .iter()
998                    .filter(|f| f.property() != pep_properties::OWNER_ID)
999                    .cloned()
1000                    .collect();
1001                filters.push(owner_filter.clone());
1002                Some(ScopeConstraint::new(filters))
1003            })
1004            .collect();
1005
1006        Self::from_constraints(constraints)
1007    }
1008
1009    /// Internal helper: build a new scope keeping only filters whose property
1010    /// is in the given whitelist.
1011    fn retain_properties(&self, properties: &[&str]) -> Self {
1012        if self.unconstrained {
1013            return Self::deny_all();
1014        }
1015
1016        let constraints = self
1017            .constraints
1018            .iter()
1019            .filter_map(|c| {
1020                let kept: Vec<ScopeFilter> = c
1021                    .filters()
1022                    .iter()
1023                    .filter(|f| properties.contains(&f.property()))
1024                    .cloned()
1025                    .collect();
1026
1027                if kept.is_empty() {
1028                    None
1029                } else {
1030                    Some(ScopeConstraint::new(kept))
1031                }
1032            })
1033            .collect();
1034
1035        Self::from_constraints(constraints)
1036    }
1037}
1038
1039#[cfg(test)]
1040#[cfg_attr(coverage_nightly, coverage(off))]
1041mod tests {
1042    use super::*;
1043    use uuid::Uuid;
1044
1045    const T1: &str = "11111111-1111-1111-1111-111111111111";
1046    const T2: &str = "22222222-2222-2222-2222-222222222222";
1047
1048    fn uid(s: &str) -> Uuid {
1049        Uuid::parse_str(s).unwrap()
1050    }
1051
1052    // --- ScopeFilter::Eq ---
1053
1054    #[test]
1055    fn scope_filter_eq_exposes_exactly_one_value() {
1056        let f = ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1));
1057        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
1058        assert!(matches!(f, ScopeFilter::Eq(_)));
1059
1060        // The behaviour worth pinning is what an `Eq` filter yields, not that
1061        // the constructor stored what it was handed: exactly one value, and one
1062        // that parses back to the UUID it was built from.
1063        assert_eq!(f.values().iter().count(), 1);
1064        assert_eq!(f.uuid_values(), vec![uid(T1)]);
1065    }
1066
1067    #[test]
1068    fn all_values_for_works_with_eq() {
1069        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1070            pep_properties::OWNER_TENANT_ID,
1071            uid(T1),
1072        )]));
1073        assert_eq!(
1074            scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
1075            &[uid(T1)]
1076        );
1077    }
1078
1079    #[test]
1080    fn all_values_for_works_with_mixed_eq_and_in() {
1081        let scope = AccessScope::from_constraints(vec![
1082            ScopeConstraint::new(vec![ScopeFilter::eq(
1083                pep_properties::OWNER_TENANT_ID,
1084                uid(T1),
1085            )]),
1086            ScopeConstraint::new(vec![ScopeFilter::in_uuids(
1087                pep_properties::OWNER_TENANT_ID,
1088                vec![uid(T2)],
1089            )]),
1090        ]);
1091        let values = scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID);
1092        assert_eq!(values, &[uid(T1), uid(T2)]);
1093    }
1094
1095    #[test]
1096    fn contains_uuid_works_with_eq() {
1097        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1098            pep_properties::OWNER_TENANT_ID,
1099            uid(T1),
1100        )]));
1101        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1102        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
1103    }
1104
1105    #[test]
1106    fn a_subquery_filter_reports_that_it_cannot_be_decided_in_memory() {
1107        // An empty value view means two different things, and only this
1108        // predicate separates them: an `In` filter with no values genuinely
1109        // matches nothing, while a subquery filter matches whatever the
1110        // database says and simply cannot answer here.
1111        let empty_in = ScopeFilter::r#in(pep_properties::OWNER_TENANT_ID, vec![]);
1112        assert_eq!(empty_in.values().iter().count(), 0);
1113        assert!(
1114            empty_in.is_representable_in_memory(),
1115            "an In filter with no values is a real, decidable negative"
1116        );
1117
1118        for subquery in [
1119            ScopeFilter::in_group(pep_properties::RESOURCE_ID, vec![ScopeValue::Uuid(uid(T1))]),
1120            ScopeFilter::in_group_subtree(
1121                pep_properties::RESOURCE_ID,
1122                vec![ScopeValue::Uuid(uid(T1))],
1123            ),
1124        ] {
1125            assert_eq!(subquery.values().iter().count(), 0);
1126            assert!(
1127                !subquery.is_representable_in_memory(),
1128                "a subquery filter's empty value view is not a negative"
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn contains_uuid_matches_a_uuid_held_as_a_string() {
1135        // The same id can sit in a scope as either `Uuid` or the `String` of its
1136        // text, and the two are not equal under `PartialEq`. `contains_uuid`
1137        // parses through `as_uuid`, so it answers on identity rather than on
1138        // representation.
1139        let as_text = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1140            pep_properties::OWNER_TENANT_ID,
1141            ScopeValue::String(uid(T1).to_string()),
1142        )]));
1143        assert!(as_text.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1144        assert!(!as_text.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
1145
1146        let typed = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1147            pep_properties::OWNER_TENANT_ID,
1148            uid(T1),
1149        )]));
1150        assert!(typed.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1151    }
1152
1153    #[test]
1154    fn contains_uuid_is_false_for_a_subquery_filter() {
1155        // `InGroup` resolves in SQL and exposes no values in memory, so this
1156        // answers "no" for a grant that does apply — one of the reasons it is
1157        // not an authorization decision.
1158        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_group(
1159            pep_properties::RESOURCE_ID,
1160            vec![ScopeValue::Uuid(uid(T1))],
1161        )]));
1162        assert!(!scope.contains_uuid(pep_properties::RESOURCE_ID, uid(T1)));
1163    }
1164
1165    #[test]
1166    fn contains_uuid_does_not_evaluate_the_conjunction() {
1167        // The reason this is deprecated: a constraint is an AND, and this
1168        // reports on a single filter. The grant is "tenant T1 *and* owner T2",
1169        // yet the tenant alone answers true — which is why a caller must not
1170        // read it as permission.
1171        let scope = AccessScope::single(ScopeConstraint::new(vec![
1172            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1173            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
1174        ]));
1175
1176        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1177        assert!(
1178            !scope.contains_uuid(pep_properties::OWNER_ID, uid(T1)),
1179            "guard: T1 is the tenant, not the owner"
1180        );
1181    }
1182
1183    #[test]
1184    fn an_allow_all_scope_permits_everything_it_reports_no_constraint_for() {
1185        // The distinction the `contains_*` family cannot express on its own: an
1186        // allow-all scope holds no constraints, so every one of them answers
1187        // "no" for a value the scope in fact permits.
1188        let scope = AccessScope::allow_all();
1189
1190        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1191        assert!(!scope.has_property(pep_properties::OWNER_TENANT_ID));
1192        assert!(
1193            scope
1194                .all_uuid_values_for(pep_properties::OWNER_TENANT_ID)
1195                .is_empty()
1196        );
1197
1198        // `is_unconstrained` is the only accessor that tells the two apart, and
1199        // it is what a caller must consult — a deny-all scope reports exactly
1200        // the same emptiness while permitting nothing.
1201        assert!(scope.is_unconstrained());
1202        assert!(!AccessScope::deny_all().is_unconstrained());
1203    }
1204
1205    #[test]
1206    fn an_empty_constraint_is_refused() {
1207        // An AND over no filters is TRUE, so this shape is an allow-all
1208        // disjunct -- while the scope carrying it still reports itself as
1209        // neither unconstrained nor deny-all, so nothing downstream sees that
1210        // it grants everything.
1211        assert_eq!(
1212            ScopeConstraint::try_new(vec![]).unwrap_err(),
1213            EmptyScopeConstraint
1214        );
1215
1216        assert!(
1217            ScopeConstraint::try_new(vec![ScopeFilter::eq(
1218                pep_properties::OWNER_TENANT_ID,
1219                uid(T1)
1220            )])
1221            .is_ok(),
1222            "one filter is enough to narrow"
1223        );
1224    }
1225
1226    #[test]
1227    #[should_panic(expected = "must carry at least one filter")]
1228    fn the_infallible_constructor_refuses_an_empty_list_too() {
1229        // `new` is for literals, where an empty list is a bug in the caller
1230        // rather than a condition to handle -- but it must not quietly produce
1231        // the allow-all shape either.
1232        drop(ScopeConstraint::new(vec![]));
1233    }
1234
1235    #[test]
1236    fn a_deny_all_scope_permits_nothing() {
1237        let scope = AccessScope::deny_all();
1238        assert!(scope.is_deny_all());
1239        assert!(!scope.is_unconstrained());
1240        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1241    }
1242
1243    // --- tenant_only ---
1244
1245    #[test]
1246    fn tenant_only_strips_owner_id() {
1247        let scope = AccessScope::single(ScopeConstraint::new(vec![
1248            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1249            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
1250        ]));
1251
1252        let tenant_scope = scope.tenant_only();
1253        assert!(tenant_scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1254        assert!(!tenant_scope.has_property(pep_properties::OWNER_ID));
1255    }
1256
1257    #[test]
1258    fn tenant_only_widens_a_resource_scoped_grant_to_the_whole_tenant() {
1259        // Pinning the sharp edge rather than the happy path: removing a term
1260        // from a conjunction admits everything that term excluded. This is what
1261        // makes `tenant_only()` safe only when re-targeting the scope at an
1262        // entity the removed terms never applied to -- and unsafe as the sole
1263        // authorization for the same entity.
1264        let scope = AccessScope::single(ScopeConstraint::new(vec![
1265            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1266            ScopeFilter::eq(pep_properties::RESOURCE_ID, uid(T2)),
1267        ]));
1268
1269        let tenant_scope = scope.tenant_only();
1270        assert!(
1271            !tenant_scope.has_property(pep_properties::RESOURCE_ID),
1272            "the resource narrowing is gone, so this grant now covers the tenant"
1273        );
1274        assert!(tenant_scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1275    }
1276
1277    #[test]
1278    fn tenant_only_unconstrained_becomes_deny_all() {
1279        let scope = AccessScope::allow_all();
1280        let tenant_scope = scope.tenant_only();
1281        assert!(tenant_scope.is_deny_all());
1282    }
1283
1284    #[test]
1285    fn tenant_only_deny_all_when_no_tenant_filters() {
1286        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1287            pep_properties::OWNER_ID,
1288            uid(T1),
1289        )]));
1290
1291        let tenant_scope = scope.tenant_only();
1292        assert!(tenant_scope.is_deny_all());
1293    }
1294
1295    #[test]
1296    fn tenant_only_on_deny_all_stays_deny_all() {
1297        let scope = AccessScope::deny_all();
1298        let tenant_scope = scope.tenant_only();
1299        assert!(tenant_scope.is_deny_all());
1300    }
1301
1302    // --- tenant_and_owner ---
1303
1304    #[test]
1305    fn tenant_and_owner_keeps_both_properties() {
1306        let scope = AccessScope::single(ScopeConstraint::new(vec![
1307            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1308            ScopeFilter::eq(pep_properties::OWNER_ID, uid(T2)),
1309            ScopeFilter::eq(pep_properties::RESOURCE_ID, uid(T1)),
1310        ]));
1311
1312        let narrowed = scope.tenant_and_owner();
1313        assert!(narrowed.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1314        assert!(narrowed.contains_uuid(pep_properties::OWNER_ID, uid(T2)));
1315        assert!(!narrowed.has_property(pep_properties::RESOURCE_ID));
1316    }
1317
1318    #[test]
1319    fn tenant_and_owner_unconstrained_becomes_deny_all() {
1320        let scope = AccessScope::allow_all();
1321        assert!(scope.tenant_and_owner().is_deny_all());
1322    }
1323
1324    #[test]
1325    fn tenant_and_owner_deny_all_when_no_matching_filters() {
1326        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1327            pep_properties::RESOURCE_ID,
1328            uid(T1),
1329        )]));
1330        assert!(scope.tenant_and_owner().is_deny_all());
1331    }
1332
1333    // --- ensure_owner ---
1334
1335    #[test]
1336    fn ensure_owner_adds_owner_when_missing() {
1337        let scope = AccessScope::for_tenant(uid(T1));
1338        let owner_id = uid(T2);
1339
1340        let scoped = scope.ensure_owner(owner_id);
1341        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1342        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
1343    }
1344
1345    #[test]
1346    fn ensure_owner_keeps_existing_owner() {
1347        let existing_owner = uid(T2);
1348        let scope = AccessScope::single(ScopeConstraint::new(vec![
1349            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1350            ScopeFilter::eq(pep_properties::OWNER_ID, existing_owner),
1351        ]));
1352
1353        let scoped = scope.ensure_owner(existing_owner);
1354        assert_eq!(
1355            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1356            &[existing_owner]
1357        );
1358    }
1359
1360    #[test]
1361    fn ensure_owner_on_unconstrained_creates_owner_scope() {
1362        let scope = AccessScope::allow_all();
1363        let owner_id = uid(T1);
1364
1365        let scoped = scope.ensure_owner(owner_id);
1366        assert!(!scoped.is_unconstrained());
1367        assert!(scoped.contains_uuid(pep_properties::OWNER_ID, owner_id));
1368    }
1369
1370    #[test]
1371    fn ensure_owner_on_deny_all_stays_deny_all() {
1372        let scope = AccessScope::deny_all();
1373        let scoped = scope.ensure_owner(uid(T1));
1374        assert!(scoped.is_deny_all());
1375    }
1376
1377    #[test]
1378    fn ensure_owner_narrows_existing_owner_to_subject() {
1379        let user_a = uid(T1);
1380        let user_b = uid(T2);
1381        let scope = AccessScope::single(ScopeConstraint::new(vec![
1382            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1383            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![user_a, user_b]),
1384        ]));
1385
1386        let scoped = scope.ensure_owner(user_a);
1387        assert_eq!(
1388            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1389            &[user_a],
1390            "Must narrow to exactly the subject's owner_id"
1391        );
1392        assert!(scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1393    }
1394
1395    #[test]
1396    fn ensure_owner_drops_constraint_when_subject_not_in_pdp() {
1397        let user_x = uid(T1);
1398        let user_y = uid(T2);
1399        let scope = AccessScope::single(ScopeConstraint::new(vec![
1400            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, uid(T1)),
1401            ScopeFilter::eq(pep_properties::OWNER_ID, user_x),
1402        ]));
1403
1404        let scoped = scope.ensure_owner(user_y);
1405        assert!(
1406            scoped.is_deny_all(),
1407            "Must be deny-all when subject not in PDP's owner set"
1408        );
1409    }
1410
1411    #[test]
1412    fn ensure_owner_checks_all_owner_filters_in_constraint() {
1413        let alice = uid(T1);
1414        let bob = uid(T2);
1415        // Contrived: two owner_id filters in one constraint.
1416        // alice is in the first but not the second → must be dropped.
1417        let scope = AccessScope::single(ScopeConstraint::new(vec![
1418            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![alice, bob]),
1419            ScopeFilter::in_uuids(pep_properties::OWNER_ID, vec![bob]),
1420        ]));
1421
1422        let scoped = scope.ensure_owner(alice);
1423        assert!(
1424            scoped.is_deny_all(),
1425            "Must deny when subject is missing from any owner_id filter"
1426        );
1427
1428        // bob is in both → should pass and narrow to Eq.
1429        let scoped = scope.ensure_owner(bob);
1430        assert!(!scoped.is_deny_all());
1431        assert_eq!(
1432            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1433            &[bob],
1434            "Must narrow to single Eq for the matching owner"
1435        );
1436    }
1437
1438    #[test]
1439    fn ensure_owner_multi_constraint_keeps_only_matching() {
1440        let alice = uid(T1);
1441        let bob = uid(T2);
1442        let tenant = uid(T1);
1443
1444        // Constraint 1: tenant + alice → matches alice
1445        let c1 = ScopeConstraint::new(vec![
1446            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
1447            ScopeFilter::eq(pep_properties::OWNER_ID, alice),
1448        ]);
1449        // Constraint 2: tenant + bob → does NOT match alice
1450        let c2 = ScopeConstraint::new(vec![
1451            ScopeFilter::eq(pep_properties::OWNER_TENANT_ID, tenant),
1452            ScopeFilter::eq(pep_properties::OWNER_ID, bob),
1453        ]);
1454
1455        let scope = AccessScope::from_constraints(vec![c1, c2]);
1456        let scoped = scope.ensure_owner(alice);
1457
1458        assert!(
1459            !scoped.is_deny_all(),
1460            "Must not be deny-all - one constraint matches"
1461        );
1462        assert_eq!(
1463            scoped.all_uuid_values_for(pep_properties::OWNER_ID),
1464            &[alice],
1465            "Must keep only the constraint matching alice"
1466        );
1467        assert!(
1468            scoped.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant),
1469            "Tenant filter must be preserved"
1470        );
1471    }
1472
1473    // --- ScopeFilter::InGroup ---
1474
1475    #[test]
1476    fn scope_filter_in_group_constructor() {
1477        let f = ScopeFilter::in_group(
1478            pep_properties::OWNER_TENANT_ID,
1479            vec![ScopeValue::Uuid(uid(T1))],
1480        );
1481        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
1482        assert!(matches!(f, ScopeFilter::InGroup(_)));
1483        assert_eq!(f.values().iter().count(), 0);
1484    }
1485
1486    // --- ScopeFilter::InGroupSubtree ---
1487
1488    #[test]
1489    fn scope_filter_in_group_subtree_constructor() {
1490        let f = ScopeFilter::in_group_subtree(
1491            pep_properties::OWNER_TENANT_ID,
1492            vec![ScopeValue::Uuid(uid(T1))],
1493        );
1494        assert_eq!(f.property(), pep_properties::OWNER_TENANT_ID);
1495        assert!(matches!(f, ScopeFilter::InGroupSubtree(_)));
1496        assert_eq!(f.values().iter().count(), 0);
1497    }
1498
1499    #[test]
1500    fn in_group_scope_contains_uuid_returns_false() {
1501        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_group(
1502            pep_properties::OWNER_TENANT_ID,
1503            vec![ScopeValue::Uuid(uid(T1))],
1504        )]));
1505        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1506    }
1507
1508    #[test]
1509    fn in_group_subtree_scope_contains_uuid_returns_false() {
1510        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_group_subtree(
1511            pep_properties::OWNER_TENANT_ID,
1512            vec![ScopeValue::Uuid(uid(T1))],
1513        )]));
1514        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1515    }
1516
1517    // --- ScopeFilter::InTenantSubtree ---
1518
1519    #[test]
1520    fn scope_filter_in_tenant_subtree_constructor_defaults_to_respect() {
1521        let f = ScopeFilter::in_tenant_subtree(
1522            pep_properties::RESOURCE_ID,
1523            ScopeValue::Uuid(uid(T1)),
1524            true,
1525            Vec::new(),
1526        );
1527        assert_eq!(f.property(), pep_properties::RESOURCE_ID);
1528        assert!(matches!(f, ScopeFilter::InTenantSubtree(_)));
1529        assert_eq!(f.values().iter().count(), 0);
1530        match &f {
1531            ScopeFilter::InTenantSubtree(sf) => {
1532                assert!(sf.respect_barriers());
1533                assert!(sf.descendant_status().is_empty());
1534            }
1535            other => panic!("unexpected variant: {other:?}"),
1536        }
1537    }
1538
1539    #[test]
1540    fn in_tenant_subtree_scope_contains_uuid_returns_false() {
1541        let scope =
1542            AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::in_tenant_subtree(
1543                pep_properties::RESOURCE_ID,
1544                ScopeValue::Uuid(uid(T1)),
1545                true,
1546                Vec::new(),
1547            )]));
1548        assert!(!scope.contains_uuid(pep_properties::RESOURCE_ID, uid(T1)));
1549    }
1550
1551    #[test]
1552    fn in_tenant_subtree_scope_filter_carries_descendant_status() {
1553        let filter =
1554            ScopeFilter::InTenantSubtree(InTenantSubtreeScopeFilter::with_descendant_status(
1555                pep_properties::OWNER_TENANT_ID,
1556                ScopeValue::Uuid(uid(T1)),
1557                true,
1558                vec![ScopeValue::Int(1), ScopeValue::Int(2)],
1559            ));
1560
1561        // The status list must survive into the filter...
1562        let ScopeFilter::InTenantSubtree(inner) = &filter else {
1563            panic!("constructed as InTenantSubtree");
1564        };
1565        assert_eq!(
1566            inner.descendant_status(),
1567            &[ScopeValue::Int(1), ScopeValue::Int(2)]
1568        );
1569
1570        // ...while `values()` stays empty, because this variant resolves as a
1571        // subquery in SQL and exposes nothing to match against in memory. That
1572        // pairing is what the filter promises; reading the field back alone
1573        // would pass even if the variant started leaking values.
1574        assert_eq!(filter.values().iter().count(), 0);
1575        assert!(!filter.is_representable_in_memory());
1576    }
1577
1578    #[test]
1579    fn in_tenant_subtree_scope_filter_exposes_property_and_root() {
1580        let f =
1581            InTenantSubtreeScopeFilter::new(pep_properties::RESOURCE_ID, ScopeValue::Uuid(uid(T1)));
1582        assert_eq!(f.property(), pep_properties::RESOURCE_ID);
1583        assert_eq!(f.root_tenant_id(), &ScopeValue::Uuid(uid(T1)));
1584        assert!(f.respect_barriers());
1585    }
1586
1587    #[test]
1588    fn in_tenant_subtree_scope_filter_ignore_barriers_constructor() {
1589        let f = InTenantSubtreeScopeFilter::with_respect_barriers(
1590            pep_properties::OWNER_TENANT_ID,
1591            ScopeValue::Uuid(uid(T1)),
1592            false,
1593        );
1594        assert!(!f.respect_barriers());
1595    }
1596
1597    // `tenant_tables_constants_are_stable` used to live here, comparing each
1598    // constant to the literal it was defined as a few hundred lines above --
1599    // which can only fail if someone edits one and forgets the other. The
1600    // constants themselves have since moved to `toolkit-db`, next to the code
1601    // that emits SQL against those tables.
1602
1603    // --- contains_uuid string matching ---
1604
1605    #[test]
1606    fn contains_uuid_matches_string_variant() {
1607        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1608            pep_properties::OWNER_TENANT_ID,
1609            ScopeValue::String(T1.to_owned()),
1610        )]));
1611        assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1612        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T2)));
1613    }
1614
1615    #[test]
1616    fn contains_uuid_does_not_match_invalid_string() {
1617        let scope = AccessScope::single(ScopeConstraint::new(vec![ScopeFilter::eq(
1618            pep_properties::OWNER_TENANT_ID,
1619            ScopeValue::String("not-a-uuid".to_owned()),
1620        )]));
1621        assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uid(T1)));
1622    }
1623}