From a2f6005d29172dd38f748ef2bd9773ec3d165ec5 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Thu, 1 Oct 2026 15:38:50 +1000 Subject: [PATCH] refactor: move ecr authorization token action to domain --- .../domains/ecr/awsapi/authorization_token.go | 41 +++++++++++++ .../ecr/awsapi/authorization_token_test.go | 58 +++++++++++++++++++ spinifex/gateway/ecr_getauthorizationtoken.go | 28 +++------ 3 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 spinifex/domains/ecr/awsapi/authorization_token.go create mode 100644 spinifex/domains/ecr/awsapi/authorization_token_test.go diff --git a/spinifex/domains/ecr/awsapi/authorization_token.go b/spinifex/domains/ecr/awsapi/authorization_token.go new file mode 100644 index 000000000..f2381c593 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/authorization_token.go @@ -0,0 +1,41 @@ +package awsapi + +import ( + "encoding/base64" + "errors" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecr" + ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// AuthorizationTokenIssuer is the narrow credential capability the ECR AWS +// action needs. ECR auth owns the concrete ES256 issuer; the AWS adapter owns +// its AWS JSON response projection. +type AuthorizationTokenIssuer interface { + Mint(ecrauth.Principal) (token string, expiresAt time.Time, err error) +} + +// GetAuthorizationToken mints an ECR registry token for the canonical +// SigV4-authenticated principal. The returned token is base64("AWS:"), +// which Docker replays as Basic auth to the ECR /v2 registry endpoint. +func GetAuthorizationToken(issuer AuthorizationTokenIssuer, endpoint RepositoryEndpoint, principal ecrauth.Principal) (*ecr.GetAuthorizationTokenOutput, error) { + if issuer == nil { + return nil, errors.New(awserrors.ErrorNotImplemented) + } + token, expiresAt, err := issuer.Mint(principal) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + authToken := base64.StdEncoding.EncodeToString([]byte("AWS:" + token)) + return &ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []*ecr.AuthorizationData{{ + AuthorizationToken: aws.String(authToken), + ProxyEndpoint: aws.String("https://" + endpoint.RegistryURIHost(principal.AccountID)), + ExpiresAt: aws.Time(expiresAt), + }}, + }, nil +} diff --git a/spinifex/domains/ecr/awsapi/authorization_token_test.go b/spinifex/domains/ecr/awsapi/authorization_token_test.go new file mode 100644 index 000000000..ada98a740 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/authorization_token_test.go @@ -0,0 +1,58 @@ +package awsapi + +import ( + "encoding/base64" + "errors" + "testing" + "time" + + ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeAuthorizationTokenIssuer struct { + got ecrauth.Principal + token string + expiresAt time.Time + err error +} + +func (i *fakeAuthorizationTokenIssuer) Mint(principal ecrauth.Principal) (string, time.Time, error) { + i.got = principal + return i.token, i.expiresAt, i.err +} + +func TestGetAuthorizationToken_ProjectsMintedCredential(t *testing.T) { + expiresAt := time.Date(2026, 10, 2, 3, 4, 5, 0, time.UTC) + issuer := &fakeAuthorizationTokenIssuer{token: "signed-token", expiresAt: expiresAt} + principal := ecrauth.Principal{ + AccountID: "123456789012", ARN: "arn:aws:iam::123456789012:user/dev", + Type: "user", AccessKeyID: "AKIAEXAMPLE", + } + + out, err := GetAuthorizationToken(issuer, RepositoryEndpoint{ + Region: "ap-southeast-2", ServicesDomain: "spinifex.test", RegistryPort: "9999", + }, principal) + require.NoError(t, err) + require.Len(t, out.AuthorizationData, 1) + data := out.AuthorizationData[0] + assert.Equal(t, principal, issuer.got) + assert.Equal(t, "https://123456789012.dkr.ecr.ap-southeast-2.spinifex.test:9999", *data.ProxyEndpoint) + assert.Equal(t, expiresAt, *data.ExpiresAt) + decoded, err := base64.StdEncoding.DecodeString(*data.AuthorizationToken) + require.NoError(t, err) + assert.Equal(t, "AWS:signed-token", string(decoded)) +} + +func TestGetAuthorizationToken_RefusesUnavailableIssuer(t *testing.T) { + principal := ecrauth.Principal{AccountID: "123456789012"} + _, err := GetAuthorizationToken(nil, RepositoryEndpoint{}, principal) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorNotImplemented, awserrors.ValidErrorCodeFromError(err)) + + _, err = GetAuthorizationToken(&fakeAuthorizationTokenIssuer{err: errors.New("signer unavailable")}, RepositoryEndpoint{}, principal) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err)) +} diff --git a/spinifex/gateway/ecr_getauthorizationtoken.go b/spinifex/gateway/ecr_getauthorizationtoken.go index 101cdb838..a07d8ad88 100644 --- a/spinifex/gateway/ecr_getauthorizationtoken.go +++ b/spinifex/gateway/ecr_getauthorizationtoken.go @@ -1,22 +1,19 @@ package gateway import ( - "encoding/base64" "errors" "log/slog" "net/http" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ecr" ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth" awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" ) -// handleGetAuthorizationToken mints a self-contained ES256 ECR token for the -// SigV4-authenticated caller and returns it in the AWS GetAuthorizationToken -// shape. The token is base64("AWS:"); docker stores it and replays it as -// Basic auth against the /v2 registry, where the auth bridge verifies it. +// handleGetAuthorizationToken adapts an authenticated gateway identity into +// the canonical ECR credential principal. The AWS token response is owned by +// the ECR action adapter, while canonical IAM/STS ARN construction remains a +// generic gateway identity concern. func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *http.Request) error { if gw.ECRTokenIssuer == nil { return errors.New(awserrors.ErrorNotImplemented) @@ -42,26 +39,17 @@ func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *h return errors.New(awserrors.ErrorServerInternal) } - token, expiresAt, err := gw.ECRTokenIssuer.Mint(ecrauth.Principal{ + output, err := awsapi.GetAuthorizationToken(gw.ECRTokenIssuer, gw.ecrRepositoryEndpoint(), ecrauth.Principal{ AccountID: accountID, ARN: callerARN, Type: principalType, AccessKeyID: accessKey, }) if err != nil { - slog.Error("GetAuthorizationToken: mint failed", "err", err) - return errors.New(awserrors.ErrorServerInternal) + slog.Error("GetAuthorizationToken: action failed", "err", err) + return err } - authToken := base64.StdEncoding.EncodeToString([]byte("AWS:" + token)) - proxyEndpoint := "https://" + gw.ecrRepositoryEndpoint().RegistryURIHost(accountID) - - awsapi.WriteJSONResponse(w, &ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []*ecr.AuthorizationData{{ - AuthorizationToken: aws.String(authToken), - ProxyEndpoint: aws.String(proxyEndpoint), - ExpiresAt: aws.Time(expiresAt), - }}, - }) + awsapi.WriteJSONResponse(w, output) return nil }