diff --git a/CHANGELOG.md b/CHANGELOG.md index 794fe12..f771d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.110 (2026-06-16) + +### Fixed + +- **The separate management port (actuator + admin) is now OPEN by default.** When + ``pyfly.management.server.port`` runs actuator/admin on a dedicated port, the + app's user security filters (e.g. an ``HttpSecurity`` gate whose ``deny-all`` + catch-all is scoped to the *main* app's URL space) were applied there too — + rejecting ``/admin``, ``/actuator/info``, ``/actuator/metrics`` with 401/403 + while only ``/actuator/health`` (explicitly permitted) worked. The management + port is a separate, typically-internal listener (Spring ``management.server.port`` + parity) protected by network isolation, so it no longer applies the app's + security filters by default. Opt back in with + ``pyfly.management.security.enabled: true``. + +--- + ## v26.06.109 (2026-06-16) ### Fixed diff --git a/pyproject.toml b/pyproject.toml index bcd8aa0..63adea6 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.109" +version = "26.6.110" 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 514b941..127f2b5 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.109" +__version__ = "26.06.110" diff --git a/src/pyfly/web/adapters/starlette/management_app.py b/src/pyfly/web/adapters/starlette/management_app.py index 2017eed..078acfa 100644 --- a/src/pyfly/web/adapters/starlette/management_app.py +++ b/src/pyfly/web/adapters/starlette/management_app.py @@ -104,21 +104,31 @@ def create_management_app( filters.append(_capture) builtin_types = tuple(type(f) for f in filters) - # Pull in user security/session/CSRF WebFilter beans so actuator/admin auth - # works on the management port, excluding the capture filters owned by the - # main app. - present = {id(f) for f in filters} - for _cls, reg in context.container._registrations.items(): - inst = reg.instance - if ( - inst is not None - and id(inst) not in present - and isinstance(inst, WebFilter) - and not isinstance(inst, builtin_types) - and type(inst).__name__ not in _CAPTURE_FILTER_NAMES - ): - filters.append(inst) - present.add(id(inst)) + # The management port (actuator + admin) is OPEN by default: it is a separate, + # typically-internal port (Spring management.server.port parity) protected by + # network isolation, not by the application's auth. The app's user security + # WebFilters (e.g. an HttpSecurity gate with a deny-all catch-all scoped to the + # MAIN app's URL space) would otherwise reject /admin, /actuator/info, etc. with + # 401/403. Opt in with ``pyfly.management.security.enabled: true`` to also apply + # the app's security/session/CSRF filters to the management port. + management_security = str(context.config.get("pyfly.management.security.enabled", "false")).lower() in ( + "true", + "1", + "yes", + ) + if management_security: + present = {id(f) for f in filters} + for _cls, reg in context.container._registrations.items(): + inst = reg.instance + if ( + inst is not None + and id(inst) not in present + and isinstance(inst, WebFilter) + and not isinstance(inst, builtin_types) + and type(inst).__name__ not in _CAPTURE_FILTER_NAMES + ): + filters.append(inst) + present.add(id(inst)) filters.sort(key=lambda f: get_order(type(f))) middleware = [Middleware(WebFilterChainMiddleware, filters=filters)] diff --git a/tests/web/test_management_separation.py b/tests/web/test_management_separation.py index 9ebcd94..9c925c6 100644 --- a/tests/web/test_management_separation.py +++ b/tests/web/test_management_separation.py @@ -167,6 +167,50 @@ async def _lifespan(app_: Any): assert client.get("/actuator/health/liveness").status_code == 200 +def test_management_port_open_by_default_ignores_user_security_filters() -> None: + # The management app must NOT apply the app's user security filters by default + # (a deny-all HttpSecurity gate scoped to the main app would 403 /admin etc.). + import asyncio + + from pyfly.container.ordering import HIGHEST_PRECEDENCE, order + from pyfly.web.adapters.starlette.management_app import create_management_app + from pyfly.web.filters import OncePerRequestFilter + from pyfly.web.ports.filter import WebFilter + + @order(HIGHEST_PRECEDENCE + 350) + class _DenyAll(OncePerRequestFilter): + async def do_filter(self, request, call_next): # type: ignore[no-untyped-def] + from starlette.responses import PlainTextResponse + + return PlainTextResponse("denied", status_code=403) + + async def _mgmt_filter_names(config: dict) -> list[str]: + ctx = ApplicationContext(Config(config)) + ctx.container.register_instance(WebFilter, _DenyAll(), name="deny_all") + await ctx.start() + try: + app = create_management_app( + ctx, + health_agg=None, + http_exchange_recorder=None, + admin_trace_collector=None, + actuator_active=True, + admin_enabled=False, + ) + return [ + type(f).__name__ + for mw in app.user_middleware + for f in (getattr(mw, "kwargs", {}) or {}).get("filters", []) or [] + ] + finally: + await ctx.stop() + + # Default: the deny-all gate is NOT on the management app → actuator/admin open. + assert "_DenyAll" not in asyncio.run(_mgmt_filter_names({"pyfly": {}})) + # Opt in: pyfly.management.security.enabled=true applies it. + assert "_DenyAll" in asyncio.run(_mgmt_filter_names({"pyfly": {"management": {"security": {"enabled": "true"}}}})) + + @pytest.mark.asyncio async def test_health_aggregator_exposed_in_separate_mode() -> None: # Even when actuator runs on a separate management port, the aggregator is diff --git a/uv.lock b/uv.lock index 9e785a1..78e4a5c 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.109" +version = "26.6.110" source = { editable = "." } dependencies = [ { name = "pydantic" },