Skip to content

fix: use --role-arn service role for companion stack create/update - #9267

Open
Sanjays2402 wants to merge 6 commits into
aws:developfrom
Sanjays2402:fix-companion-stack-service-role
Open

fix: use --role-arn service role for companion stack create/update#9267
Sanjays2402 wants to merge 6 commits into
aws:developfrom
Sanjays2402:fix-companion-stack-service-role

Conversation

@Sanjays2402

Copy link
Copy Markdown

Which issue(s) does this change fix?

#5051

Why is this change necessary?

When deploying an image-based function with sam deploy --role-arn <role> --resolve-image-repos, the companion stack that creates the ECR repositories is created/updated without the service role. CloudFormation then falls back to the caller's credentials, which can fail with ecr:CreateRepository AccessDenied even though the role passed via --role-arn has the required permissions.

How does it address the issue?

  • CompanionStackManager accepts an optional role_arn and passes RoleARN to create_stack/update_stack when set.
  • sync_ecr_stack forwards the new optional parameter.
  • sam deploy (non-guided) passes its --role-arn value through to sync_ecr_stack.

What side effects does this change have?

None when --role-arn is not provided (the RoleARN kwarg is omitted, keeping existing behavior). When provided, the companion stack is created/updated under the same service role as the main stack.

Mandatory Checklist

  • Review the generative AI contribution guidelines
  • Add input/output type hints to new functions/methods
  • Write design document if needed (Do I need to write a design document?) — not needed, small bug fix
  • Write/update unit tests
  • Write/update integration tests — behavior is covered by the new unit tests; no AWS credentials available in this environment
  • Write/update functional tests if needed — not needed
  • make pr passes — ran the affected unit tests, plus ruff, black, and mypy on the changed files (all clean); did not run the full suite
  • make update-reproducible-reqs if dependencies were changed — dependencies unchanged
  • Write documentation — no new options or user-facing docs needed

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

The companion stack created by sam deploy --resolve-image-repos ignored
the --role-arn service role: create_stack/update_stack were called
without RoleARN, so ECR repo creation fell back to the caller's
permissions and could fail with ecr:CreateRepository AccessDenied.

Thread role_arn through CompanionStackManager and sync_ecr_stack, and
pass RoleARN to CloudFormation create_stack/update_stack when set.

Fixes aws#5051
@Sanjays2402
Sanjays2402 requested a review from a team as a code owner September 9, 2026 18:30
@github-actions github-actions Bot added area/deploy sam deploy command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Sep 9, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..64dfdf0
Files: 3
Comments: 1

if resolve_image_repos:
image_repositories = sync_ecr_stack(
template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories
template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories, role_arn=role_arn

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 fix only covers the non-guided branch, but --role-arn applies to guided deploys too — it is passed to DeployContext unconditionally at command.py:416, and it is also persisted into samconfig.toml. That means sam deploy --guided --role-arn <role> --resolve-image-repos still reproduces the exact failure from #5051: the main stack deploys under the service role, while the companion stack is created with the caller's credentials and fails on ecr:CreateRepository.

Both companion-stack entry points in the guided flow are affected:

  • samcli/commands/deploy/guided_context.py:191sync_ecr_stack(...) when --resolve-image-repos is set
  • samcli/commands/deploy/guided_context.py:362CompanionStackManager(stack_name, region, s3_bucket, s3_prefix) in prompt_image_repository, which calls manager.sync_repos() at line 390 and therefore reaches update_companion_stack() / create_stack

Threading role_arn through GuidedContext would close the gap, e.g. accept it in GuidedContext.__init__, pass role_arn=role_arn from the guided branch of do_cli, and forward it at both call sites:

# guided_context.py, in guided_prompts
image_repositories = (
   sync_ecr_stack(
       self.template_file,
       stack_name,
       region,
       managed_s3_bucket,
       self.s3_prefix,
       self.image_repositories,
       role_arn=self.role_arn,
   )
   if self.resolve_image_repositories
   else self.prompt_image_repository(...)
)

If leaving guided out is deliberate, it is worth stating why in the PR description, since the reported symptom is identical in that path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — you're right, the guided path had the same bug. GuidedContext now accepts role_arn, do_cli passes --role-arn through on the guided branch, and it's forwarded at both companion-stack call sites (sync_ecr_stack in guided_prompts and the CompanionStackManager in prompt_image_repository). Added guided tests asserting the role reaches both. Verified: 24/24 guided-context tests and all companion-stack manager tests pass.

GuidedContext now accepts role_arn and forwards it at both guided companion-stack call sites (sync_ecr_stack in guided_prompts and CompanionStackManager in prompt_image_repository); do_cli passes --role-arn through on the guided path. Previously sam deploy --guided --role-arn --resolve-image-repos created the companion stack with caller credentials. Add guided role_arn tests.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..4bd8f41
Files: 5
Comments: 1

"Capabilities": ["CAPABILITY_AUTO_EXPAND"],
}
if self._role_arn:
stack_kwargs["RoleARN"] = self._role_arn

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 service role is now wired into create_stack/update_stack, but sync_repos() also reaches ECR directly, and that path still runs under the caller's credentials — so the failure mode from #5051 remains reachable.

def sync_repos(self) -> None:
   has_repo = bool(self.get_repository_mapping())
   if self.does_companion_stack_exist():
       self.delete_unreferenced_repos()   # <-- direct ECR call, caller creds
       ...
def delete_unreferenced_repos(self) -> None:
   repos = self.get_unreferenced_repos()
   for repo in repos:
       try:
           self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
       except self._ecr_client.exceptions.RepositoryNotFoundException:
           LOG.debug(...)

self._ecr_client is built from ambient credentials in __init__; a CloudFormation RoleARN only affects calls CloudFormation makes on your behalf, not SDK calls SAM CLI makes itself. For the user profile described in the issue (caller has no ECR permissions, the --role-arn role does), removing an image function from the template and re-deploying with --resolve-image-repos --role-arn will raise an AccessDenied ClientError on ecr:DeleteRepository. That error is not caught here (only RepositoryNotFoundException is), so the deploy aborts with a raw botocore error before the companion stack update runs.

Two options, either is fine:

  • Assume role_arn via STS and build self._ecr_client from those credentials when role_arn is set, so all companion-stack side effects use one identity.
  • If keeping caller credentials for ECR is intentional, catch ClientError with AccessDeniedException here and raise an actionable message explaining that ecr:DeleteRepository is required on the calling identity even when --role-arn is supplied.

Worth covering with a unit test for the unreferenced-repo path, since the current tests exercise only create/update.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — when role_arn is set, the manager now assumes it via STS and builds self._ecr_client from the assumed-role credentials, so delete_unreferenced_repos and all other direct ECR calls run under one identity. An assume-role failure raises an actionable AWSServiceClientError. Added tests covering the assumed-role delete path and the failure case.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..5fdfcc7
Files: 5
Comments: 2

self._account_id = boto3.client("sts").get_caller_identity().get("Account")
sts_client = boto3.client("sts")
self._account_id = sts_client.get_caller_identity().get("Account")
if role_arn:

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 constructor now calls sts:AssumeRole on the --role-arn value unconditionally whenever it is set. That is very likely to break the exact command this PR is trying to fix.

--role-arn is a CloudFormation service role: the only principal that needs to be in its trust policy is cloudformation.amazonaws.com. The deploying user/CI principal generally has no sts:AssumeRole permission on it, and the role's trust policy generally does not trust that principal. In that (common) configuration, _ecr_client_for_role raises AWSServiceClientError and sam deploy --role-arn <role> --resolve-image-repos fails hard at manager construction — before any stack or ECR work happens. Users who previously succeeded (main stack under the service role, ECR calls under their own credentials, which they were permitted to make) would now be blocked. This also contradicts the stated side effects in the PR description, which only mentions passing RoleARN to create_stack/update_stack.

The assume also happens eagerly even when no direct ECR call will ever be made (the only use of _ecr_client is delete_unreferenced_repos, which is a no-op when there are no stale repos), so the failure is triggered on paths that need no extra permissions at all.

Suggested direction: keep the RoleARN wiring on the CloudFormation calls (that part correctly fixes #5051), and make the assume-role behavior non-fatal and lazy — only attempt it when a direct ECR call is actually about to be made, and fall back to the caller's credentials with a LOG.debug/warning if the role cannot be assumed:

def getecr_client(self):
   """Return the assumed-role ECR client when available, else the caller's client."""
   if not self._role_arn:
       return self._ecr_client
   if self._role_ecr_client is None:
       try:
           self._role_ecr_client = self._ecr_client_for_role(boto3.client("sts"), self._role_arn)
       except AWSServiceClientError as ex:
           LOG.debug("Unable to assume %s for ECR operations, using caller credentials: %s", self._role_arn, ex)
           self._role_ecr_client = self._ecr_client
   return self._role_ecr_client

If assuming the service role is genuinely intended as a hard requirement, that is a user-visible behavior change that needs to be called out in the PR description and documentation, not just an internal detail.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call — that was a real regression I introduced. The eager assume is gone from the constructor; the role is now assumed lazily only when a direct ECR call is actually about to happen, and an assume failure just falls back to caller credentials with a debug log instead of aborting the deploy. When no --role-arn is set, nothing changed. CloudFormation create/update/delete keep their RoleARN wiring. Reworked the tests: assume is attempted lazily and exactly once, the failure case now asserts construction succeeds and ECR deletes run under caller creds, plus a case that no STS call happens when there are no stale repos.

"Capabilities": ["CAPABILITY_AUTO_EXPAND"],
}
if self._role_arn:
stack_kwargs["RoleARN"] = self._role_arn

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 service role is applied to create_stack/update_stack but not to _delete_companion_stack, which is reachable from sync_repos() whenever the last image function is removed:

if self.does_companion_stack_exist():
   self.delete_unreferenced_repos()
   if has_repo:
       self.update_companion_stack()
   else:
       self._delete_companion_stack()   # no RoleARN

DeleteStack accepts RoleARN. When omitted, CloudFormation reuses the role previously associated with the stack — but any companion stack created before this fix (or created without --role-arn) has no associated role, so CloudFormation falls back to a session derived from the caller's credentials and the stack delete can fail with the same AccessDenied class of error as #5051, this time on repository deletion. Passing the role keeps the whole companion-stack lifecycle on one identity:

def deletecompanion_stack(self) -> None:
   stack_name = self._companion_stack.stack_name
   waiter = self._cfn_client.get_waiter("stack_delete_complete")
   delete_kwargs: Dict[str, Any] = {"StackName": stack_name}
   if self._role_arn:
       delete_kwargs["RoleARN"] = self._role_arn
   self._cfn_client.delete_stack(**delete_kwargs)
   waiter.wait(StackName=stack_name, WaiterConfig=self._delete_stack_waiter_config)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — _delete_companion_stack now passes RoleARN on delete_stack when the role is set, so the whole companion-stack lifecycle (create/update/delete) runs under one identity. Also covered by a new test asserting delete_stack gets the RoleARN kwarg; the no-role case still deletes with just StackName.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..75f5f7c
Files: 5
Comments: 2

"""
try:
assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
except ClientError as ex:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The _get_ecr_client docstring states that "any failure falls back to the caller's credentials rather than aborting the deploy", but only ClientError is translated into AWSServiceClientError here — and _get_ecr_client catches only AWSServiceClientError. Any other assume_role failure escapes the fallback and aborts the deploy from a path that is explicitly designed never to abort.

This is reachable without an unusual setup: --role-arn is an unvalidated free-form string (role_arn_click_option in samcli/commands/_utils/options.py declares no type or callback), and botocore client-side validates RoleArn before the call. A typo'd or truncated value raises ParamValidationError, not ClientError. Because sync_repos() runs delete_unreferenced_repos() before update_companion_stack(), the user gets a raw botocore traceback instead of the clean CloudFormation validation error they would have gotten previously. Endpoint/connection errors from STS have the same effect.

Widening the caught type keeps the documented behavior intact:

from botocore.exceptions import BotoCoreError, ClientError

try:
   assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
except (ClientError, BotoCoreError) as ex:
   raise AWSServiceClientError(
       f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
   ) from ex

BotoCoreError is the base for ParamValidationError, NoCredentialsError, and the endpoint/connection errors, so the fallback then covers every realistic STS failure.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point — a malformed role ARN fails with ParamValidationError before any ClientError is raised, so the fallback would never trigger there. _ecr_client_for_role now catches BotoCoreError as well, and I added a test with a malformed ARN covering the fallback.

If repo does not exist, this will simply skip it.
"""
repos = self.get_unreferenced_repos()
ecr_client = self._get_ecr_client()

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] _get_ecr_client() is called before checking whether there is anything to delete, so the sts:AssumeRole attempt happens even when repos is empty. That contradicts the laziness the method's own docstring promises ("only when a direct ECR call is actually about to be made").

The empty case is the steady state, not an edge case: sync_repos() calls delete_unreferenced_repos() on every deploy where the companion stack already exists, and there are usually no stale repos. Combined with the fact that a CloudFormation service role's trust policy generally does not include the deploying principal — as the docstring itself notes — this means every routine sam deploy --role-arn ... --resolve-image-repos emits a failed AssumeRole to CloudTrail for no reason, which is the kind of event security tooling alerts on.

Guarding on the repo list resolves it:

repos = self.get_unreferenced_repos()
if not repos:
   return
ecr_client = self._get_ecr_client()
for repo in repos:
   ...

Note on prior review threads: the four earlier findings (guided-path plumbing, RoleARN on _delete_companion_stack, the unconditional constructor-time assume_role, and the direct-ECR identity mismatch) are all resolved in this diff and were not re-raised.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — delete_unreferenced_repos now returns early when there are no candidate repos, so AssumeRole is never called in that case. Added a test asserting assume_role isn't invoked when nothing is stale.

…and skip AssumeRole when there are no stale repos

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..a3353b2
Files: 5
Comments: 2

raise AWSServiceClientError(
f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
) from ex
credentials = assumed_role["Credentials"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The try block only wraps the assume_role call, so the "always falls back" contract documented in _get_ecr_client is still incomplete — this is the unresolved remainder of the earlier error-handling finding. Broadening the caught types to (ClientError, BotoCoreError) fixed the exception-breadth half, but the two statements that consume the response are outside the protected region:

except (ClientError, BotoCoreError) as ex:
       raise AWSServiceClientError(...) from ex
   credentials = assumed_role["Credentials"]   # outside try
   return boto3.client(                        # outside try
       "ecr",
       config=self._boto_config,
       aws_access_key_id=credentials["AccessKeyId"],
       ...
   )

_get_ecr_client catches only AWSServiceClientError, so anything raised by these two statements (a KeyError on a response shape that lacks Credentials/AccessKeyId, or a botocore error from client construction) propagates out of delete_unreferenced_repossync_repos and aborts the deploy from a code path whose whole purpose is to never abort. That is the same class of failure the fallback was added to prevent, and the fix is to move both statements inside the protected block:

try:
       assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
       credentials = assumed_role["Credentials"]
       return boto3.client(
           "ecr",
           config=self._boto_config,
           aws_access_key_id=credentials["AccessKeyId"],
           aws_secret_access_[REDACTED]
           aws_session_[REDACTED]
       )
   except (ClientError, BotoCoreError, KeyError) as ex:
       raise AWSServiceClientError(
           f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
       ) from ex

This also makes the Raises section of the docstring accurate for the whole method rather than just its first line.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — the try block now also covers credential extraction and ECR client construction, so any failure there (malformed AssumeRole response, client construction errors) raises AWSServiceClientError and falls back to the caller-credential client.

self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
except self._ecr_client.exceptions.RepositoryNotFoundException:
ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
except ecr_client.exceptions.RepositoryNotFoundException:

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] Routing delete_repository through the assumed service role changes the identity of this call for users whose role is assumable, but the except clause still only handles RepositoryNotFoundException. A CloudFormation service role is commonly scoped to what CFN needs to create/update the stack; if it can be assumed but lacks ecr:DeleteRepository, the resulting AccessDenied ClientError is unhandled and aborts sam deploy before packaging — a case that worked before this PR, because the delete previously always ran under the caller's (typically broader) credentials.

Note this is reachable on ordinary deploys, not just teardown: sync_repos() calls delete_unreferenced_repos() whenever the companion stack exists, so removing or renaming a single image function is enough.

Since the design already treats the assumed role as best-effort, the permission failure deserves the same treatment — fall back to the caller's client rather than failing the deploy:

for repo in repos:
           try:
               ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
           except ecr_client.exceptions.RepositoryNotFoundException:
               LOG.debug("Image repo [%s] not found in companion stack. Skipping deletion.", repo.physical_id)
           except ClientError as ex:
               if ecr_client is self._ecr_client or ex.response.get("Error", {}).get("Code") != "AccessDeniedException":
                   raise
               LOG.debug(
                   "Assumed role not authorized to delete image repo [%s]; retrying with caller credentials.",
                   repo.physical_id,
               )
               self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)

Re-raising for any other error code keeps genuine failures visible instead of silently swallowing them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — if the assumed-role delete hits AccessDeniedException, we now retry the delete with the caller-credential client instead of aborting the deploy. Other errors still propagate.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 94ee3f3..c04273d
Files: 5
Comments: 1

"Assumed role not authorized to delete image repo [%s]; retrying with caller credentials.",
repo.physical_id,
)
self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The caller-credentials retry is not covered by the RepositoryNotFoundException handler that guards the primary call, so this method's documented contract — "If repo does not exist, this will simply skip it" — no longer holds on the fallback path.

ECR authorizes a request before checking whether the resource exists, so a service role that is assumable but lacks ecr:DeleteRepository returns AccessDeniedException even for a repository that was already deleted out of band (manually, or by a previous partially-failed deploy). The fallback then calls self._ecr_client.delete_repository, which raises RepositoryNotFoundException with nothing catching it — aborting the deploy from the exact code path that was added to keep the deploy alive.

Secondary point: after the fallback fires for the first repo, the loop keeps using the assumed-role client for the remaining repos, so every subsequent repo pays another AccessDenied round trip before falling back again.

Both are fixed by reusing the existing not-found handling and demoting the client once:

except ClientError as ex:
               error_code = ex.response.get("Error", {}).get("Code")
               if ecr_client is self._ecr_client or error_code != "AccessDeniedException":
                   raise
               LOG.debug(
                   "Assumed role not authorized to delete image repo [%s]; retrying with caller credentials.",
                   repo.physical_id,
               )
               # Stop using the assumed-role client for the remaining repos.
               self._role_ecr_client = ecr_client = self._ecr_client
               try:
                   self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
               except self._ecr_client.exceptions.RepositoryNotFoundException:
                   LOG.debug("Image repo [%s] not found in companion stack. Skipping deletion.", repo.physical_id)

Rebinding ecr_client is safe here because the except ecr_client.exceptions.… clause is resolved at exception time, so later iterations correctly reference the caller's client.

Notes on prior findings: the guided-path wiring (command.py, guided_context.py), the lazy assume with caller-credential fallback, the RoleARN on delete_stack, the broadened (ClientError, BotoCoreError, KeyError) catch with response consumption inside the try, and the early return that avoids sts:AssumeRole when no repos are stale are all present in this head and are no longer flagged. I verified no other companion-stack entry point needs the role: sam package --resolve-image-repos has no --role-arn option, sam sync sets support_resolve_image_repos=False, and the PackageContext created by sam deploy leaves resolve_image_repos at its False default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/deploy sam deploy command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant