|
3 | 3 | # Copyright (C) 2019-2023 CERN. |
4 | 4 | # Copyright (C) 2019-2020 Northwestern University. |
5 | 5 | # Copyright (C) 2024 Ubiquity Press. |
| 6 | +# Copyright (C) 2026 CESNET z.s.p.o. |
6 | 7 | # |
7 | 8 | # Invenio-Records-Permissions is free software; you can redistribute it |
8 | 9 | # and/or modify it under the terms of the MIT License; see LICENSE file for |
|
11 | 12 | """Invenio Records Permissions Generators.""" |
12 | 13 |
|
13 | 14 | import operator |
14 | | -from abc import abstractmethod |
| 15 | +from abc import ABC, abstractmethod |
15 | 16 | from functools import reduce |
16 | 17 | from itertools import chain |
17 | 18 |
|
18 | 19 | from flask import current_app |
19 | | -from flask_principal import ActionNeed, UserNeed |
| 20 | +from flask_principal import UserNeed |
20 | 21 | from invenio_access import ActionRoles, ActionUsers, Permission |
21 | 22 | from invenio_access.permissions import ( |
22 | 23 | any_user, |
@@ -225,7 +226,7 @@ def query_filter(self, identity=None, **kwargs): |
225 | 226 | "id": id_need.value, |
226 | 227 | # TODO: Implement other schemes |
227 | 228 | } |
228 | | - } |
| 229 | + }, |
229 | 230 | ) |
230 | 231 | for access_level in read_levels |
231 | 232 | ] |
@@ -339,3 +340,132 @@ def _condition(self, **_): |
339 | 340 | # | False | False | Open | True | |
340 | 341 | # |-----------------|------------------|--------------|--------| |
341 | 342 | # |
| 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]) |
0 commit comments