Skip to content

feat(registry-auth): auth for private container and artifact registries hosted by cloud providers#317

Merged
erhancagirici merged 3 commits intocrossplane-contrib:mainfrom
haarchri:feature/registry-auth
Jan 26, 2026
Merged

feat(registry-auth): auth for private container and artifact registries hosted by cloud providers#317
erhancagirici merged 3 commits intocrossplane-contrib:mainfrom
haarchri:feature/registry-auth

Conversation

@haarchri
Copy link
Member

Description of your changes

This PR enables access to private container and artifact registries hosted by cloud providers when provider-helm is configured with identities such as WorkloadIdentity, IRSA, or Pod Identity.

When a identity is available, the provider automatically discovers and uses the associated credentials to authenticate against the registry, without requiring explicit registry secrets in the release API.

This allows users to interact with private registries in a seamless and secure way, while continuing to support explicit credentials when needed.

Fixes #

I have:

  • Read and followed Crossplane's contribution process.
  • Run make reviewable to ensure this PR is ready for review.

How has this code been tested

tested with a helm release hosted in a private ECR repository and a public available helm release

kubectl get releases.helm.m -A
NAMESPACE   NAME              CHART             VERSION   SYNCED   READY   STATE      REVISION   DESCRIPTION        AGE
default     helm-test-chart   helm-test-chart   0.1.0     True     True    deployed   1          Install complete   23m
default     pod-info          podinfo           6.9.4     True     True    deployed   1          Install complete   23m

Using RegistryAuth from within upbound saas environment

first we need to configure proidc, that we get the right token injected in the provider-helm:

apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
  name: aws-audience
spec:
  deploymentTemplate:
    spec:
      template:
        metadata:
          annotations:
            proidc.cloud-spaces.upbound.io/audience: sts.amazonaws.com
        spec:
          containers:
          - name: package-runtime
            args:
            - --debug
            env:
            - name: AWS_WEB_IDENTITY_TOKEN_FILE
              value: /var/run/secrets/upbound.io/provider/token
            - name: AWS_ROLE_ARN
              value: arn:aws:iam::123456789101:role/provider-helm-access-eks
            - name: AWS_REGION
              value: us-west-2
      selector: {}
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: haarchri-provider-helm
spec:
  package: xpkg.upbound.io/uchris/provider-helm:v1.0.24-reg-auth
  runtimeConfigRef:
    name: aws-audience

on the AWS side you need an identity-trust and an assigned policy:

trust:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::123456789101:oidc-provider/proidc.upbound.io"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringLike": {
                    "proidc.upbound.io:aud": "sts.amazonaws.com",
                    "proidc.upbound.io:sub": "mcp:upbound/haarchri-*:provider:haarchri-provider-helm"
                }
            }
        }
    ]
}

policy:

          {
            "Version": "2012-10-17",
            "Statement": [
              {
                "Effect": "Allow",
                "Action": [
                  "ecr:GetAuthorizationToken"
                ],
                "Resource": "*"
              },
              {
                "Effect": "Allow",
                "Action": [
                  "ecr:BatchCheckLayerAvailability",
                  "ecr:GetDownloadUrlForLayer",
                  "ecr:BatchGetImage"
                ],
                "Resource": "arn:aws:ecr:*:*:repository/*"
              }
            ]
          }

simply apply the following release:

apiVersion: helm.m.crossplane.io/v1beta1
kind: Release
metadata:
  name: helm-test-chart
  namespace: default
spec:
  forProvider:
    namespace: helm-test-chart
    chart:
      name: helm-test-chart
      repository: "oci://123456789101.dkr.ecr.us-west-2.amazonaws.com"
      version: 0.1.0
  providerConfigRef:
    name: default
    kind: ProviderConfig
kubectl describe releases.helm.m helm-test-chart 
Name:         helm-test-chart
Namespace:    default
Labels:       <none>
Annotations:  crossplane.io/external-create-pending: 2026-01-20T14:51:06Z
              crossplane.io/external-create-succeeded: 2026-01-20T14:51:10Z
              crossplane.io/external-name: helm-test-chart
API Version:  helm.m.crossplane.io/v1beta1
Kind:         Release
Metadata:
  Creation Timestamp:  2026-01-20T14:51:06Z
  Finalizers:
    finalizer.managedresource.crossplane.io
  Generation:        2
  Resource Version:  182164
  UID:               67fd53ca-0da4-4957-8984-734d4ec131af
Spec:
  For Provider:
    Chart:
      Name:  helm-test-chart
      Pull Secret Ref:
        Name:      
      Repository:  oci://123456789101.dkr.ecr.us-west-2.amazonaws.com
      Version:     0.1.0
    Namespace:     helm-test-chart
  Management Policies:
    *
  Provider Config Ref:
    Kind:  ProviderConfig
    Name:  default
Status:
  At Provider:
    Release Description:  Install complete
    Revision:             1
    State:                deployed
  Conditions:
    Last Transition Time:  2026-01-20T14:51:10Z
    Reason:                Available
    Status:                True
    Type:                  Ready
    Last Transition Time:  2026-01-20T14:51:10Z
    Observed Generation:   2
    Reason:                ReconcileSuccess
    Status:                True
    Type:                  Synced
  Synced:                  true
Events:
  Type    Reason                   Age   From                                  Message
  ----    ------                   ----  ----                                  -------
  Normal  CreatedExternalResource  28m   managed/release.helm.m.crossplane.io  Successfully requested creation of external resource
helm list -aA
NAME           	NAMESPACE      	REVISION	UPDATED                                	STATUS  	CHART                	APP VERSION
helm-test-chart	helm-test-chart	1       	2026-01-20 14:51:09.580928507 +0000 UTC	deployed	helm-test-chart-0.1.0	1.16.0        
kubectl get cm -n helm-test-chart   helm-test-chart-configmap  -o yaml
apiVersion: v1
data:
  myvalue: Hello World
kind: ConfigMap
metadata:
  annotations:
    meta.helm.sh/release-name: helm-test-chart
    meta.helm.sh/release-namespace: helm-test-chart
  creationTimestamp: "2026-01-20T14:51:10Z"
  labels:
    app.kubernetes.io/managed-by: Helm
  name: helm-test-chart-configmap
  namespace: helm-test-chart
  resourceVersion: "410671"
  uid: 91c3faa6-6c61-4398-aa42-3b2df213d18c

…ains/credentials helper

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Copy link
Collaborator

@erhancagirici erhancagirici left a comment

Choose a reason for hiding this comment

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

@haarchri many thanks for the PR 🚀 I've left some comments for clarification

amazonKeychain,
google.Keychain,
azureKeychain,
authn.DefaultKeychain,
Copy link
Collaborator

Choose a reason for hiding this comment

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

for clarification purposes, to better understand the potential behavior change here:

  • Does the authn.DefaultKeychain behavior match today's behavior when there is no pullSecretRef, i.e. falls back to anonymous? I assume yes since e2e tests of this PR passed

  • are those keychain resolutions "registry-aware" , e.g. does not try to auth foo registry with say google creds?

  • one potential caveat I see is there is no opt-out of this auth chain order or influence it, that might have edge cases regarding the full intent and cause a behavior change. for example:

    • today, your existing Release MR has no pullSecret, i.e. configured to use anonymous pulls, but say that provider-helm pod also has a valid AWS creds. After upgrading to this, does it (unintentionally) pick AWS creds here? If so, that might potentially break existing MRs after upgrade.
    • similar to above scenario, but you are aware of the new behavior. Still you have mixed usage of anonymous and AWS registry usage, so you have configured valid AWS creds (like IRSA etc). Does it properly fall back to correct auth per Release here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Does the authn.DefaultKeychain behavior match today's behavior when there is no pullSecretRef, i.e. falls back to anonymous? I assume yes since e2e tests of this PR passed

yes, in the no pull secret case, authn.DefaultKeychain ultimately resolves to Anonymous unless it finds explicit creds in the default Docker config chain.

So the intended behavior stays the same:
Before: no creds → Helm pulls anonymously

After: multi-keychain tries helpers → they return Anonymous for registries they don’t own → DefaultKeychain → still Anonymous in the “no creds configured” case

That matches your assumption and lines up with e2e passing.

are those keychain resolutions "registry-aware" , e.g. does not try to auth foo registry with say google creds?

yes, they are effectively registry-aware because each helper only returns credentials for registries it recognizes, and otherwise it returns Anonymous (or an error that is treated as Anonymous, depending on the wrapper).

So for docker.io/..., the ECR helper won’t produce creds, and for *.dkr.ecr.*.amazonaws.com, the GCR/ACR helpers won’t produce creds.

one potential caveat I see is there is no opt-out of this auth chain order or influence it, that might have edge cases regarding the full intent and cause a behavior change. for example:

lets describe this here with an example:
If the target registry is ECR and the pod has AWS credentials available (IRSA, env vars, etc.), the ECR credential helper will always attempt to mint an authorization token via: https://github.com/awslabs/amazon-ecr-credential-helper/blob/main/ecr-login/api/client.go#L220

Case 1: IAM role does not have ecr:GetAuthorizationToken

  • GetAuthorizationToken fails
  • The helper returns an error
  • The keychain resolution falls back to Anonymous
  • Behavior is unchanged compared to today
    This is safe and works as expected.

Case 2: IAM role has ecr:GetAuthorizationToken but does not have repository pull permissions (ecr:BatchGetImage, ecr:GetDownloadUrlForLayer)

  • GetAuthorizationToken succeeds (registry-level permission)
  • Credentials are returned to Helm
  • Helm attempts an authenticated pull
  • ECR enforces repository-level permissions
  • Pull fails with 403 Forbidden

In this scenario, an anonymous pull that previously succeeded due to an ECR repository resource policy may now fail, because the presence of AWS credentials causes the pull to be attempted using authenticated ECR access, which takes precedence over anonymous access.

This behavior is not unique to providers. Crossplane core uses the same keychain resolution logic when fetching packages (xpkg) https://github.com/crossplane/crossplane/blob/main/internal/xpkg/fetch.go#L133 , so the same rules apply there as well. The only difference is that Crossplane core is additionally allowed (via RBAC) to resolve Kubernetes ServiceAccount–based credentials, whereas providers do not have that permission and therefore rely solely on the cloud-provider keychains.

In my point of view this is acceptable as this is not a bug / regression_

  • GetAuthorizationToken does not validate repository access
  • Repository permissions are intentionally checked later by ECR
  • Having ecr:GetAuthorizationToken without pull permissions is a misconfigured IAM policy

Proposed release note (to make the behavior explicit) - and this is the same for the other registries:

If AWS credentials are present (e.g., via IRSA), ECR images will be pulled using ECR authentication.
Ensure the principal has both ecr:GetAuthorizationToken and repository pull permissions (ecr:BatchGetImage, ecr:GetDownloadUrlForLayer).

Note: If an ECR repository allows anonymous pulls via a resource policy, but the workload has AWS credentials with only ecr:GetAuthorizationToken (and not pull permissions), the authenticated pull may fail with 403 even though an anonymous pull would have succeeded.

Copy link
Collaborator

Choose a reason for hiding this comment

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

got it, thanks for the detailed explanation!
Since the new helpers are registry-aware, I think we are safe regarding existing MRs with anonymous pulls in general 👍

Note: If an ECR repository allows anonymous pulls via a resource policy, but the workload has AWS credentials with only ecr:GetAuthorizationToken (and not pull permissions), the authenticated pull may fail with 403 even though an anonymous pull would have succeeded.

I think this is fair 👍 Just for the sake of completeness, this would affect consumers with the specific scenario:

  • today, the provider pod had IRSA (or more generally, similar pod-level auth) config, due to some other arbitrary purpose
  • using a chart from an anonymous ECR (or respective cloud provider's containter registry)
  • now upgrades to this PR, they cannot opt-out of resolving AWS creds and use anonymous pulls.
    Arguably this is rare today, and would be still fair to ask to define public ECR pull access to the principal in this case.
    feat(auth): update dependency to get AWSWebIdentityCredentials #316 is not released yet, so I expect no existing functional IRSA (or similar specific auth) users. So, no practical breaking behavior change here 👍

With that said, it should definitely go into the docs and release notes.

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Copy link
Collaborator

@erhancagirici erhancagirici left a comment

Choose a reason for hiding this comment

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

thanks @haarchri for tackling this! LGTM.

@erhancagirici erhancagirici merged commit bffedd5 into crossplane-contrib:main Jan 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants