Skip to content

Commit 4ccd60e

Browse files
mesemusRonald Krist
andcommitted
feat: SameAs generator
* Changed permission policy's context to propagate policy instance to generators * Added CompositeGenerator class * Added implementation of SameAs with tests Co-authored-by: Ronald Krist <ronald.krist@cesnet.cz>
1 parent 1319da7 commit 4ccd60e

File tree

5 files changed

+496
-9
lines changed

5 files changed

+496
-9
lines changed

invenio_records_permissions/generators.py

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright (C) 2019-2023 CERN.
44
# Copyright (C) 2019-2020 Northwestern University.
55
# Copyright (C) 2024 Ubiquity Press.
6+
# Copyright (C) 2026 CESNET z.s.p.o.
67
#
78
# Invenio-Records-Permissions is free software; you can redistribute it
89
# and/or modify it under the terms of the MIT License; see LICENSE file for
@@ -11,12 +12,12 @@
1112
"""Invenio Records Permissions Generators."""
1213

1314
import operator
14-
from abc import abstractmethod
15+
from abc import ABC, abstractmethod
1516
from functools import reduce
1617
from itertools import chain
1718

1819
from flask import current_app
19-
from flask_principal import ActionNeed, UserNeed
20+
from flask_principal import UserNeed
2021
from invenio_access import ActionRoles, ActionUsers, Permission
2122
from invenio_access.permissions import (
2223
any_user,
@@ -225,7 +226,7 @@ def query_filter(self, identity=None, **kwargs):
225226
"id": id_need.value,
226227
# TODO: Implement other schemes
227228
}
228-
}
229+
},
229230
)
230231
for access_level in read_levels
231232
]
@@ -339,3 +340,132 @@ def _condition(self, **_):
339340
# | False | False | Open | True |
340341
# |-----------------|------------------|--------------|--------|
341342
#
343+
344+
345+
class CompositeGenerator(Generator, ABC):
346+
"""Base class for generators that compose multiple generators.
347+
348+
This generator implements the Composite design pattern, allowing you to combine
349+
multiple generators and treat them as a single generator. Subclasses must implement
350+
the ``_generators(**context)`` method to return the list of generators to compose.
351+
352+
Behavior:
353+
- ``needs``: Combines (flattens) the needs from all composed generators
354+
- ``excludes``: Combines (flattens) the excludes from all composed generators
355+
- ``query_filter``: Combines query filters using OR logic (any filter matches)
356+
"""
357+
358+
@abstractmethod
359+
def _generators(self, **context):
360+
"""Return the list of generators to compose.
361+
362+
Must be implemented by subclasses.
363+
364+
:param context: Context dictionary that may contain record, etc.
365+
:returns: List of Generator instances to compose
366+
"""
367+
raise NotImplementedError # pragma: no cover
368+
369+
def needs(self, **context):
370+
"""Get enabling needs from all composed generators.
371+
372+
Combines needs from all generators into a single flattened list.
373+
"""
374+
needs = [
375+
generator.needs(**context) for generator in self._generators(**context)
376+
]
377+
return list(chain.from_iterable(needs))
378+
379+
def excludes(self, **context):
380+
"""Get preventing needs from all composed generators.
381+
382+
Combines excludes from all generators into a single flattened list.
383+
"""
384+
excludes = [
385+
generator.excludes(**context) for generator in self._generators(**context)
386+
]
387+
return list(chain.from_iterable(excludes))
388+
389+
def query_filter(self, **context):
390+
"""Get search filters from all composed generators.
391+
392+
Combines query filters from all generators using OR logic. This means a record
393+
matches if it satisfies ANY of the composed generator's filters.
394+
395+
:returns: Combined query using OR, or match_none if no generators provide filters
396+
"""
397+
generators = self._generators(**context)
398+
399+
queries = [g.query_filter(**context) for g in generators]
400+
queries = [q for q in queries if q]
401+
if not queries:
402+
return dsl.Q("match_none")
403+
404+
return reduce(operator.or_, queries) if queries else None
405+
406+
407+
class SameAs(CompositeGenerator):
408+
"""Generator that delegates permissions to another permission on the same policy.
409+
410+
This generator allows you to reuse the permission configuration from one action
411+
for another action, promoting DRY (Don't Repeat Yourself) principles. It dynamically
412+
retrieves the generators from the specified permission attribute on the policy.
413+
414+
This is particularly useful when:
415+
- Multiple actions should have identical permission requirements
416+
- You want permission inheritance when subclassing policies
417+
- You need to maintain a single source of truth for related permissions
418+
419+
Example:
420+
.. code-block:: python
421+
422+
class RecordPermissionPolicy(BasePermissionPolicy):
423+
can_edit = [RecordOwners(), AdminAction("admin-access")]
424+
can_delete = [SameAs("can_edit")] # Delegates to can_edit
425+
can_create_files = [SameAs("can_edit")] # Also delegates to can_edit
426+
427+
In this example, ``can_delete`` and ``can_create_files`` will have the exact
428+
same permissions as ``can_edit``. If you later modify ``can_edit`` or override
429+
it in a subclass, the delegating permissions automatically inherit those changes.
430+
431+
Note:
432+
The permission name must be an attribute on the policy instance and must
433+
contain a list of generators.
434+
"""
435+
436+
def __init__(self, permission_name: str) -> None:
437+
"""Initialize the generator.
438+
439+
:param permission_name: Name of the permission attribute to delegate to.
440+
Must be in the format "can_<action>" (e.g., "can_edit", "can_read").
441+
This attribute must exist on the permission policy and contain a list of generators.
442+
"""
443+
self._delegated_permission_name = permission_name
444+
445+
def _generators(self, **context):
446+
"""Get the generators from the delegated permission on the policy.
447+
448+
:param context: Must contain 'permission_policy' key with the policy instance
449+
:returns: List of generators from the delegated permission
450+
"""
451+
policy = context["permission_policy"]
452+
return getattr(policy, self._delegated_permission_name)
453+
454+
def __add__(self, other):
455+
"""Allow adding another generator or list of generators to this SameAs generator.
456+
457+
Example:
458+
can_delete = SameAs("can_edit") + [RecordOwners()]
459+
"""
460+
if isinstance(other, (tuple, list, set)):
461+
return [self] + list(other)
462+
else:
463+
return [self, other]
464+
465+
def __iter__(self):
466+
"""Let the SameAs be used as the sole element inside the can_abc properties.
467+
468+
Example:
469+
can_delete = SameAs("can_edit")
470+
"""
471+
return iter([self])

invenio_records_permissions/policies/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ def __init__(self, action, **over):
5858
"""Constructor."""
5959
super().__init__()
6060
self.action = action
61-
self.over = over
61+
62+
# adding self to the permission policy context for generators to use
63+
self.over = {
64+
**over,
65+
"permission_policy": self,
66+
}
6267

6368
@property
6469
def generators(self):

invenio_records_permissions/policies/records.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414
from werkzeug.utils import import_string
1515

1616
from ..errors import UnknownGeneratorError
17-
from ..generators import AnyUser, AnyUserIfPublic, Disable, RecordOwners, SystemProcess
17+
from ..generators import (
18+
AnyUser,
19+
AnyUserIfPublic,
20+
Disable,
21+
RecordOwners,
22+
SameAs,
23+
SystemProcess,
24+
)
1825
from .base import BasePermissionPolicy
1926

2027

@@ -43,7 +50,14 @@ def obj_or_import_string(value, default=None):
4350

4451

4552
class RecordPermissionPolicy(BasePermissionPolicy):
46-
"""Access control configuration for records."""
53+
"""Access control configuration for records.
54+
55+
can_read and can_update are the main permissions.
56+
can_delete, can_update_files are delegating to can_update,
57+
but can be overridden if needed.
58+
can_read_files is delegating to can_read, but can be overridden if needed.
59+
)
60+
"""
4761

4862
NEED_LABEL_TO_ACTION = {
4963
"bucket-update": "update_files",
@@ -57,15 +71,15 @@ class RecordPermissionPolicy(BasePermissionPolicy):
5771
# be used.
5872
can_create = [Disable()]
5973
# Read access given to everyone if public record/files and owners always.
60-
can_read = [AnyUserIfPublic(), RecordOwners()]
74+
can_read = [AnyUserIfPublic(), SameAs("can_update")]
6175
# Update access given to record owners.
6276
can_update = [RecordOwners()]
6377
# Delete access given to superuser-access action only
6478
# (superuser-access is added by default by base policy)
6579
can_delete = []
6680
# Associated files permissions (which are really bucket permissions)
67-
can_read_files = [AnyUserIfPublic(), RecordOwners()]
68-
can_update_files = [RecordOwners()]
81+
can_read_files = [SameAs("can_read")]
82+
can_update_files = [SameAs("can_update")]
6983
can_read_deleted_files = []
7084
can_create_or_update_many = [SystemProcess()]
7185

tests/test_composite_generator.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2026 CESNET z.s.p.o.
4+
#
5+
# Invenio-Records-Permissions is free software; you can redistribute it
6+
# and/or modify it under the terms of the MIT License; see LICENSE file for
7+
# more details.
8+
"""Test CompositeGenerator."""
9+
10+
import json
11+
12+
from flask_principal import Need
13+
from invenio_access.permissions import any_user, authenticated_user
14+
15+
from invenio_records_permissions.generators import (
16+
AnyUser,
17+
AnyUserIfPublic,
18+
AuthenticatedUser,
19+
CompositeGenerator,
20+
Disable,
21+
RecordOwners,
22+
)
23+
24+
25+
class StaticCompositeGenerator(CompositeGenerator):
26+
"""Test implementation that returns a static list of generators."""
27+
28+
def __init__(self, generators):
29+
"""Constructor."""
30+
self._generator_list = generators
31+
32+
def _generators(self, **context):
33+
"""Return the static list of generators."""
34+
return self._generator_list
35+
36+
37+
def stable_dict_sort(data):
38+
"""Recursively sort dictionaries and lists for stable comparison.
39+
40+
Dictionaries are re-created with sorted keys, and lists are sorted by their JSON representation.
41+
This ensures that we can compare the output of query_filter regardless
42+
of the order of keys in dictionaries or items in lists.
43+
44+
Note: this is intended just for tests as can be slow due to the multiple
45+
json dumps for each comparison.
46+
"""
47+
if isinstance(data, dict):
48+
return {k: stable_dict_sort(v) for k, v in sorted(data.items())}
49+
elif isinstance(data, list):
50+
deep_sorted = [stable_dict_sort(x) for x in data]
51+
return sorted(deep_sorted, key=lambda x: json.dumps(x, sort_keys=True))
52+
else:
53+
return data
54+
55+
56+
def test_composite_generator_empty_list():
57+
"""Test CompositeGenerator with empty generator list."""
58+
generator = StaticCompositeGenerator([])
59+
60+
assert generator.needs() == []
61+
assert generator.excludes() == []
62+
assert generator.query_filter().to_dict() == {"match_none": {}}
63+
64+
65+
def test_composite_generator_single_generator():
66+
"""Test CompositeGenerator with a single generator."""
67+
generator = StaticCompositeGenerator([AnyUser()])
68+
69+
assert generator.needs() == [any_user]
70+
assert generator.excludes() == []
71+
assert generator.query_filter().to_dict() == {"match_all": {}}
72+
73+
74+
def test_composite_generator_multiple_generators_needs(create_record):
75+
"""Test CompositeGenerator combines needs from multiple generators."""
76+
from flask_principal import UserNeed
77+
78+
record = create_record()
79+
generator = StaticCompositeGenerator([AnyUser(), RecordOwners()])
80+
81+
needs = generator.needs(record=record)
82+
assert any_user in needs
83+
assert UserNeed(1) in needs
84+
assert UserNeed(2) in needs
85+
assert UserNeed(3) in needs
86+
assert len(needs) == 4
87+
88+
89+
def test_composite_generator_multiple_generators_excludes():
90+
"""Test CompositeGenerator combines excludes from multiple generators."""
91+
generator = StaticCompositeGenerator([Disable()])
92+
93+
excludes = generator.excludes()
94+
assert excludes == [any_user]
95+
96+
97+
def test_composite_generator_query_filter_or_logic(mocker):
98+
"""Test CompositeGenerator combines query filters with OR logic."""
99+
generator = StaticCompositeGenerator([AnyUserIfPublic(), RecordOwners()])
100+
101+
# Test with identity
102+
identity = mocker.Mock(provides=[Need(method="id", value=1)])
103+
query_filter = generator.query_filter(identity=identity)
104+
105+
query_dict = query_filter.to_dict()
106+
assert stable_dict_sort(query_dict) == {
107+
"bool": {
108+
"should": [
109+
{"term": {"_access.metadata_restricted": False}},
110+
{"term": {"owners": 1}},
111+
]
112+
}
113+
}
114+
115+
116+
def test_composite_generator_query_filter_single_result():
117+
"""Test CompositeGenerator with only one generator providing query filter."""
118+
generator = StaticCompositeGenerator([AnyUser()])
119+
120+
query_filter = generator.query_filter()
121+
assert query_filter.to_dict() == {"match_all": {}}
122+
123+
124+
def test_composite_generator_query_filter_all_empty(mocker):
125+
"""Test CompositeGenerator when no generator provides query filter."""
126+
127+
class EmptyFilterGenerator(AnyUser):
128+
def query_filter(self, **kwargs):
129+
return []
130+
131+
generator = StaticCompositeGenerator([EmptyFilterGenerator()])
132+
query_filter = generator.query_filter()
133+
assert query_filter.to_dict() == {"match_none": {}}
134+
135+
136+
def test_composite_generator_with_context(create_record):
137+
"""Test CompositeGenerator passes context to all generators."""
138+
from flask_principal import UserNeed
139+
140+
record = create_record()
141+
generator = StaticCompositeGenerator([RecordOwners(), AnyUserIfPublic()])
142+
143+
needs = generator.needs(record=record)
144+
assert UserNeed(1) in needs
145+
assert UserNeed(2) in needs
146+
assert UserNeed(3) in needs
147+
assert any_user in needs
148+
149+
150+
def test_composite_generator_mixed_generators(create_record, mocker):
151+
"""Test CompositeGenerator with various generator types."""
152+
from flask_principal import UserNeed
153+
154+
record = create_record()
155+
generator = StaticCompositeGenerator(
156+
[AnyUser(), RecordOwners(), AuthenticatedUser()]
157+
)
158+
159+
# Test needs
160+
needs = generator.needs(record=record)
161+
assert any_user in needs
162+
assert authenticated_user in needs
163+
assert UserNeed(1) in needs
164+
assert UserNeed(2) in needs
165+
assert UserNeed(3) in needs
166+
167+
# Test excludes
168+
excludes = generator.excludes(record=record)
169+
assert excludes == []

0 commit comments

Comments
 (0)