Skip to content
129 changes: 99 additions & 30 deletions samcli/lib/providers/api_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------
Expand All @@ -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)

Copy link
Copy Markdown

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.


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():

Copy link
Copy Markdown

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 OPTIONS event is declared after the ANY event, so the order-dependence the PR aims to remove still exists one layer up.

SamApiProvider.merge_routes() runs before get_api() (see sam_api_provider.py:100) and de-dupes by path + method, keeping the last writer per key:

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-dupe

Because Route.normalize_method already expands ANY into all seven verbs, the ANY route claims the /{proxy+}OPTIONS key too. The explicit OPTIONS route only has that one key, so if it is written first and then overwritten, it is no longer a value in all_routes and is dropped by set(all_routes.values())dedupe_function_routes never sees it, and the OPTIONS method keeps the ANY route's authorizer.

Concretely, for the template in #9165:

  • ANY event declared first, OPTIONS event second → both routes survive merge_routes → this fix applies.
  • OPTIONS event declared first, ANY second → the OPTIONS route is discarded → preflight is still authorized.
  • ANY event implicit (no RestApiId) and OPTIONS explicit → implicit routes are iterated last by design, so the OPTIONS route is always discarded.

The new tests all call dedupe_function_routes/get_api directly, so they cannot catch this. Please either give the more specific method precedence in merge_routes (an explicit single-method route should not be clobbered by an expanded ANY route) or add a test that drives the scenario through SamApiProvider.extract_resources with the OPTIONS event declared first, so the end-to-end behavior is pinned.

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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 OPTIONS back onto every route that does not already have it:

def add_options_to_route(route: Route) -> Route:
   if "OPTIONS" not in route.methods:
       route.methods.append("OPTIONS")
   return route

So whenever the API has CORS configured, the authorizer-protected route regains OPTIONS and both split routes claim <path>:OPTIONS. LocalApigwService.create() keys _dict_of_routes by path:method only (local_apigw_service.py:145-146), so one entry silently overwrites the other and the winner is whichever route comes later in api.routes. That means the "explicit route owns the overlapping method" rule this PR establishes is re-decided by list position: it currently produces the right result only because the broadest route is appended first by the sort on line 273.

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 OPTIONS into a route that deliberately released it (or skip injection when another route in the same group already serves OPTIONS). Otherwise the order-dependence the PR removes from dedupe_function_routes reappears one step later in the pipeline.


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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 "1.0", and that method's Lambda receives a v2 event instead of a v1 event. SamApiProvider.merge_routes already guards against exactly this loss:

if route and route.payload_format_version and config.payload_format_version is None:
   config.payload_format_version = route.payload_format_version

Mirroring 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
   continue

The 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 forward inside the if matching_route: branch, i.e. only between routes that share an authorizer config. When a group now splits because the authorizers differ, the non-OPTIONS route is constructed with cors=route.cors, which is None for every non-OPTIONS method — cfn_api_provider.py only ever attaches route-level CORS to OPTIONS methods:

cors=cors if method == "OPTIONS" else None,

There is no fallback to recover it. In local_apigw_service._request_handler:

cors = route.cors if route.cors is not None else self.api.cors
...
headers.update(cors_headers)

self.api.cors is not set in this path either, because cfn_api_provider assigns collector.cors only in the elif cors: branch for non-OPTIONS methods. So for a REST API whose OPTIONS method carries the CORS integration responses while its other methods carry an authorizer, the preflight still succeeds but the actual GET/POST response is returned with no Access-Control-Allow-* headers, and the browser rejects it. Previously the single merged route held cors and all methods got the 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:
"""
Expand Down
41 changes: 32 additions & 9 deletions samcli/lib/providers/sam_api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The payload_format_version inheritance at lines 600-601 runs before the new preservation block, so when continue fires the config object has already absorbed payload_format_version from a route it no longer overrides.

That inheritance only made sense under the old semantics, where config unconditionally replaced route for this key. Now route survives and keeps its own value, while config — the shared ANY route object registered under the other six method keys — silently picks up the narrow route's version.

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 OPTIONS route is preserved (as intended), but the ANY route now carries payload_format_version == "1.0" even though the user never set one on it. Since it is the same object behind the GET/POST/... keys, every method on /x is then invoked with a v1.0 event instead of the HTTP API default of 2.0 — local_apigw_service.py:460 selects the event shape with route.payload_format_version in [None, "2.0"].

Moving the inheritance below the preservation check keeps it for the keys where config actually wins:

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 (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] The new rule is broader than the OPTIONS scenario in the PR description: it preserves any single-method route of the same function against an expanding ANY route, regardless of the authorizer configuration. That reverses the precedence this function documents and that the loop comment immediately above still asserts:

# 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 GET /x event on Api1 plus an implicit ANY /x event, GET previously resolved to the implicit route; it now resolves to the explicit one, so Api1's authorizer (and default authorizer) applies to GET instead of the implicit API's. Nothing about OPTIONS or CORS is involved, and no test in the diff covers it — both new tests assert only the OPTIONS case.

Since dedupe_function_routes now splits by authorizer anyway, the skip only needs to happen when the two routes would not merge. Gating on differing authorizer intent keeps the fix targeted and leaves the documented implicit-over-explicit precedence intact for everything else:

and (
   route.authorizer_name != config.authorizer_name
   or route.use_default_authorizer != config.use_default_authorizer
)

Note this comparison must use the raw authorizer_name/use_default_authorizer fields, since merge_routes runs before _link_authorizers() and authorizer_object is still unset at this point. If the broader behavior is intentional, the stale loop comment and the merge_routes docstring should be updated to describe the new precedence.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 (route.authorizer_name is not None or not route.use_default_authorizer) is bypassed, and authorization for a method still flips based on declaration order.

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
  • Any first: all 7 keys map to anyRoute. Then Get is processed — the preservation block is skipped because len(anyRoute.methods) == 7, so all_routes["/xGET"] = getRoute. Both routes survive. dedupe_function_routes then sees authorizer_name="MyAuthorizer" vs None, so can_merge is false, strips GET off the ANY route, and keeps getRoute — GET /x ends up unauthenticated.
  • Get first: all_routes["/xGET"] = getRoute, then Any evaluates the preservation block. The explicit-intent guard fails (authorizer_name is None and use_default_authorizer is True), so anyRoute overwrites the key and getRoute is dropped entirely — GET /x ends up authenticated with MyAuthorizer.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The preservation condition pins function_name, stack_path and event_type to be equal, but not operation_name. That matters because it is the only field in this condition that is also part of the grouping key used later by dedupe_function_routes:

[REDACTED] route.function_name, route.path, route.operation_name or "")

When the preserved narrow route and the skipped ANY route have different operation_name values, they land in different groups, so the new method-stripping logic in dedupe_function_routes never runs between them. The ANY route keeps the overlapping method in config.methods and both routes survive.

This is reachable: swagger-derived routes get operation_name from operationId (swagger/parser.py), while SAM Api/HttpApi events leave it None. A function referenced from an explicit DefinitionBody options: entry with an operationId, plus an implicit ANY event on the same path, produces two routes both claiming OPTIONS for that path.

Downstream, LocalApigwService.create() registers a Flask rule per route and fills _dict_of_routes keyed by path:method, so the two entries collide and the authorizer actually applied to OPTIONS depends on route ordering — the order-dependence this PR sets out to remove.

Adding operation_name to the condition keeps the routes in the same dedupe group (or lets the pre-existing clobber happen), so the overlap is always resolved:

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] The operation_name equality clause means the bug this PR targets is still reproducible whenever the narrow route carries an operationId, and the new test suite locks that gap in rather than flagging it.

A swagger-defined preflight normally has an operationId:

paths:
 /x:
   options:
     operationId: Preflight
     security: []          # explicitly unauthenticated
     x-amazon-apigateway-integration: {...}   # -> SamFunc1

plus an ANY /x event on the same function with Auth: {Authorizer: MyAuthorizer}. Here route.operation_name == "Preflight" while config.operation_name is None, so the guard short-circuits, all_routes["/xOPTIONS"] = config executes, and the swagger route is discarded entirely. The preflight then runs under MyAuthorizer — the exact behaviour #9165 reports. test_operation_name_mismatch_does_not_leave_duplicate_options_routes asserts this outcome (assertEqual(options_routes[0].authorizer_name, "MyAuthorizer")), so the case is knowingly excluded but reads as intended behaviour to a future reader.

I understand why the clause is there: dedupe_function_routes groups by "{stack_path}-{function_name}-{path}-{operation_name}", so two preserved routes with different operation_name land in different groups, the method-stripping in dedupe_function_routes never runs across them, and both would keep OPTIONS. local_apigw_service._dict_of_routes is keyed on path:method, so the last registration would silently win — worse than the current partial fix.

Two options that close the gap instead of excluding it:

  • Drop operation_name from the grouping key in dedupe_function_routes and normalize_cors_methods so overlapping routes are reconciled regardless of operationId. The merged route already discards the narrower route's operation_name today, so this mainly widens the grouping.
  • Keep the clause but record the limitation in the merge_routes docstring and rename the test to state that a differing operationId is an unsupported case, so the assertion is not mistaken for desired behaviour.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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-OPTIONS) methods.

Concrete case — an explicit AWS::Serverless::Api whose DefinitionBody declares get /x with no security key, plus a function event ANY /x with Auth: {Authorizer: MyAuthorizer}:

  • SwaggerParser.get_routes() (samcli/commands/local/lib/swagger/parser.py:360-361) sets authorizer_name=None, use_default_authorizer=True when security is absent — that is "unspecified", not "no auth".
  • _convert_event_route() sets authorizer_name="MyAuthorizer" for the ANY event.
  • Both routes land in explicit_routes (same RestApiId), same function_name, stack_path, event_type, and operation_name. With Api1 declared before the function in Resources, the swagger route is stored first.
  • route.authorizer_name != config.authorizer_name is then true, so continue keeps the swagger route as the owner of the GET key.
  • _link_authorizers() resolves the preserved route via default_authorizer. If the API has no DefaultAuthorizer, GET /x ends up with authorizer_name=None and _request_handler invokes the function with no authorizer — previously the ANY route won and the request was authorized.

So a route that is authorized on AWS becomes unauthenticated under sam local start-api, which is the opposite of the PR's goal and defeats the purpose of testing an authorizer locally. It also contradicts the contract this function still documents ("Implicit API definition wins because that conveys clear intent") and the loop comment two lines above ("to allow an ANY method in implicit API to override a regular HTTP method on explicit route").

Gating on an explicit opt-out rather than any mismatch keeps the OPTIONS/Authorizer: NONE and security: [] scenarios working while leaving unspecified methods on the existing precedence path:

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 merge_routes docstring and the stale loop comment to describe the new conditional precedence, and add a test for "swagger method without security + ANY event with an authorizer" asserting the method stays authorized.

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
Expand Down
Loading