Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/permissions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,17 @@ impl ScopeSet {
self.inner.insert(scope)
}

#[must_use]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the result is the implication-expanded set, not a subset of the raw scopes. {SitesAdmin}.intersection(&full_session()) stores sites:admin sites:write sites:read, so iter() / to_oidc_scope_string() emit more entries than either input and the result is not == to the original. has() answers are correct (the implication table is transitively closed), so this is not a widening. The test named intersecting_with_a_full_session_changes_nothing only covers a leaf scope and would fail for SitesAdmin under ==. Worth documenting at least, since every other pub fn here has a doc comment:

Suggested change
#[must_use]
/// Scopes granted by both sets, implication-aware: a scope is kept
/// if `self.has(scope) && other.has(scope)`. The result is stored
/// expanded (e.g. `SitesAdmin` on both sides yields admin, write and
/// read), so `iter()` and the OIDC string list every implied level.
#[must_use]

pub fn intersection(&self, other: &Self) -> Self {
Self {
inner: Scope::all()
.iter()
.copied()
.filter(|&scope| self.has(scope) && other.has(scope))
.collect(),
}
}

/// Iterator over the granted scopes, sorted for deterministic
/// output in JWT claims and audit logs.
pub fn iter(&self) -> impl Iterator<Item = Scope> + '_ {
Expand Down Expand Up @@ -464,6 +475,50 @@ impl AccessDenied {

#[cfg(test)]
mod tests {

fn set(scopes: &[Scope]) -> ScopeSet {
let mut set = ScopeSet::empty();
for &scope in scopes {
set.insert(scope);
}
set
}

#[test]
fn intersection_keeps_only_what_both_sides_grant() {
let token = set(&[Scope::SitesAdmin, Scope::TokensManage]);
let role = set(&[Scope::SitesWrite]);
let granted = token.intersection(&role);
assert!(granted.has(Scope::SitesWrite));
assert!(granted.has(Scope::SitesRead));
assert!(!granted.has(Scope::SitesAdmin));
assert!(!granted.has(Scope::TokensManage));
}

#[test]
fn intersecting_with_a_full_session_changes_nothing() {
let token = set(&[Scope::SitesRead]);
assert_eq!(
token
.intersection(&ScopeSet::full_session())
.iter()
.filter(|s| s.as_str().starts_with("sites:"))
.collect::<Vec<_>>(),
vec![Scope::SitesRead]
);
assert!(
!token
.intersection(&ScopeSet::full_session())
.has(Scope::SitesWrite)
);
}

#[test]
fn intersecting_with_nothing_grants_nothing() {
let granted = ScopeSet::full_session().intersection(&ScopeSet::empty());
assert!(Scope::all().iter().all(|&scope| !granted.has(scope)));
}

use super::*;

#[test]
Expand Down
Loading