refactor: move ecr auth bridge to domain

This commit is contained in:
joshsiv-mulga
2026-10-01 14:48:07 +10:00
parent 5e7311eb25
commit 474c073167
14 changed files with 49 additions and 49 deletions
@@ -1,4 +1,4 @@
package gateway_ecrauth
package ecrauth
import (
"crypto/ecdsa"
@@ -1,4 +1,4 @@
package gateway_ecrauth
package ecrauth
import (
"context"
@@ -1,4 +1,4 @@
package gateway_ecrauth
package ecrauth
import (
"context"
@@ -1,9 +1,9 @@
// Package gateway_ecrauth implements the ECR auth bridge: it mints and verifies
// Package ecrauth implements the ECR auth bridge: it mints and verifies
// the self-contained ES256 JWT that GetAuthorizationToken issues and the
// /v2/* registry surface accepts (Authorization: Bearer | Basic AWS:<jwt>).
// Signing keys live in the cluster-replicated JetStream KV bucket awsgw-keys
// under jwt-signing/{kid}, encrypted at rest with the IAM master key.
package gateway_ecrauth
package ecrauth
import (
"context"
@@ -1,4 +1,4 @@
package gateway_ecrauth
package ecrauth
import (
"crypto/ecdsa"
@@ -8,8 +8,8 @@ import (
"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"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
@@ -42,7 +42,7 @@ func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *h
return errors.New(awserrors.ErrorServerInternal)
}
token, expiresAt, err := gw.ECRTokenIssuer.Mint(gateway_ecrauth.Principal{
token, expiresAt, err := gw.ECRTokenIssuer.Mint(ecrauth.Principal{
AccountID: accountID,
ARN: callerARN,
Type: principalType,
+5 -5
View File
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
@@ -72,13 +72,13 @@ func classifyIAMLookupErr(err error, what string) error {
}
// resolveECRPrincipal rehydrates claims (already ES256/expiry/issuer/audience
// verified by gateway_ecrauth.Verifier) against current IAM/STS state and
// verified by ecrauth.Verifier) against current IAM/STS state and
// returns the equivalent of a fresh SigV4 authentication for the same
// identity. It never trusts claims.PrincipalType or claims.Subject on their
// own — both are cross-checked against the freshly resolved record before
// being accepted, so a forged or stale claim cannot widen access beyond what
// the current IAM/STS state actually grants.
func (gw *GatewayConfig) resolveECRPrincipal(claims *gateway_ecrauth.Claims) (principalContext, error) {
func (gw *GatewayConfig) resolveECRPrincipal(claims *ecrauth.Claims) (principalContext, error) {
switch {
case strings.HasPrefix(claims.AccessKeyID, longLivedAKIDPrefix):
return gw.resolveECRLongLivedPrincipal(claims)
@@ -94,7 +94,7 @@ func (gw *GatewayConfig) resolveECRPrincipal(claims *gateway_ecrauth.Claims) (pr
// user it names must still exist, the account must still be active, and the
// canonical ARN/principalType computed from that current state must match
// what was signed into the token.
func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *gateway_ecrauth.Claims) (principalContext, error) {
func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *ecrauth.Claims) (principalContext, error) {
if gw.IAMService == nil {
return principalContext{}, ecrDependencyFailure("IAM service not available")
}
@@ -143,7 +143,7 @@ func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *gateway_ecrauth.Cl
// GetSessionToken user session and an assumed-role session are re-resolved
// against their principal's live IAM record and rejected if it was deleted, or
// recreated under the same name with a different immutable ID.
func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Claims) (principalContext, error) {
func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *ecrauth.Claims) (principalContext, error) {
if gw.STSService == nil {
return principalContext{}, ecrDependencyFailure("STS service not available")
}
+14 -14
View File
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
@@ -162,7 +162,7 @@ func TestResolveECRPrincipal_LongLivedUser(t *testing.T) {
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
@@ -180,7 +180,7 @@ func TestResolveECRPrincipal_GlobalRoot(t *testing.T) {
seedECRTestUser(iamSvc, utils.GlobalAccountID, "root", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: utils.GlobalAccountID,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
@@ -194,8 +194,8 @@ func TestResolveECRPrincipal_GlobalRoot(t *testing.T) {
}
func TestResolveECRPrincipal_LongLivedUser_Rejections(t *testing.T) {
validClaims := func() *gateway_ecrauth.Claims {
c := &gateway_ecrauth.Claims{
validClaims := func() *ecrauth.Claims {
c := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
@@ -311,7 +311,7 @@ func TestResolveECRPrincipal_SessionToken_User(t *testing.T) {
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
@@ -343,7 +343,7 @@ func TestResolveECRPrincipal_SessionToken_User_StalePrincipalRejected(t *testing
stsSvc.principalErr = tc.err
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
@@ -363,7 +363,7 @@ func TestResolveECRPrincipal_SessionToken_Expired(t *testing.T) {
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(-time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
@@ -377,7 +377,7 @@ func TestResolveECRPrincipal_SessionToken_Expired(t *testing.T) {
func TestResolveECRPrincipal_SessionToken_UnknownSession(t *testing.T) {
gw := &GatewayConfig{IAMService: newECRMockIAMService(), STSService: newECRMockSTSService()}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
@@ -419,7 +419,7 @@ func TestResolveECRPrincipal_AssumedRole(t *testing.T) {
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
@@ -441,7 +441,7 @@ func TestResolveECRPrincipal_AssumedRole_LegacyEmptyPrincipalType(t *testing.T)
stsSvc.sessions[ecrPrincipalTestASID].PrincipalType = "" // pre-PrincipalType legacy record
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
@@ -454,8 +454,8 @@ func TestResolveECRPrincipal_AssumedRole_LegacyEmptyPrincipalType(t *testing.T)
}
func TestResolveECRPrincipal_AssumedRole_Rejections(t *testing.T) {
baseClaims := func(assumedARN string) *gateway_ecrauth.Claims {
c := &gateway_ecrauth.Claims{
baseClaims := func(assumedARN string) *ecrauth.Claims {
c := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
@@ -534,7 +534,7 @@ func TestResolveECRPrincipal_AssumedRole_Rejections(t *testing.T) {
func TestResolveECRPrincipal_UnrecognizedAccessKeyPrefix(t *testing.T) {
gw := &GatewayConfig{IAMService: newECRMockIAMService()}
claims := &gateway_ecrauth.Claims{
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: "NOTAVALIDPREFIX",
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"time"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
)
// ociTokenResponse is the Docker Registry v2 token-endpoint body. token and
@@ -89,7 +89,7 @@ func (gw *GatewayConfig) handleECRToken(w http.ResponseWriter, r *http.Request)
return
}
fresh, expiresAt, err := gw.ECRTokenIssuer.Mint(gateway_ecrauth.Principal{
fresh, expiresAt, err := gw.ECRTokenIssuer.Mint(ecrauth.Principal{
AccountID: principal.accountID,
ARN: callerARN,
Type: principal.principalType,
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/go-chi/chi/v5"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -52,7 +52,7 @@ func TestECRToken_BearerCredentialAccepted(t *testing.T) {
IAMService: ecrBridgeTestIAM(ecrTestAccount),
}
tok, _, err := iss.Mint(gateway_ecrauth.Principal{
tok, _, err := iss.Mint(ecrauth.Principal{
AccountID: ecrTestAccount,
ARN: "arn:aws:iam::" + ecrTestAccount + ":user/dev",
Type: principalTypeUser,
+6 -6
View File
@@ -9,7 +9,7 @@ import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -30,13 +30,13 @@ func ecrTestMasterKey(t *testing.T) []byte {
}
// newECRAuth builds a wired issuer+verifier over a freshly generated signing key.
func newECRAuth(t *testing.T) (*gateway_ecrauth.Issuer, *gateway_ecrauth.Verifier) {
func newECRAuth(t *testing.T) (*ecrauth.Issuer, *ecrauth.Verifier) {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t))
key, verify, err := ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t))
require.NoError(t, err)
return gateway_ecrauth.NewIssuer(key, ecrTestAudience), gateway_ecrauth.NewVerifier(verify, ecrTestAudience)
return ecrauth.NewIssuer(key, ecrTestAudience), ecrauth.NewVerifier(verify, ecrTestAudience)
}
// mintBasic mints a token for account/"dev" using ecrBridgeTestAKID as its
@@ -45,9 +45,9 @@ func newECRAuth(t *testing.T) (*gateway_ecrauth.Issuer, *gateway_ecrauth.Verifie
// active "dev" user in account (see ecrBridgeTestIAM), since the bridge now
// rehydrates every token against current IAM state rather than trusting the
// claims outright.
func mintBasic(t *testing.T, iss *gateway_ecrauth.Issuer, account string) string {
func mintBasic(t *testing.T, iss *ecrauth.Issuer, account string) string {
t.Helper()
tok, _, err := iss.Mint(gateway_ecrauth.Principal{
tok, _, err := iss.Mint(ecrauth.Principal{
AccountID: account,
ARN: "arn:aws:iam::" + account + ":user/dev",
Type: principalTypeUser,
+3 -3
View File
@@ -27,12 +27,12 @@ import (
"github.com/mulgadc/bluebottle/pkg/sigv4"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/spinifex/accountteardown"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks"
"github.com/mulgadc/spinifex/spinifex/gateway/policy"
gateway_sts "github.com/mulgadc/spinifex/spinifex/gateway/sts"
@@ -162,8 +162,8 @@ type GatewayConfig struct {
// ECRTokenIssuer mints GetAuthorizationToken JWTs; ECRTokenVerifier validates
// them on /v2/*. Both nil disables the auth bridge (registry mounts open, as
// in unit tests of unrelated routes).
ECRTokenIssuer *gateway_ecrauth.Issuer
ECRTokenVerifier *gateway_ecrauth.Verifier
ECRTokenIssuer *ecrauth.Issuer
ECRTokenVerifier *ecrauth.Verifier
// BedrockCredentials resolves per-account provider API keys for bedrock
// routes. Nil falls back to no external providers (self-host models only).
BedrockCredentials *gateway_bedrock.CredentialStore
+5 -5
View File
@@ -23,6 +23,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
@@ -30,7 +31,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/gateway"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
handlers_bedrock "github.com/mulgadc/spinifex/spinifex/handlers/bedrock"
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
@@ -288,7 +288,7 @@ func launchService(config *config.ClusterConfig) error {
if err != nil {
return fmt.Errorf("jetstream client: %w", err)
}
signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(janitorCtx, js, masterKey)
signingKey, verifyKeys, err := ecrauth.LoadOrCreateSigningKey(janitorCtx, js, masterKey)
if err != nil {
return fmt.Errorf("ECR auth bridge: load signing key: %w", err)
}
@@ -452,8 +452,8 @@ func launchService(config *config.ClusterConfig) error {
Version: version,
Commit: commit,
ECRRegistry: ecrRegistry,
ECRTokenIssuer: gateway_ecrauth.NewIssuer(signingKey, ecrAudience),
ECRTokenVerifier: gateway_ecrauth.NewVerifier(verifyKeys, ecrAudience),
ECRTokenIssuer: ecrauth.NewIssuer(signingKey, ecrAudience),
ECRTokenVerifier: ecrauth.NewVerifier(verifyKeys, ecrAudience),
BedrockCredentials: bedrockCredentials,
BedrockEndpoints: bedrockEndpoints,
BedrockEndpointResolver: bedrockEndpointResolver,
@@ -473,7 +473,7 @@ func launchService(config *config.ClusterConfig) error {
// Rotate the ECR signing key on a 30-day cadence, retaining the previous keys
// until their tokens expire. The rotator keeps the issuer/verifier current as
// keys roll. Bound to the same lifetime context as the STS janitor.
keyRotator, err := gateway_ecrauth.NewRotator(janitorCtx, js, masterKey, gw.ECRTokenIssuer, gw.ECRTokenVerifier)
keyRotator, err := ecrauth.NewRotator(janitorCtx, js, masterKey, gw.ECRTokenIssuer, gw.ECRTokenVerifier)
if err != nil {
return fmt.Errorf("ECR auth bridge: signing-key rotator: %w", err)
}
+4 -4
View File
@@ -34,10 +34,10 @@ import (
"github.com/aws/aws-sdk-go/service/sts"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
"github.com/mulgadc/spinifex/spinifex/gateway"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
@@ -155,7 +155,7 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option)
// ECR auth bridge signing key: reuses the IAM master key to encrypt the
// signing key at rest in the same embedded JetStream KV, matching
// production's awsgw-keys wiring (services/awsgw/awsgw.go).
signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, masterKey)
signingKey, verifyKeys, err := ecrauth.LoadOrCreateSigningKey(t.Context(), js, masterKey)
require.NoError(t, err)
bedrockAccess := gateway_bedrock.NewModelAccessStore(js)
@@ -174,8 +174,8 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option)
// real MetaServiceImpl or every ECR request will time out with no
// responder. Blob/manifest bytes are memory-backed: no predastore.
ECRRegistry: ecrregistry.NewRegistry(objectstore.NewMemoryObjectStore(), handlers_ecr.NewNATSMetaStore(nc), utils.GlobalAccountID),
ECRTokenIssuer: gateway_ecrauth.NewIssuer(signingKey, testECRAudience),
ECRTokenVerifier: gateway_ecrauth.NewVerifier(verifyKeys, testECRAudience),
ECRTokenIssuer: ecrauth.NewIssuer(signingKey, testECRAudience),
ECRTokenVerifier: ecrauth.NewVerifier(verifyKeys, testECRAudience),
// Ochre model access is deny-by-default, so without a grant store every
// bedrock route refuses. Tests sign as the system account, which the
// store exempts from grants exactly as it does in production, so the