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 @@
-
+
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" },