Skip to content

Commit 4836682

Browse files
mesemusRonald Kristmax-moser
committed
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> Co-authored-by: Max <maximilian.moser@tuwien.ac.at>
1 parent 1319da7 commit 4836682

File tree

5 files changed

+505
-9
lines changed

5 files changed

+505
-9
lines changed

invenio_records_permissions/generators.py

Lines changed: 143 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,142 @@ 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+
By virtue of operator overloading, ``SameAs`` can also be used outside of lists
432+
to simplify permission policies:
433+
434+
.. code-block:: python
435+
436+
class RecordPermissionPolicy(BasePermissionPolicy):
437+
can_manage = [RecordOwners(), SystemProcess()]
438+
can_curate = SameAs("can_manage") + [AccessGrant("edit")]
439+
can_delete = SameAs("can_curate")
440+
441+
Note:
442+
The permission name must be an attribute on the policy instance and must
443+
contain a list of generators.
444+
"""
445+
446+
def __init__(self, permission_name):
447+
"""Initialize the generator.
448+
449+
:param permission_name: Name of the permission attribute to delegate to.
450+
Must be in the format "can_<action>" (e.g., "can_edit", "can_read").
451+
This attribute must exist on the permission policy and contain a list of generators.
452+
"""
453+
self._delegated_permission_name = permission_name
454+
455+
def _generators(self, **context):
456+
"""Get the generators from the delegated permission on the policy.
457+
458+
:param context: Must contain 'permission_policy' key with the policy instance
459+
:returns: List of generators from the delegated permission
460+
"""
461+
policy = context["permission_policy"]
462+
return getattr(policy, self._delegated_permission_name)
463+
464+
def __add__(self, other):
465+
"""Allow adding another generator or list of generators to this SameAs generator.
466+
467+
Example:
468+
can_delete = SameAs("can_edit") + [RecordOwners()]
469+
"""
470+
if isinstance(other, (tuple, list, set)):
471+
return [self] + list(other)
472+
else:
473+
return [self, other]
474+
475+
def __iter__(self):
476+
"""Let the SameAs be used as the sole element inside the can_abc properties.
477+
478+
Example:
479+
can_delete = SameAs("can_edit")
480+
"""
481+
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: 18 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,13 @@ 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+
"""
4760

4861
NEED_LABEL_TO_ACTION = {
4962
"bucket-update": "update_files",
@@ -57,15 +70,15 @@ class RecordPermissionPolicy(BasePermissionPolicy):
5770
# be used.
5871
can_create = [Disable()]
5972
# Read access given to everyone if public record/files and owners always.
60-
can_read = [AnyUserIfPublic(), RecordOwners()]
73+
can_read = SameAs("can_update") + [AnyUserIfPublic()]
6174
# Update access given to record owners.
6275
can_update = [RecordOwners()]
6376
# Delete access given to superuser-access action only
6477
# (superuser-access is added by default by base policy)
6578
can_delete = []
6679
# Associated files permissions (which are really bucket permissions)
67-
can_read_files = [AnyUserIfPublic(), RecordOwners()]
68-
can_update_files = [RecordOwners()]
80+
can_read_files = SameAs("can_read")
81+
can_update_files = SameAs("can_update")
6982
can_read_deleted_files = []
7083
can_create_or_update_many = [SystemProcess()]
7184

0 commit comments

Comments
 (0)