Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,4 +460,4 @@ This is **by design** - Barnacle routes to multiple upstream registries, requiri
| Accept header content negotiation | Can't negotiate manifest format (image vs list) | Medium |
| Range request support (206 Partial Content) | Can't resume interrupted blob downloads | Low |
| Accept-Ranges header on blob endpoints | Clients don't know ranges are unsupported | Low |
| Content-Type header on HEAD blob | Minor, not strictly required by spec | Low |
| Content-Type header on HEAD blob | Minor, not strictly required by spec | Low |Ok s
13 changes: 13 additions & 0 deletions docs/architecture/auth/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ If the HEAD check returns `401` or `403`, barnacle returns the upstream's error

**Environment:** Cache is shared across all clients. No per-client scoping or revalidation needed since barnacle owns the credentials and all clients are treated equally.

## Configuration schema design

Auth settings are configured using a flat, named-key structure where each supported auth method has a dedicated top-level field. Only one method should be set at a time; this is enforced at validation rather than at the schema level.

```yaml
auth:
basic:
username: xxx
password: xxx
```

This approach keeps the schema straightforward and self-documenting — valid fields for each auth method are fully described without requiring a polymorphic type/spec envelope. Mutual exclusivity is validated explicitly on startup, failing fast with a clear error if misconfigured.

## Out of Scope

- Client-facing auth enforcement (no plans to require clients to authenticate to barnacle itself)
Expand Down
2 changes: 1 addition & 1 deletion internal/configloader/configloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func LoadConfig(configDirectory string, logger *zap.Logger) (*configuration.Conf

// Validate the configuration
log.Debug("Validating configuration")
if err := config.Validate(); err != nil {
if err := config.Validate(log); err != nil {
log.Error("Configuration validation failed", zap.Error(err))
return nil, fmt.Errorf("%w: %w", ErrValidateConfiguration, err)
}
Expand Down
57 changes: 18 additions & 39 deletions internal/registry/registry_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,8 @@ func TestNewUpstreamRegistry(t *testing.T) {
name: "single upstream",
upstreams: map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
},
wantErr: false,
Expand All @@ -83,19 +81,12 @@ func TestNewUpstreamRegistry(t *testing.T) {
name: "multiple upstreams",
upstreams: map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
"gcr": {
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{
Basic: &configuration.UpstreamBasicAuthentication{
Username: "user",
Password: "pass",
},
},
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{},
},
},
wantErr: false,
Expand Down Expand Up @@ -158,10 +149,8 @@ func TestUpstreamRegistry_ListUpstreams(t *testing.T) {
name: "single upstream",
upstreams: map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
},
wantList: []string{"dockerio"},
Expand All @@ -170,22 +159,16 @@ func TestUpstreamRegistry_ListUpstreams(t *testing.T) {
name: "multiple upstreams",
upstreams: map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
"gcr": {
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{},
},
"quay": {
Registry: "https://quay.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://quay.io",
Authentication: configuration.UpstreamAuthentication{},
},
},
wantList: []string{"dockerio", "gcr", "quay"},
Expand Down Expand Up @@ -229,16 +212,12 @@ func TestUpstreamRegistry_GetUpstream(t *testing.T) {
logger := testutils.CreateTestLogger(t)
config := newTestConfig(t, map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
"gcr": {
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{},
},
})
redisClient, cleanup := setupTestRedis(t)
Expand Down
23 changes: 2 additions & 21 deletions internal/registry/upstream/standard/standard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"strings"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
Expand Down Expand Up @@ -34,27 +33,9 @@ type standardUpstream struct {
logger *zap.Logger
}

// remoteOptions returns the remote options for upstream registry operations,
// including authentication if configured.
// remoteOptions returns the remote options for upstream registry operations.
func (s *standardUpstream) remoteOptions(ctx context.Context) []remote.Option {
opts := []remote.Option{remote.WithContext(ctx)}

auth := s.config.Authentication
switch {
case auth.Basic != nil:
opts = append(opts, remote.WithAuth(authn.FromConfig(authn.AuthConfig{
Username: auth.Basic.Username,
Password: auth.Basic.Password,
})))
case auth.Bearer != nil:
opts = append(opts, remote.WithAuth(authn.FromConfig(authn.AuthConfig{
RegistryToken: auth.Bearer.Token,
})))
default:
// Anonymous authentication - no auth option needed
}

return opts
return []remote.Option{remote.WithContext(ctx)}
}

// buildReference constructs a reference string from registry, repo, and tag/digest.
Expand Down
13 changes: 1 addition & 12 deletions internal/routes/apiroutes/upstreamsapi/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,6 @@ func (c *upstreamControllerV1) Get(ctx *gin.Context) {
ctx.JSON(http.StatusOK, upstreamdtos.GetUpstreamResponse{
Alias: alias,
Registry: cfg.Registry,
AuthType: authType(&cfg.Authentication),
AuthType: cfg.Authentication.GetAuthType().GetName(),
})
}

// authType returns a string identifying the configured authentication type.
func authType(auth *configuration.UpstreamAuthentication) string {
if auth.Basic != nil {
return "basic"
}
if auth.Bearer != nil {
return "bearer"
}
return "anonymous"
}
47 changes: 15 additions & 32 deletions internal/routes/apiroutes/upstreamsapi/v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,16 @@ func TestUpstreamsAPI_List_EmptyConfiguration(t *testing.T) {
func TestUpstreamsAPI_List_WithUpstreams(t *testing.T) {
config := newTestConfig(t, map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
"gcr": {
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{},
},
"quay": {
Registry: "https://quay.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://quay.io",
Authentication: configuration.UpstreamAuthentication{},
},
})

Expand Down Expand Up @@ -140,19 +134,12 @@ func TestUpstreamsAPI_List_WithUpstreams(t *testing.T) {
func TestUpstreamsAPI_Get_ExistingUpstream(t *testing.T) {
config := newTestConfig(t, map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
"gcr": {
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{
Basic: &configuration.UpstreamBasicAuthentication{
Username: "user",
Password: "pass",
},
},
Registry: "https://gcr.io",
Authentication: configuration.UpstreamAuthentication{},
},
})

Expand All @@ -175,7 +162,7 @@ func TestUpstreamsAPI_Get_ExistingUpstream(t *testing.T) {
name: "get gcr",
alias: "gcr",
wantRegistry: "https://gcr.io",
wantAuthType: "basic",
wantAuthType: "anonymous",
},
}

Expand Down Expand Up @@ -213,10 +200,8 @@ func TestUpstreamsAPI_Get_ExistingUpstream(t *testing.T) {
func TestUpstreamsAPI_Get_UnknownUpstream(t *testing.T) {
config := newTestConfig(t, map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
})

Expand Down Expand Up @@ -270,10 +255,8 @@ func TestUpstreamsAPI_Get_EmptyConfiguration(t *testing.T) {
func TestUpstreamsAPI_MethodNotAllowed(t *testing.T) {
config := newTestConfig(t, map[string]configuration.UpstreamConfiguration{
"dockerio": {
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{
Anonymous: &configuration.UpstreamAnonymousAuthentication{},
},
Registry: "https://registry-1.docker.io",
Authentication: configuration.UpstreamAuthentication{},
},
})

Expand Down
107 changes: 107 additions & 0 deletions pkg/configuration/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package configuration

import (
"errors"
"fmt"

"go.uber.org/zap"
)

// AuthType is implemented by all authentication mode configurations.
type AuthType interface {
GetName() string
}

// ErrMultipleAuthTypes is returned when multiple authentication types are configured simultaneously.
var ErrMultipleAuthTypes = errors.New("multiple authentication types configured")

// ErrInvalidAuthConfiguration is returned when an authentication configuration is invalid.
var ErrInvalidAuthConfiguration = errors.New("invalid authentication configuration")

// UpstreamAuthentication defines the authentication configuration for an upstream registry.
// Each upstream is configured for exactly one auth mode. Only one field should be set at a time;
// mutual exclusivity is enforced at validation.
type UpstreamAuthentication struct {
// Anonymous configures unauthenticated access to the upstream registry.
Anonymous *AnonymousAuthentication `koanf:"anonymous"`

// Passthrough configures barnacle to forward client-provided credentials to the upstream registry.
Passthrough *PassthroughAuthentication `koanf:"passthrough"`
}

// Validate checks that the authentication configuration is valid.
// At most one auth mode may be set. If none are set, a warning is logged
// and behavior defaults to anonymous.
func (u *UpstreamAuthentication) Validate(logger *zap.Logger) error {
count := 0

if u.Anonymous != nil {
count++
}

if u.Passthrough != nil {
count++
}

if count > 1 {
return fmt.Errorf("%w: only one authentication mode may be configured per upstream", ErrMultipleAuthTypes)
}

if count == 0 {
logger.Warn(
"no authentication mode configured, defaulting to anonymous — set anonymous auth explicitly to suppress this warning",
)
return nil
}

if u.Anonymous != nil {
return u.Anonymous.Validate()
}

if u.Passthrough != nil {
return u.Passthrough.Validate()
}

return nil
}

// GetAuthType returns the configured authentication mode.
// If no mode is explicitly configured, it defaults to anonymous.
func (u *UpstreamAuthentication) GetAuthType() AuthType {
if u.Passthrough != nil {
return u.Passthrough
}

if u.Anonymous != nil {
return u.Anonymous
}

return &AnonymousAuthentication{}
}

// AnonymousAuthentication configures unauthenticated access to the upstream registry.
type AnonymousAuthentication struct{}

// GetName returns the name of the anonymous authentication type.
func (a *AnonymousAuthentication) GetName() string {
return "anonymous"
}

// Validate checks that the anonymous authentication configuration is valid.
func (a *AnonymousAuthentication) Validate() error {
return nil
}

// PassthroughAuthentication configures barnacle to forward client-provided credentials
// directly to the upstream registry without inspection or caching.
type PassthroughAuthentication struct{}

// GetName returns the name of the passthrough authentication type.
func (p *PassthroughAuthentication) GetName() string {
return "passthrough"
}

// Validate checks that the passthrough authentication configuration is valid.
func (p *PassthroughAuthentication) Validate() error {
return nil
}
Loading