Skip to content
Open
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
2 changes: 1 addition & 1 deletion litestar/_openapi/path_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def create_operation_for_handler_method(
responses=responses,
request_body=request_body,
parameters=parameters or None, # type: ignore[arg-type]
security=list(route_handler.security) if route_handler.security else None,
security=list(route_handler.security) if route_handler.security is not None else None,
)

def create_operation_id(self, route_handler: HTTPRouteHandler, http_method: HttpMethod) -> str:
Expand Down
13 changes: 10 additions & 3 deletions litestar/handlers/http_handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ def __init__(
self.response_description = response_description
self.summary = summary
self.tags = frozenset(tags) if tags else frozenset()
self.security = tuple(security) if security else ()
self.security = tuple(security) if security is not None else None
self.responses = responses
# memoized attributes, defaulted to Empty
self._kwargs_models: dict[tuple[str, ...], KwargsModel] = {}
Expand Down Expand Up @@ -374,7 +374,14 @@ def _get_merge_opts(self, others: tuple[Router, ...]) -> dict[str, Any]:
merge_opts["etag"] = merge_opts.get("etag") or other.etag
merge_opts["response_cookies"] = (*merge_opts.get("response_cookies", ()), *other.response_cookies)
merge_opts["response_headers"] = (*other.response_headers, *merge_opts.get("response_headers", ()))
merge_opts["security"] = (*other.security, *merge_opts.get("security", ()))
if other is self:
# only the handler itself can express "explicitly no security" (security=());
# ancestor layers (Router/Controller/app) always store a list, so an empty
# value there is indistinguishable from "not set" and must not force an override.
if other.security is not None:
merge_opts["security"] = (*other.security, *merge_opts.get("security", ()))
elif other.security:
merge_opts["security"] = (*other.security, *merge_opts.get("security", ()))
merge_opts["tags"] = (*other.tags, *merge_opts.get("tags", ()))

# these are all properties which return a safe default if the corresponding
Expand Down Expand Up @@ -515,7 +522,7 @@ def resolve_security(self) -> tuple[SecurityRequirement, ...]:
Returns:
list[SecurityRequirement]: The resolved security property.
"""
return self.security
return self.security if self.security is not None else ()

@litestar_deprecated("3.0", removal_in="4.0", alternative=".tags attribute")
def resolve_tags(self) -> frozenset[str]:
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/test_handlers/test_http_handlers/test_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,38 @@ async def handler() -> None:
assert resolved_handler.resolve_security() == resolved_handler.security # type: ignore[attr-defined]


def test_security_unset_on_every_layer_resolves_to_none() -> None:
"""When no ownership layer declares ``security``, the resolved handler's ``.security`` should be ``None``
(distinct from an explicitly empty sequence), so the OpenAPI schema generator can tell "not configured" apart
from "explicitly no security" and correctly fall back to the document-level default.
"""

@get()
async def handler() -> None:
pass

app = Litestar(route_handlers=[handler])
resolved_handler = app.route_handler_method_map["/"]["GET"]
assert resolved_handler.security is None # type: ignore[attr-defined]
# the deprecated public accessor still guarantees a tuple, never None
assert resolved_handler.resolve_security() == () # type: ignore[attr-defined]


def test_security_explicit_empty_on_handler_resolves_to_empty_tuple() -> None:
"""A route handler explicitly declaring ``security=[]``, with no ownership layer contributing any requirement,
should resolve to an empty tuple rather than ``None``, so it can be distinguished from "unset" by the schema
generator and documented as explicitly requiring no security.
"""

@get(security=[])
async def handler() -> None:
pass

app = Litestar(route_handlers=[handler])
resolved_handler = app.route_handler_method_map["/"]["GET"]
assert resolved_handler.security == () # type: ignore[attr-defined]


def test_resolve_tags() -> None:
@get(tags=["foo"])
async def handler() -> None:
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/test_openapi/test_security_schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,52 @@ def my_handler(self) -> None: ...
{"controllerToken": []},
{"handlerToken": []},
]


def test_schema_without_any_security_omits_operation_security(public_route: "HTTPRouteHandler") -> None:
"""When no layer declares ``security``, the operation should omit the field entirely, so it falls back to
whatever global ``security`` is declared on the OpenAPI document, rather than being treated as an explicit
opt-out.
"""
app = Litestar(
route_handlers=[public_route],
openapi_config=OpenAPIConfig(title="test app", version="0.0.1", security=[{"BearerToken": []}]),
)
schema_dict = app.openapi_schema.to_schema()

assert schema_dict["paths"]["/handler"]["get"].get("security") is None
assert schema_dict.get("security") == [{"BearerToken": []}]


def test_schema_with_explicit_empty_route_security_opts_out() -> None:
"""A route handler explicitly declaring ``security=[]`` (with no security declared on any ownership layer)
should be documented as requiring no security, instead of falling back to the document-level default.
"""

@get("/public", security=[])
def _handler() -> Any: ...

app = Litestar(
route_handlers=[_handler],
openapi_config=OpenAPIConfig(title="test app", version="0.0.1", security=[{"BearerToken": []}]),
)
schema_dict = app.openapi_schema.to_schema()

assert schema_dict["paths"]["/public"]["get"].get("security") == []
assert schema_dict.get("security") == [{"BearerToken": []}]


def test_explicit_empty_route_security_does_not_cancel_ownership_layer_security() -> None:
"""A route handler cannot use ``security=[]`` to cancel security requirements declared on an ownership layer
above it (router/controller/app) - security requirements are additive, and there's no override semantic for
individual layers.
"""

@get("/opt-out", security=[])
def _handler() -> Any: ...

router = Router("/router", route_handlers=[_handler], security=[{"routerToken": []}])
app = Litestar(route_handlers=[router])
schema_dict = app.openapi_schema.to_schema()

assert schema_dict["paths"]["/router/opt-out"]["get"].get("security") == [{"routerToken": []}]
Loading