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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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"
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.109"
__version__ = "26.06.110"
40 changes: 25 additions & 15 deletions src/pyfly/web/adapters/starlette/management_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
44 changes: 44 additions & 0 deletions tests/web/test_management_separation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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