diff --git a/CHANGELOG.md b/CHANGELOG.md index cacbf8e..2a567d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.45 (2026-06-07) + +### Added (security — role hierarchy) + +- **`RoleHierarchy`** (`pyfly.security`) — declare `ADMIN > MANAGER`, `MANAGER > USER` so a + higher role implies every authority of the lower ones (Spring Security's `RoleHierarchy`). + `RoleHierarchy.from_string(...)` parses `HIGHER > LOWER` rules; `.expand(roles)` returns the + transitive closure. +- **`set_role_hierarchy(...)` / `get_role_hierarchy()`** install the process-wide hierarchy + consulted by `hasRole` / `hasAnyRole` / `hasAuthority` in all method-security expressions + (`@pre_authorize` / `@post_authorize` / `@secure`). With no hierarchy set, behavior is + unchanged (no implicit roles). + ## v26.06.44 (2026-06-07) ### Added (scheduling — @scheduled time zones) diff --git a/README.md b/README.md index 2aa79f4..3669db1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.44 + Version: 26.06.45 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index ce3472f..181b757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.44" +version = "26.6.45" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 96adb4c..62d4991 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.44" +__version__ = "26.06.45" diff --git a/src/pyfly/security/__init__.py b/src/pyfly/security/__init__.py index d7323ea..1597f4e 100644 --- a/src/pyfly/security/__init__.py +++ b/src/pyfly/security/__init__.py @@ -24,18 +24,23 @@ from pyfly.security.context import SecurityContext from pyfly.security.decorators import secure +from pyfly.security.expression import get_role_hierarchy, set_role_hierarchy from pyfly.security.http_security import AccessRule, AccessRuleType, HttpSecurity, SecurityRule from pyfly.security.method_security import post_authorize, pre_authorize +from pyfly.security.role_hierarchy import RoleHierarchy __all__ = [ "AccessRule", "AccessRuleType", "HttpSecurity", + "RoleHierarchy", "SecurityContext", "SecurityRule", + "get_role_hierarchy", "post_authorize", "pre_authorize", "secure", + "set_role_hierarchy", ] try: diff --git a/src/pyfly/security/expression.py b/src/pyfly/security/expression.py index 4655cf4..02a71ab 100644 --- a/src/pyfly/security/expression.py +++ b/src/pyfly/security/expression.py @@ -36,10 +36,39 @@ from pyfly.kernel.exceptions import SecurityException from pyfly.security.context import SecurityContext +from pyfly.security.role_hierarchy import RoleHierarchy _PARAM_RE = re.compile(r"#(\w+)") _PARAM_PREFIX = "_pyfly_arg_" +# Process-wide role hierarchy consulted by hasRole/hasAnyRole/hasAuthority (Spring's +# RoleHierarchy bean). Configure once at startup via set_role_hierarchy(). +_active_hierarchy: RoleHierarchy | None = None + + +def set_role_hierarchy(hierarchy: RoleHierarchy | None) -> None: + """Install the role hierarchy used by method-security role checks (``None`` disables).""" + global _active_hierarchy + _active_hierarchy = hierarchy + + +def get_role_hierarchy() -> RoleHierarchy | None: + """Return the currently installed role hierarchy, if any.""" + return _active_hierarchy + + +def _effective_roles(ctx: SecurityContext) -> set[str]: + """The principal's roles, expanded through the active hierarchy when one is set.""" + if _active_hierarchy is None: + return set(ctx.roles) + return _active_hierarchy.expand(ctx.roles) + + +def _has_role(ctx: SecurityContext, role: Any) -> bool: + name = str(role) + return ctx.has_role(name) if _active_hierarchy is None else name in _effective_roles(ctx) + + _CMP_OPS: dict[type, Callable[[Any, Any], bool]] = { ast.Eq: operator.eq, ast.NotEq: operator.ne, @@ -70,7 +99,7 @@ def __bool__(self) -> bool: def _has_authority(ctx: SecurityContext, authority: Any) -> bool: name = str(authority) - return ctx.has_role(name) or ctx.has_permission(name) + return _has_role(ctx, name) or ctx.has_permission(name) def _build_namespace(ctx: SecurityContext, args: dict[str, Any] | None, return_object: Any) -> dict[str, Any]: @@ -87,8 +116,8 @@ def _build_namespace(ctx: SecurityContext, args: dict[str, Any] | None, return_o "denyAll": _BoolFn(lambda: False), "isAuthenticated": _BoolFn(lambda: ctx.is_authenticated), "isAnonymous": _BoolFn(lambda: not ctx.is_authenticated), - "hasRole": _BoolFn(lambda role: ctx.has_role(str(role))), - "hasAnyRole": _BoolFn(lambda *roles: ctx.has_any_role([str(r) for r in roles])), + "hasRole": _BoolFn(lambda role: _has_role(ctx, role)), + "hasAnyRole": _BoolFn(lambda *roles: any(_has_role(ctx, r) for r in roles)), "hasAuthority": _BoolFn(lambda authority: _has_authority(ctx, authority)), "hasAnyAuthority": _BoolFn(lambda *auths: any(_has_authority(ctx, a) for a in auths)), # 1-arg hasPermission(perm) or 2-arg hasPermission(target, perm) — the last diff --git a/src/pyfly/security/role_hierarchy.py b/src/pyfly/security/role_hierarchy.py new file mode 100644 index 0000000..5f5a4c6 --- /dev/null +++ b/src/pyfly/security/role_hierarchy.py @@ -0,0 +1,61 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Role hierarchy — higher roles imply lower ones (Spring Security ``RoleHierarchy``). + +Declare ``ADMIN > USER`` to mean an ``ADMIN`` also has every authority of ``USER``. +``hasRole`` / ``hasAnyRole`` / ``hasAuthority`` in method-security expressions consult the +configured hierarchy, so ``hasRole('USER')`` is satisfied for an ``ADMIN``. +""" + +from __future__ import annotations + +from collections.abc import Iterable + + +class RoleHierarchy: + """Directed role-implication graph with transitive expansion.""" + + def __init__(self, edges: dict[str, set[str]] | None = None) -> None: + # _implies[X] = roles directly implied by X + self._implies: dict[str, set[str]] = {k: set(v) for k, v in (edges or {}).items()} + + @classmethod + def from_string(cls, spec: str) -> RoleHierarchy: + """Parse a hierarchy spec: one ``HIGHER > LOWER`` rule per line (or ``;``-separated). + + Example:: + + RoleHierarchy.from_string("ADMIN > MANAGER\\nMANAGER > USER") + """ + edges: dict[str, set[str]] = {} + for raw in spec.replace(";", "\n").splitlines(): + line = raw.strip() + if not line or ">" not in line: + continue + higher, lower = (part.strip() for part in line.split(">", 1)) + if higher and lower: + edges.setdefault(higher, set()).add(lower) + return cls(edges) + + def expand(self, roles: Iterable[str]) -> set[str]: + """Return *roles* plus every role transitively implied by them.""" + result: set[str] = set() + stack = list(roles) + while stack: + role = stack.pop() + if role in result: + continue + result.add(role) + stack.extend(self._implies.get(role, ())) + return result diff --git a/tests/security/test_role_hierarchy.py b/tests/security/test_role_hierarchy.py new file mode 100644 index 0000000..2151be2 --- /dev/null +++ b/tests/security/test_role_hierarchy.py @@ -0,0 +1,62 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Role hierarchy (v26.06.45): higher roles imply lower ones in method-security checks.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from pyfly.security import RoleHierarchy, get_role_hierarchy, set_role_hierarchy +from pyfly.security.context import SecurityContext +from pyfly.security.expression import evaluate_security_expression as ev + + +@pytest.fixture(autouse=True) +def _reset_hierarchy() -> Iterator[None]: + yield + set_role_hierarchy(None) # module-global must not leak into other tests + + +def test_expand_transitive() -> None: + h = RoleHierarchy.from_string("ADMIN > MANAGER\nMANAGER > USER") + assert h.expand(["ADMIN"]) == {"ADMIN", "MANAGER", "USER"} + assert h.expand(["MANAGER"]) == {"MANAGER", "USER"} + assert h.expand(["USER"]) == {"USER"} + assert h.expand([]) == set() + + +def test_from_string_separators_and_noise() -> None: + h = RoleHierarchy.from_string("ADMIN > USER ; USER > GUEST\n\nnonsense-without-arrow") + assert h.expand(["ADMIN"]) == {"ADMIN", "USER", "GUEST"} + + +def test_hierarchy_makes_admin_satisfy_lower_roles() -> None: + admin = SecurityContext(user_id="u", roles=["ADMIN"]) + # Without a hierarchy, ADMIN is not implicitly USER (back-compat). + assert ev("hasRole('USER')", admin) is False + + set_role_hierarchy(RoleHierarchy.from_string("ADMIN > USER")) + assert get_role_hierarchy() is not None + assert ev("hasRole('USER')", admin) is True + assert ev("hasAnyRole('USER')", admin) is True + assert ev("hasAuthority('USER')", admin) is True + + +def test_hierarchy_does_not_grant_unrelated_roles() -> None: + admin = SecurityContext(user_id="u", roles=["ADMIN"]) + set_role_hierarchy(RoleHierarchy.from_string("ADMIN > USER")) + assert ev("hasRole('SUPERUSER')", admin) is False + assert ev("hasRole('ADMIN')", admin) is True # still has its own role diff --git a/uv.lock b/uv.lock index dd3f4d3..30fc00d 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.44" +version = "26.6.45" source = { editable = "." } dependencies = [ { name = "pydantic" },