-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: preserve authorizer config for overlapping API routes #9166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
bfb7b0e
c5a226e
df9f9b1
6b5599b
6f849fb
4e8413c
2535804
3a8614f
a227931
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -217,7 +217,11 @@ def get_api(self) -> Api: | |
| @staticmethod | ||
| def normalize_cors_methods(routes: List[Route], cors: Optional[Cors]) -> List[Route]: | ||
| """ | ||
| Adds OPTIONS method to all the route methods if cors exists | ||
| Adds OPTIONS method to route methods if cors exists while preserving | ||
| existing OPTIONS ownership for each function and path, regardless of | ||
| operation name. In get_api(), authorizers are linked before this step, so | ||
| synthesized OPTIONS prefers a route without a linked local authorizer. If | ||
| every sibling has one, the first route remains the fallback owner. | ||
|
|
||
| Parameters | ||
| ----------- | ||
|
|
@@ -229,52 +233,117 @@ def normalize_cors_methods(routes: List[Route], cors: Optional[Cors]) -> List[Ro | |
|
|
||
| Return | ||
| ------- | ||
| A list of routes without duplicate routes with the same function_name and method | ||
| A list of routes with existing OPTIONS ownership preserved and synthesized | ||
| OPTIONS assigned to at most one route per group | ||
| """ | ||
| if not cors: | ||
| return routes | ||
|
|
||
| def add_options_to_route(route: Route) -> Route: | ||
| if "OPTIONS" not in route.methods: | ||
| route.methods.append("OPTIONS") | ||
| return route | ||
| grouped_routes: Dict[Tuple[str, Optional[str], str], List[Route]] = {} | ||
|
|
||
| return routes if not cors else [add_options_to_route(route) for route in routes] | ||
| for route in routes: | ||
| key = (route.stack_path, route.function_name, route.path) | ||
| grouped_routes.setdefault(key, []).append(route) | ||
|
|
||
| result: List[Route] = [] | ||
|
|
||
| for route_group in grouped_routes.values(): | ||
| options_claimed = any("OPTIONS" in route.methods for route in route_group) | ||
|
|
||
| if not options_claimed: | ||
| owner = next( | ||
| (route for route in route_group if route.authorizer_object is None), | ||
| route_group[0], | ||
| ) | ||
| owner.methods.append("OPTIONS") | ||
|
|
||
| result.extend(route_group) | ||
|
|
||
| return result | ||
|
|
||
| @staticmethod | ||
| def dedupe_function_routes(routes: List[Route]) -> List[Route]: | ||
| """ | ||
| Remove duplicate routes that have the same function_name and method | ||
| Remove duplicate routes that have the same function_name, path, and method while preserving method-specific | ||
| operation names. | ||
|
|
||
| route: list(Route) | ||
| List of Routes | ||
|
|
||
| Return | ||
| ------- | ||
| A list of routes without duplicate routes with the same stack_path, function_name and method | ||
| A list of routes without duplicate routes with the same stack_path, function_name, path, and method | ||
| """ | ||
| grouped_routes: Dict[str, Route] = {} | ||
| grouped_routes: Dict[Tuple[str, Optional[str], str], List[Route]] = {} | ||
|
|
||
| for route in routes: | ||
| key = "{}-{}-{}-{}".format(route.stack_path, route.function_name, route.path, route.operation_name or "") | ||
| config = grouped_routes.get(key, None) | ||
| methods = route.methods | ||
| if config: | ||
| methods += config.methods | ||
| sorted_methods = sorted(methods) | ||
| # Prefer route-specific CORS over None | ||
| cors = route.cors if route.cors is not None else (config.cors if config else None) | ||
| grouped_routes[key] = Route( | ||
| function_name=route.function_name, | ||
| path=route.path, | ||
| methods=sorted_methods, | ||
| event_type=route.event_type, | ||
| payload_format_version=route.payload_format_version, | ||
| operation_name=route.operation_name, | ||
| stack_path=route.stack_path, | ||
| authorizer_name=route.authorizer_name, | ||
| authorizer_object=route.authorizer_object, | ||
| cors=cors, | ||
| key = (route.stack_path, route.function_name, route.path) | ||
| grouped_routes.setdefault(key, []).append(route) | ||
|
|
||
| result: List[Route] = [] | ||
|
|
||
| def has_same_authorizer(first: Route, second: Route) -> bool: | ||
| return ( | ||
| first.authorizer_name == second.authorizer_name and first.authorizer_object == second.authorizer_object | ||
| ) | ||
| return list(grouped_routes.values()) | ||
|
|
||
| def can_merge(first: Route, second: Route) -> bool: | ||
| return has_same_authorizer(first, second) and (first.operation_name or "") == (second.operation_name or "") | ||
|
|
||
| for route_group in grouped_routes.values(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The split logic here is correct, but for SAM templates it is only reachable when the explicit
for config in all_configs:
# Normalize the methods before de-duping to allow an ANY method in implicit API to override a regular HTTP
# method on explicit route.
for normalized_method in config.methods:
key = config.path + normalized_method
...
all_routes[key] = config
result = set(all_routes.values()) # Assign to a set() to de-dupeBecause Concretely, for the template in #9165:
The new tests all call |
||
| merged_routes: List[Route] = [] | ||
| group_cors = next((route.cors for route in route_group if route.cors is not None), None) | ||
|
|
||
| # Process broader routes first so a more specific route can own overlapping methods, even when operation | ||
| # names differ. Only routes with the same authorizer and operation-name metadata are merged into one Route. | ||
| for route in sorted(route_group, key=lambda item: len(item.methods), reverse=True): | ||
| methods = list(dict.fromkeys(route.methods)) | ||
|
|
||
| for existing_route in merged_routes: | ||
| if not can_merge(existing_route, route): | ||
| existing_route.methods = [method for method in existing_route.methods if method not in methods] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] Stripping the overlapping methods makes the split disjoint here, but get_api() runs the CORS normalization immediately after dedupe (lines 195-196): routes = self.dedupe_function_routes(self.routes)
routes = self.normalize_cors_methods(routes, self.cors)and normalize_cors_methods appends def add_options_to_route(route: Route) -> Route:
if "OPTIONS" not in route.methods:
route.methods.append("OPTIONS")
return routeSo whenever the API has CORS configured, the authorizer-protected route regains Consider making the released methods explicit instead of relying on ordering — e.g. track the methods a route gave up during dedupe and have normalize_cors_methods skip injecting |
||
|
|
||
| matching_route = next( | ||
| (existing_route for existing_route in merged_routes if can_merge(existing_route, route)), | ||
| None, | ||
| ) | ||
|
|
||
| if matching_route: | ||
| matching_route.methods = sorted(set(matching_route.methods + methods)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] When two routes in a group share an authorizer, the merge only updates methods and cors on the surviving route — which is the first one created, i.e. the route with the most methods (usually the ANY route). Every other attribute of the narrower route is discarded, including payload_format_version. That one has real consequences for HTTP APIs, because a missing payload format version is treated as 2.0 (local_apigw_service.py:460): if route.event_type == Route.HTTP and route.payload_format_version in [None, "2.0"]:So an ANY route with no PayloadFormatVersion now deterministically shadows a sibling method route (same function/path/operation and same authorizer) that declares if route and route.payload_format_version and config.payload_format_version is None:
config.payload_format_version = route.payload_format_versionMirroring it in the merge branch keeps the behavior consistent: if matching_route:
matching_route.methods = sorted(set(matching_route.methods + methods))
if matching_route.payload_format_version is None:
matching_route.payload_format_version = route.payload_format_version
if route.cors is not None:
matching_route.cors = route.cors
continueThe same one-sided loss applies to use_default_authorizer, which the PR description says is preserved: it is preserved on the copy (line 302) but on a merge only the first route's value survives, and test_merges_routes_with_same_resolved_authorizer locks that in (the False from the POST route is dropped). That is currently inert because _link_authorizers() has already run, but it is worth a comment in the code so the next reader does not assume the flag is still meaningful. |
||
|
|
||
| if matching_route.payload_format_version is None: | ||
| matching_route.payload_format_version = route.payload_format_version | ||
|
|
||
| if route.cors is not None: | ||
| matching_route.cors = route.cors | ||
|
|
||
| # Authorizers are already resolved by _link_authorizers() before | ||
| # deduplication, so use_default_authorizer does not affect this merge. | ||
| continue | ||
|
|
||
| merged_routes.append( | ||
| Route( | ||
| function_name=route.function_name, | ||
| path=route.path, | ||
| methods=sorted(methods), | ||
| event_type=route.event_type, | ||
| payload_format_version=route.payload_format_version, | ||
| operation_name=route.operation_name, | ||
| stack_path=route.stack_path, | ||
| authorizer_name=route.authorizer_name, | ||
| authorizer_object=route.authorizer_object, | ||
| use_default_authorizer=route.use_default_authorizer, | ||
| cors=route.cors, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] Route-level CORS is no longer propagated across the routes in a group. The removed code deliberately merged it: # Prefer route-specific CORS over None
cors = route.cors if route.cors is not None else (config.cors if config else None)The new code only carries cors=cors if method == "OPTIONS" else None,There is no fallback to recover it. In cors = route.cors if route.cors is not None else self.api.cors
...
headers.update(cors_headers)
Compute the group's effective CORS once and apply it to every resulting route that has none of its own: group_cors = next((route.cors for route in route_group if route.cors is not None), None)
...
for merged_route in merged_routes:
if merged_route.cors is None:
merged_route.cors = group_cors
result.extend(route for route in merged_routes if route.methods) |
||
| ) | ||
| ) | ||
|
|
||
| for merged_route in merged_routes: | ||
| if merged_route.cors is None: | ||
| merged_route.cors = group_cors | ||
|
|
||
| result.extend(route for route in merged_routes if route.methods) | ||
|
|
||
| return result | ||
|
|
||
| def add_binary_media_types(self, logical_id: str, binary_media_types: Optional[List[str]]) -> None: | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -553,11 +553,13 @@ def _convert_event_route( | |
| @staticmethod | ||
| def merge_routes(collector: ApiCollector) -> List[Route]: | ||
| """ | ||
| Quite often, an API is defined both in Implicit and Explicit Route definitions. In such cases, Implicit API | ||
| definition wins because that conveys clear intent that the API is backed by a function. This method will | ||
| merge two such list of routes with the right order of precedence. If a Path+Method combination is defined | ||
| in both the places, only one wins. | ||
| In a multi-stack situation, the API defined in the top level wins. | ||
| Quite often, an API is defined in both implicit and explicit route definitions. The implicit API normally | ||
| wins because that conveys clear intent that the API is backed by a function. When a later expanded ANY route | ||
| overlaps a single-method route from the same function, both are retained only if the narrower route explicitly | ||
| declares different authorizer intent. Downstream deduplication then preserves that method-level authorization, | ||
| including when the routes have different operation names. A payload format version is inherited only when the | ||
| later route actually replaces the earlier route. In a multi-stack situation, the API defined in the top level | ||
| wins. | ||
|
|
||
| Parameters | ||
| ---------- | ||
|
|
@@ -581,8 +583,8 @@ def merge_routes(collector: ApiCollector) -> List[Route]: | |
| else: | ||
| explicit_routes.extend(apis) | ||
|
|
||
| # We will use "path+method" combination as key to this dictionary and store the Api config for this combination. | ||
| # If an path+method combo already exists, then overwrite it if and only if this is an implicit API | ||
| # Use the "path+method" combination as the key. Later routes normally overwrite earlier routes, subject to | ||
| # the narrow authorizer-preservation exception below. | ||
| all_routes: Dict[str, Route] = {} | ||
|
|
||
| # By adding implicit APIs to the end of the list, they will be iterated last. If a configuration was already | ||
|
|
@@ -594,13 +596,34 @@ def merge_routes(collector: ApiCollector) -> List[Route]: | |
| ) | ||
|
|
||
| for config in all_configs: | ||
| # Normalize the methods before de-duping to allow an ANY method in implicit API to override a regular HTTP | ||
| # method on explicit route. | ||
| # Normalize methods before de-duping so an ANY route normally overrides a regular HTTP method. | ||
| # A narrow route with explicit, different authorizer intent is conditionally preserved below. | ||
| for normalized_method in config.methods: | ||
| key = config.path + normalized_method | ||
| route = all_routes.get(key) | ||
|
|
||
| # Preserve a single-method route when it explicitly declares different | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The That inheritance only made sense under the old semantics, where Concrete case, both events implicit on the same HTTP API: Events:
Options:
Type: HttpApi
Properties:
Path: /x
Method: OPTIONS
PayloadFormatVersion: "1.0"
Auth: { Authorizer: NONE }
Any:
Type: HttpApi
Properties:
Path: /x
Method: ANY
Auth: { Authorizer: MyAuth }The Moving the inheritance below the preservation check keeps it for the keys where for normalized_method in config.methods:
key = config.path + normalized_method
route = all_routes.get(key)
# Preserve a single-method route when it explicitly declares different
# raw authorizer intent and both routes can be reconciled downstream.
if (
route
and len(route.methods) == 1
# ... unchanged conditions ...
):
continue
if route and route.payload_format_version and config.payload_format_version is None:
config.payload_format_version = route.payload_format_version
all_routes[key] = config |
||
| # raw authorizer intent and both routes can be reconciled downstream. | ||
| if ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The new rule is broader than the # Normalize the methods before de-duping to allow an ANY method in implicit API to override a regular HTTP
# method on explicit route.Concretely, for a function with an explicit Since and (
route.authorizer_name != config.authorizer_name
or route.use_default_authorizer != config.use_default_authorizer
)Note this comparison must use the raw There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The preservation rule is asymmetric: it is only consulted when the expanded ANY route is the second route to reach a given path+method key. When the ANY route is processed first, len(route.methods) == 1 is false (it is 7), the block is skipped entirely, and the narrow route unconditionally overwrites the key. That means the explicit-intent guard on line 612 ( Trace with two Api events on the same function/path in one explicit AWS::Serverless::Api (so both land in explicit_routes at the same stack depth, preserving template order): Events:
Any:
Type: Api
Properties: { Path: /x, Method: ANY, RestApiId: Api1, Auth: { Authorizer: MyAuthorizer } }
Get:
Type: Api
Properties: { Path: /x, Method: GET, RestApiId: Api1 } # no Auth
Same template semantics, opposite authorization outcome purely from YAML key order. This is the order-dependence the PR sets out to remove, and it is the security-relevant direction (authorization silently dropped). Note test_must_prefer_implicit_any_for_same_function_with_same_authorizer_intent only exercises the narrow-first ordering, so the gap is not covered. The rule needs to be applied from both directions: when a narrow route overwrites a key currently held by an expanded ANY route of the same function/stack/event type and the narrow route declares no authorizer intent, it should end up with the ANY route's authorizer configuration rather than silently winning with authorizer_name=None. I'd avoid prescribing an exact snippet here since mutating config in place has knock-on effects, but the condition needs a mirrored branch keyed on set(route.methods) == set(Route.ANY_HTTP_METHODS) and len(config.methods) == 1. |
||
| route | ||
| and len(route.methods) == 1 | ||
| and set(config.methods) == set(Route.ANY_HTTP_METHODS) | ||
| and route.function_name == config.function_name | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The preservation condition pins [REDACTED] route.function_name, route.path, route.operation_name or "")When the preserved narrow route and the skipped This is reachable: swagger-derived routes get Downstream, Adding if (
route
and len(route.methods) == 1
and set(config.methods) == set(Route.ANY_HTTP_METHODS)
and route.function_name == config.function_name
and route.stack_path == config.stack_path
and route.event_type == config.event_type
and route.operation_name == config.operation_name
):
continue |
||
| and route.stack_path == config.stack_path | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The A swagger-defined preflight normally has an paths:
/x:
options:
operationId: Preflight
security: [] # explicitly unauthenticated
x-amazon-apigateway-integration: {...} # -> SamFunc1plus an I understand why the clause is there: Two options that close the gap instead of excluding it:
Either way the current test name reads as "no duplicates" when what it actually pins down is "authorization is applied to a route that declared security: []". Scope of what I verified (workspace is the merge-base, so I traced the post-merge logic by hand rather than executing it): the previously raised findings are addressed in this revision — route-level CORS is restored via group_cors, use_default_authorizer is excluded from has_same_authorizer, normalize_cors_methods no longer re-appends OPTIONS onto a route whose sibling already owns it, payload_format_version inheritance moved after the continue, function_name/stack_path/event_type/operation_name are pinned in the preservation guard, and the guard now requires the narrow route to declare explicit authorizer intent. I also traced both event-declaration orders (OPTIONS before ANY and after) and confirmed dedupe_function_routes produces disjoint method sets in each, and checked that multiple Route objects sharing one path register cleanly in Flask/Werkzeug (same bound view_func, method-aware rule matching). |
||
| and route.event_type == config.event_type | ||
| and (route.authorizer_name is not None or not route.use_default_authorizer) | ||
| and ( | ||
| route.authorizer_name != config.authorizer_name | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The preservation rule triggers on any difference in raw authorizer intent, including the case where the narrow route simply does not declare one. That silently strips an event-level authorizer from ordinary (non- Concrete case — an explicit
So a route that is authorized on AWS becomes unauthenticated under Gating on an explicit opt-out rather than any mismatch keeps the and (
# Only preserve when the narrower route explicitly declares its own
# authorizer intent. A missing swagger "security" key means unspecified,
# not "no authorization", and must not override the ANY route.
(route.authorizer_name is not None or not route.use_default_authorizer)
and (
route.authorizer_name != config.authorizer_name
or route.use_default_authorizer != config.use_default_authorizer
)
)Please also update the |
||
| or route.use_default_authorizer != config.use_default_authorizer | ||
| ) | ||
| ): | ||
| continue | ||
|
|
||
| # Inherit only from a route that config is about to replace. A preserved route must not mutate the | ||
| # shared expanded ANY route used by the other method keys. | ||
| if route and route.payload_format_version and config.payload_format_version is None: | ||
| config.payload_format_version = route.payload_format_version | ||
|
|
||
| all_routes[key] = config | ||
|
|
||
| result = set(all_routes.values()) # Assign to a set() to de-dupe | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[GENERAL] options_claimed short-circuits the new unauthenticated-owner preference whenever the group contains an ANY-derived route, because Route.normalize_method expands ANY to ANY_HTTP_METHODS, which always includes OPTIONS. So the docstring's claim that "synthesized OPTIONS prefers a route without a linked local authorizer" does not hold for the most common CORS shape.
Concretely, an AWS::Serverless::Api with Cors set and a group of ANY /x (authorizer MyAuth) plus POST /x (security: []): dedupe_function_routes strips POST from the ANY route but leaves OPTIONS on it, so options_claimed is True and OPTIONS stays owned by the authorizer-protected route. _request_handler dispatches on route.authorizer_object, so the local preflight is challenged. On AWS the Cors property causes the transform to emit an unauthenticated OPTIONS mock integration that overrides ANY, so preflight succeeds there — a browser-visible local/deployed divergence.
The check that matters is not "does any route in the group list OPTIONS" but "does any route explicitly declare OPTIONS". An OPTIONS entry that only exists because ANY was expanded is not an ownership claim, and when cors is configured it should be re-assignable to an unauthorized sibling the same way an explicitly declared OPTIONS route already takes precedence in dedupe_function_routes. As written, the preference only ever fires for groups made up entirely of single-method routes — which is what the new tests cover.