Skip to content
Open
Show file tree
Hide file tree
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
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Change Log
Unreleased
**********

1.25.0 - 2026-09-24
*******************

Changed
=======

* Filtering assignments by scope now respects the hierarchy: querying any course or library scope also returns assignments from its ancestor org-level and platform-level scopes.

1.24.0 - 2026-09-14
*******************

Expand Down
94 changes: 94 additions & 0 deletions src/openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,75 @@ def IS_GLOB(self) -> bool:
"""Whether this scope represents a glob pattern (org- or platform-level)."""
return self.IS_ORG_GLOB or self.IS_PLATFORM_GLOB

@property
def ancestors(self) -> set[str]:
"""External keys of the scopes that hierarchically contain this one.

The scope hierarchy is::

specific resource -> organization glob -> platform glob

A concrete scope is contained by the org-level glob of its organization and by
the platform-level glob of its namespace. Subclasses that already sit higher in
the hierarchy override this property to report their own ancestors.

Returns:
set[str]: The external keys of the scopes containing this one. Empty when the
scope has no ancestors.

Examples:
>>> ScopeData(external_key='course-v1:DemoX+CS101+2024').ancestors
{'course-v1:DemoX+*', 'course-v1:*'}
>>> ScopeData(external_key='lib:DemoX:CSPROB').ancestors
{'lib:DemoX:*', 'lib:*'}
"""
namespace = type(self).NAMESPACE
ancestor_keys: set[str] = set()

# The base ScopeData has no 'org'; only concrete resource scopes derive one.
org = getattr(self, "org", None)
if org: # pragma: no branch
org_glob_cls = type(self).org_glob_registry.get(namespace)
if org_glob_cls:
ancestor_keys.add(org_glob_cls.build_external_key(org))

platform_glob_cls = type(self).platform_glob_registry.get(namespace)
if platform_glob_cls: # pragma: no branch
ancestor_keys.add(platform_glob_cls.build_external_key())

return ancestor_keys

@classmethod
def expand_keys_with_ancestors(cls, external_keys: list[str]) -> set[str]:
"""Expand scope external keys to include their hierarchical ancestors.

For each key, the org-level and platform-level ancestor scopes that also apply
according to the scope hierarchy are added to the result. Keys that cannot be
resolved to a registered scope type are kept as-is and not expanded, preserving
exact-match behaviour for unknown keys.

Args:
external_keys (list[str]): The scope external keys to expand.

Returns:
set[str]: The original keys plus all applicable ancestors.

Examples:
>>> ScopeData.expand_keys_with_ancestors(['course-v1:DemoX+CS101+2024'])
{'course-v1:DemoX+CS101+2024', 'course-v1:DemoX+*', 'course-v1:*'}
"""
expanded: set[str] = set(external_keys)

for external_key in external_keys:
try:
scope = ScopeData(external_key=external_key)
except ValueError:
# Unrecognised scope format - keep the original, skip expansion.
continue
expanded |= scope.ancestors

return expanded

@classmethod
def validate_external_key(cls, _: str) -> bool:
"""Validate the external_key format for ScopeData.
Expand Down Expand Up @@ -864,6 +933,20 @@ def org(self) -> str | None:
"""
return self.get_org(self.external_key)

@property
def ancestors(self) -> set[str]:
"""External keys of the scopes that hierarchically contain this one.

An organization-level glob is contained only by the platform-level glob of its
namespace (e.g., ``lib:DemoX:*`` is contained by ``lib:*``).

Returns:
set[str]: The platform-level glob external key, or an empty set when the
namespace has no registered platform-level glob.
"""
platform_glob_cls = type(self).platform_glob_registry.get(type(self).NAMESPACE)
return {platform_glob_cls.build_external_key()} if platform_glob_cls else set()

@classmethod
def validate_external_key(cls, external_key: str) -> bool:
"""Validate the external_key format for organization-level glob patterns.
Expand Down Expand Up @@ -1118,6 +1201,17 @@ class PlatformGlobData(ScopeData):
NAMESPACE: ClassVar[str] = "platform"
IS_PLATFORM_GLOB: ClassVar[bool] = True

@property
def ancestors(self) -> set[str]:
"""External keys of the scopes that hierarchically contain this one.

Platform-level globs sit at the top of the hierarchy, so they have no ancestors.

Returns:
set[str]: Always an empty set.
"""
return set()

@classmethod
def validate_external_key(cls, external_key: str) -> bool:
"""Validate the external_key format for platform-level glob patterns.
Expand Down
8 changes: 7 additions & 1 deletion src/openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ def _filter_candidate_assignments_by_params(
and are applied only when provided. This runs before the scope-based authorization
pass to avoid paying the DB cost for assignments that would be dropped anyway.

When filtering by scope, the hierarchy is respected: assignments at higher levels
(org-level and platform-level globs) that apply to the queried scope are also
included. For example, filtering by ``course-v1:OpenedX+DemoX+DemoCourse`` will
also keep assignments scoped to ``course-v1:OpenedX+*`` and ``course-v1:*``.

Args:
assignments: The full assignment list to filter. Each entry has exactly one role
(one policy line), as produced by get_role_assignments.
Expand All @@ -320,7 +325,8 @@ def _filter_candidate_assignments_by_params(
The filtered assignment list.
"""
if scopes:
assignments = [a for a in assignments if a.scope.external_key in scopes]
expanded_scopes = ScopeData.expand_keys_with_ancestors(scopes)
assignments = [a for a in assignments if a.scope.external_key in expanded_scopes]
if orgs:
assignments = [a for a in assignments if getattr(a.scope, "org", None) in orgs]
if roles:
Expand Down
17 changes: 17 additions & 0 deletions src/openedx_authz/tests/api/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
PlatformCourseOverviewGlobData,
RoleAssignmentData,
RoleData,
ScopeData,
UserData,
)
from openedx_authz.api.users import (
Expand Down Expand Up @@ -939,3 +940,19 @@ def test_prefilter_is_applied_before_authorization(self):
for a in authorized:
self.assertEqual(getattr(a.scope, "org", None), "Org1")
self.assertTrue(any(r.external_key == "library_admin" for r in a.roles))


class TestExpandScopesWithAncestors(UserAssignmentsSetupMixin):
"""Unit tests for ScopeData.expand_keys_with_ancestors."""

def test_unrecognized_scope_format_is_kept_without_expansion(self):
"""A scope key that cannot be resolved keeps the original and skips expansion.

This covers the ``except ValueError`` branch in expand_keys_with_ancestors
where ``ScopeData(external_key=...)`` raises because the key format is
invalid or the namespace is unknown.
"""
bogus_scope = "unknown-namespace:some-value"
result = ScopeData.expand_keys_with_ancestors([bogus_scope])

self.assertEqual(result, {bogus_scope})
Loading
Loading