Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ APACHE 2.0 LICENSED DEPENDENCIES
License: Apache-2.0
URL: https://github.com/aws/aws-sdk-go-v2/blob/service/ecr/v1.56.1/service/ecr/LICENSE.txt

- github.com/aws/aws-sdk-go-v2/service/ecrpublic
License: Apache-2.0
URL: https://github.com/aws/aws-sdk-go-v2/blob/service/ecrpublic/v1.38.12/service/ecrpublic/LICENSE.txt

- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding
License: Apache-2.0
URL: https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.13.7/service/internal/accept-encoding/LICENSE.txt
Expand Down
168 changes: 168 additions & 0 deletions docs/prd/ecr-public-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# ECR Public Authentication PRD

## Executive Summary

Add ECR Public authentication support to Atmos via a new **`aws/ecr-public`** integration kind. This enables authenticated pulls from `public.ecr.aws`, eliminating rate limits that affect CI workflows.

**Companion to:** [`ecr-authentication.md`](./ecr-authentication.md) (private ECR).

## Problem Statement

Docker image pulls from `public.ecr.aws` are rate-limited when unauthenticated. The `cloudposse/github-action-docker-build-push` action pulls BuildKit and binfmt images from public ECR, so every Docker build hits these limits. Currently there is no native Atmos way to authenticate to ECR Public — users must add manual `docker/login-action` steps to their workflows.

### User Impact

**Current Experience:**
```bash
# Must manually authenticate to ECR Public
$ aws ecr-public get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin public.ecr.aws

# Or add docker/login-action steps to every CI workflow
```

**Desired Experience:**
```bash
# Automatic via integration auto_provision
$ atmos auth login dev-admin
✓ Authenticated as arn:aws:sts::123456789012:assumed-role/DevRole/user
✓ ECR Public login: public.ecr.aws (expires in 12h)

# Or explicit
$ atmos auth ecr-login ecr-public
✓ ECR Public login: public.ecr.aws (expires in 12h)
```

## How ECR Public Differs from Private ECR

| Aspect | Private ECR (`aws/ecr`) | Public ECR (`aws/ecr-public`) |
|--------|------------------------|-------------------------------|
| AWS SDK service | `ecr` | `ecrpublic` |
| API call | `ecr:GetAuthorizationToken` | `ecr-public:GetAuthorizationToken` |
| Auth mechanism | SigV4 | Bearer token (`sts:GetServiceBearerToken`) |
| Auth region | Any region where registry exists | **us-east-1 only** |
| Service regions | All commercial AWS regions | us-east-1, us-west-2 only |
| Registry URL | `{account_id}.dkr.ecr.{region}.amazonaws.com` | `public.ecr.aws` (always) |
| IAM permissions | `ecr:GetAuthorizationToken` | `ecr-public:GetAuthorizationToken` + `sts:GetServiceBearerToken` |
| Config needs | `account_id` + `region` required | No config needed (fixed endpoint) |
| China/GovCloud | Private ECR available | **Not available** |

### Regional Availability

ECR Public is only available in two regions:

| Region | Service endpoints | Auth (`GetAuthorizationToken`) |
|--------|------------------|-------------------------------|
| us-east-1 (N. Virginia) | Yes | **Yes (only region)** |
| us-west-2 (Oregon) | Yes | No |

**Not available in:** EU, Asia Pacific, China (cn-north-1, cn-northwest-1), GovCloud, or any other region.

**Source:** [AWS ECR Public endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/ecr-public.html).

### Region Validation

The implementation must validate any user-specified region against the supported set `{us-east-1, us-west-2}`. Auth calls are always forced to us-east-1 regardless of any user configuration.

## Configuration Schema

### Minimal Configuration (Recommended)

```yaml
auth:
integrations:
ecr-public:
kind: aws/ecr-public
via:
identity: plat-dev/terraform
spec:
auto_provision: true
```

No `registry` block is needed since ECR Public is always `public.ecr.aws` in `us-east-1`.

### Configuration Options

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `kind` | Yes | — | Must be `aws/ecr-public` |
| `via.identity` | No | — | Identity providing AWS credentials |
| `spec.auto_provision` | No | `true` | Auto-trigger on identity login |

## Technical Specification

### New Integration Kind: `aws/ecr-public`

Registered via the existing integration registry pattern (same as `aws/ecr`).

### Authentication Flow

1. Build AWS config with credentials from the linked identity.
2. Force region to `us-east-1` (the only supported auth region).
3. Call `ecrpublic.GetAuthorizationToken()` via AWS SDK v2.
4. Decode base64 authorization token to `username:password`.
5. Write credentials to Docker config for `public.ecr.aws`.
6. Log success with token expiration time.

### Package Structure (New Files)

```text
pkg/auth/
├── cloud/aws/
│ ├── ecr.go # Existing private ECR
│ ├── ecr_public.go # NEW: ECR Public token fetcher
│ └── ecr_public_test.go # NEW: Tests
├── integrations/
│ ├── types.go # Add KindAWSECRPublic constant
│ └── aws/
│ ├── ecr.go # Existing private ECR integration
│ ├── ecr_public.go # NEW: ECR Public integration
│ └── ecr_public_test.go # NEW: Tests
```

### Error Handling

New sentinel errors in `errors/errors.go`:
- `ErrECRPublicAuthFailed` — "ECR Public authentication failed"
- `ErrECRPublicInvalidRegion` — "invalid ECR Public region"

Integration failures during auto-provision are non-fatal (logged as warnings, don't block identity auth). Explicit `ecr-login` command failures are fatal.

### CLI Integration

No new CLI command needed. The existing `atmos auth ecr-login` command routes through the integration registry and handles `aws/ecr-public` automatically:

```bash
# Named integration
atmos auth ecr-login ecr-public

# Via identity (triggers all auto_provision integrations)
atmos auth ecr-login --identity plat-dev/terraform
```

## Implementation Checklist

- [ ] PRD document (`docs/prd/ecr-public-authentication.md`)
- [ ] SDK dependency (`github.com/aws/aws-sdk-go-v2/service/ecrpublic`)
- [ ] Error sentinels (`errors/errors.go`)
- [ ] Kind constant (`pkg/auth/integrations/types.go`)
- [ ] Cloud layer (`pkg/auth/cloud/aws/ecr_public.go`)
- [ ] Cloud layer tests (`pkg/auth/cloud/aws/ecr_public_test.go`)
- [ ] Integration (`pkg/auth/integrations/aws/ecr_public.go`)
- [ ] Integration tests (`pkg/auth/integrations/aws/ecr_public_test.go`)

## Security Considerations

1. **Token lifetime:** ECR Public tokens expire after 12 hours (AWS-enforced).
2. **Docker config:** Credentials written to standard Docker config location with `0600` permissions.
3. **No secrets in logs:** Authorization tokens are never logged.
4. **Secret masking:** ECR Public tokens follow Atmos secret masking patterns via Gitleaks integration.
5. **Region pinning:** Auth is always pinned to us-east-1, preventing misconfiguration.

## References

- [Amazon ECR Public endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/ecr-public.html)
- [ECR Public registry authentication](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registry-auth.html)
- [ECR Public GetAuthorizationToken API](https://docs.aws.amazon.com/AmazonECRPublic/latest/APIReference/API_GetAuthorizationToken.html)
- [aws ecr-public is region specific — Issue #5917](https://github.com/aws/aws-cli/issues/5917)
- [Companion PRD: ECR Authentication](./ecr-authentication.md)
4 changes: 4 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,10 @@ var (
ErrDockerConfigWrite = errors.New("failed to write Docker config")
ErrDockerConfigRead = errors.New("failed to read Docker config")

// ECR Public authentication errors.
ErrECRPublicAuthFailed = errors.New("ECR Public authentication failed")
ErrECRPublicInvalidRegion = errors.New("invalid ECR Public region: only us-east-1 and us-west-2 are supported")

// Identity authentication errors.
ErrIdentityAuthFailed = errors.New("failed to authenticate identity")
ErrIdentityCredentialsNone = errors.New("credentials not available for identity")
Expand Down
1 change: 1 addition & 0 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 139 additions & 0 deletions pkg/auth/cloud/aws/ecr_public.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package aws

import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/service/ecrpublic"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/auth/types"
"github.com/cloudposse/atmos/pkg/perf"
)

const (
// ECRPublicRegistryURL is the fixed registry URL for ECR Public.
ECRPublicRegistryURL = "public.ecr.aws"

// ECRPublicAuthRegion is the only region that supports ECR Public authentication.
// AWS docs: "always authenticate to the us-east-1 Region".
ECRPublicAuthRegion = "us-east-1"
)

// ECRPublicSupportedRegions contains the regions where ECR Public service endpoints exist.
// Auth (GetAuthorizationToken) is only supported in us-east-1, but the service
// has endpoints in both us-east-1 and us-west-2 for other API operations.
var ECRPublicSupportedRegions = map[string]bool{
"us-east-1": true,
"us-west-2": true,
}

// ECRPublicClient abstracts the AWS ECR Public API for testability.
//
//go:generate go run go.uber.org/mock/mockgen@latest -source=ecr_public.go -destination=mock_ecr_public_client_test.go -package=aws
type ECRPublicClient interface {
GetAuthorizationToken(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error)
}

// ECRPublicAuthResult contains ECR Public authorization token information.
type ECRPublicAuthResult struct {
Username string // Always "AWS".
Password string //nolint:gosec // G117: This is an authorization token, not a password secret.
ExpiresAt time.Time // Token expiration time.
}

// ecrPublicAuthConfig holds optional overrides for GetPublicAuthorizationToken.
type ecrPublicAuthConfig struct {
client ECRPublicClient
}

// ECRPublicAuthOption configures GetPublicAuthorizationToken behavior.
type ECRPublicAuthOption func(*ecrPublicAuthConfig)

// WithECRPublicClient injects a custom ECR Public client (for testing).
func WithECRPublicClient(client ECRPublicClient) ECRPublicAuthOption {
return func(c *ecrPublicAuthConfig) {
c.client = client
}
}

// GetPublicAuthorizationToken retrieves ECR Public credentials using AWS credentials.
// The auth call is always made to us-east-1, which is the only region that supports it.
func GetPublicAuthorizationToken(ctx context.Context, creds types.ICredentials, opts ...ECRPublicAuthOption) (*ECRPublicAuthResult, error) {
defer perf.Track(nil, "aws.GetPublicAuthorizationToken")()

cfg := &ecrPublicAuthConfig{}
for _, opt := range opts {
opt(cfg)
}

client := cfg.client
if client == nil {
// Build AWS config from credentials, forcing us-east-1 for auth.
awsCfg, err := buildAWSConfigFromCreds(ctx, creds, ECRPublicAuthRegion)
if err != nil {
return nil, fmt.Errorf("%w: failed to build AWS config: %w", errUtils.ErrECRPublicAuthFailed, err)
}

// Create ECR Public client.
client = ecrpublic.NewFromConfig(awsCfg)
}

// Get authorization token.
result, err := client.GetAuthorizationToken(ctx, &ecrpublic.GetAuthorizationTokenInput{})
if err != nil {
return nil, fmt.Errorf("%w: %w", errUtils.ErrECRPublicAuthFailed, err)
}

if result.AuthorizationData == nil || result.AuthorizationData.AuthorizationToken == nil {
return nil, fmt.Errorf("%w: no authorization data returned", errUtils.ErrECRPublicAuthFailed)
}

// Decode the authorization token (base64 encoded "username:password").
decoded, err := base64.StdEncoding.DecodeString(*result.AuthorizationData.AuthorizationToken)
if err != nil {
return nil, fmt.Errorf("%w: failed to decode token: %w", errUtils.ErrECRPublicAuthFailed, err)
}

// Split into username and password.
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("%w: invalid token format", errUtils.ErrECRPublicAuthFailed)
}

// Parse expiration time.
var expiresAt time.Time
if result.AuthorizationData.ExpiresAt != nil {
expiresAt = *result.AuthorizationData.ExpiresAt
}

return &ECRPublicAuthResult{
Username: parts[0],
Password: parts[1],
ExpiresAt: expiresAt,
}, nil
}

// ValidateECRPublicRegion validates that the given region is supported by ECR Public.
func ValidateECRPublicRegion(region string) error {
defer perf.Track(nil, "aws.ValidateECRPublicRegion")()

if !ECRPublicSupportedRegions[region] {
return fmt.Errorf("%w: %q (supported: us-east-1, us-west-2)", errUtils.ErrECRPublicInvalidRegion, region)
}
return nil
}

// IsECRPublicRegistry checks if a URL is the ECR Public registry.
func IsECRPublicRegistry(url string) bool {
defer perf.Track(nil, "aws.IsECRPublicRegistry")()

url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")

// Match "public.ecr.aws" with optional trailing path.
return url == ECRPublicRegistryURL || strings.HasPrefix(url, ECRPublicRegistryURL+"/")
}
Loading
Loading