Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.44-brightgreen" alt="Version: 26.06.44"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.45-brightgreen" alt="Version: 26.06.45"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.44"
__version__ = "26.06.45"
5 changes: 5 additions & 0 deletions src/pyfly/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 32 additions & 3 deletions src/pyfly/security/expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand All @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/pyfly/security/role_hierarchy.py
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions tests/security/test_role_hierarchy.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading