-
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 2 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 |
|---|---|---|
|
|
@@ -251,30 +251,66 @@ def dedupe_function_routes(routes: List[Route]) -> List[Route]: | |
| ------- | ||
| A list of routes without duplicate routes with the same stack_path, function_name and method | ||
| """ | ||
| grouped_routes: Dict[str, Route] = {} | ||
| grouped_routes: Dict[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, | ||
| 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()) | ||
|
|
||
| for route_group in grouped_routes.values(): | ||
| 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, e.g. explicit OPTIONS overriding ANY. | ||
| 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 has_same_authorizer(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 has_same_authorizer(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 route.cors is not None: | ||
| matching_route.cors = route.cors | ||
| 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: | ||
| """ | ||
|
|
||
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] The split logic here is correct, but for SAM templates it is only reachable when the explicit
OPTIONSevent is declared after theANYevent, so the order-dependence the PR aims to remove still exists one layer up.SamApiProvider.merge_routes()runs beforeget_api()(seesam_api_provider.py:100) and de-dupes bypath + method, keeping the last writer per key:Because
Route.normalize_methodalready expandsANYinto all seven verbs, theANYroute claims the/{proxy+}OPTIONSkey too. The explicitOPTIONSroute only has that one key, so if it is written first and then overwritten, it is no longer a value inall_routesand is dropped byset(all_routes.values())—dedupe_function_routesnever sees it, and theOPTIONSmethod keeps theANYroute's authorizer.Concretely, for the template in #9165:
ANYevent declared first,OPTIONSevent second → both routes survivemerge_routes→ this fix applies.OPTIONSevent declared first,ANYsecond → theOPTIONSroute is discarded → preflight is still authorized.ANYevent implicit (noRestApiId) andOPTIONSexplicit → implicit routes are iterated last by design, so theOPTIONSroute is always discarded.The new tests all call
dedupe_function_routes/get_apidirectly, so they cannot catch this. Please either give the more specific method precedence inmerge_routes(an explicit single-method route should not be clobbered by an expandedANYroute) or add a test that drives the scenario throughSamApiProvider.extract_resourceswith theOPTIONSevent declared first, so the end-to-end behavior is pinned.