Integrate auth.AccessController into registry app

This changeset integrates the AccessController into the main registry app. This
includes support for configuration and a test implementation, called "silly"
auth. Auth is only enabled if the configuration is present but takes measure to
ensure that configuration errors don't allow the appserver to start with open
access.
This commit is contained in:
Stephen J Day 2014-12-18 12:30:19 -08:00
parent 3b8847f489
commit d0a9e9b475
9 changed files with 413 additions and 17 deletions

View file

@ -39,6 +39,14 @@ var ErrorDescriptors = []ErrorDescriptor{
Description: `Generic error returned when the error does not have an Description: `Generic error returned when the error does not have an
API classification.`, API classification.`,
}, },
{
Code: ErrorCodeUnauthorized,
Value: "UNAUTHORIZED",
Message: "access to the requested resource is not authorized",
Description: `The access controller denied access for the operation on
a resource. Often this will be accompanied by a 401 Unauthorized
response status.`,
},
{ {
Code: ErrorCodeDigestInvalid, Code: ErrorCodeDigestInvalid,
Value: "DIGEST_INVALID", Value: "DIGEST_INVALID",

View file

@ -13,6 +13,9 @@ const (
// ErrorCodeUnknown is a catch-all for errors not defined below. // ErrorCodeUnknown is a catch-all for errors not defined below.
ErrorCodeUnknown ErrorCode = iota ErrorCodeUnknown ErrorCode = iota
// ErrorCodeUnauthorized is returned if a request is not authorized.
ErrorCodeUnauthorized
// ErrorCodeDigestInvalid is returned when uploading a blob if the // ErrorCodeDigestInvalid is returned when uploading a blob if the
// provided digest does not match the blob contents. // provided digest does not match the blob contents.
ErrorCodeDigestInvalid ErrorCodeDigestInvalid

110
app.go
View file

@ -5,11 +5,11 @@ import (
"net/http" "net/http"
"github.com/docker/docker-registry/api/v2" "github.com/docker/docker-registry/api/v2"
"github.com/docker/docker-registry/storagedriver" "github.com/docker/docker-registry/auth"
"github.com/docker/docker-registry/storagedriver/factory"
"github.com/docker/docker-registry/configuration" "github.com/docker/docker-registry/configuration"
"github.com/docker/docker-registry/storage" "github.com/docker/docker-registry/storage"
"github.com/docker/docker-registry/storagedriver"
"github.com/docker/docker-registry/storagedriver/factory"
log "github.com/Sirupsen/logrus" log "github.com/Sirupsen/logrus"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -28,6 +28,8 @@ type App struct {
// services contains the main services instance for the application. // services contains the main services instance for the application.
services *storage.Services services *storage.Services
accessController auth.AccessController
} }
// NewApp takes a configuration and returns a configured app, ready to serve // NewApp takes a configuration and returns a configured app, ready to serve
@ -61,6 +63,16 @@ func NewApp(configuration configuration.Configuration) *App {
app.driver = driver app.driver = driver
app.services = storage.NewServices(app.driver) app.services = storage.NewServices(app.driver)
authType := configuration.Auth.Type()
if authType != "" {
accessController, err := auth.GetAccessController(configuration.Auth.Type(), configuration.Auth.Parameters())
if err != nil {
panic(fmt.Sprintf("unable to configure authorization (%s): %v", authType, err))
}
app.accessController = accessController
}
return app return app
} }
@ -111,15 +123,11 @@ func (ssrw *singleStatusResponseWriter) WriteHeader(status int) {
// handler, using the dispatch factory function. // handler, using the dispatch factory function.
func (app *App) dispatcher(dispatch dispatchFunc) http.Handler { func (app *App) dispatcher(dispatch dispatchFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) context := app.context(r)
context := &Context{
App: app,
Name: vars["name"],
urlBuilder: v2.NewURLBuilderFromRequest(r),
}
// Store vars for underlying handlers. if err := app.authorized(w, r, context); err != nil {
context.vars = vars return
}
context.log = log.WithField("name", context.Name) context.log = log.WithField("name", context.Name)
handler := dispatch(context, r) handler := dispatch(context, r)
@ -140,6 +148,86 @@ func (app *App) dispatcher(dispatch dispatchFunc) http.Handler {
}) })
} }
// context constructs the context object for the application. This only be
// called once per request.
func (app *App) context(r *http.Request) *Context {
vars := mux.Vars(r)
context := &Context{
App: app,
Name: vars["name"],
urlBuilder: v2.NewURLBuilderFromRequest(r),
}
// Store vars for underlying handlers.
context.vars = vars
return context
}
// authorized checks if the request can proceed with with request access-
// level. If it cannot, the method will return an error.
func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Context) error {
if app.accessController == nil {
return nil // access controller is not enabled.
}
var accessRecords []auth.Access
resource := auth.Resource{
Type: "repository",
Name: context.Name,
}
switch r.Method {
case "GET", "HEAD":
accessRecords = append(accessRecords,
auth.Access{
Resource: resource,
Action: "pull",
})
case "POST", "PUT", "PATCH":
accessRecords = append(accessRecords,
auth.Access{
Resource: resource,
Action: "pull",
},
auth.Access{
Resource: resource,
Action: "push",
})
case "DELETE":
// DELETE access requires full admin rights, which is represented
// as "*". This may not be ideal.
accessRecords = append(accessRecords,
auth.Access{
Resource: resource,
Action: "*",
})
}
if err := app.accessController.Authorized(r, accessRecords...); err != nil {
switch err := err.(type) {
case auth.Challenge:
w.Header().Set("Content-Type", "application/json")
err.ServeHTTP(w, r)
var errs v2.Errors
errs.Push(v2.ErrorCodeUnauthorized, accessRecords)
serveJSON(w, errs)
default:
// This condition is a potential security problem either in
// the configuration or whatever is backing the access
// controller. Just return a bad request with no information
// to avoid exposure. The request should not proceed.
context.log.Errorf("error checking authorization: %v", err)
w.WriteHeader(http.StatusBadRequest)
}
return err
}
return nil
}
// apiBase implements a simple yes-man for doing overall checks against the // apiBase implements a simple yes-man for doing overall checks against the
// api. This can support auth roundtrips to support docker login. // api. This can support auth roundtrips to support docker login.
func apiBase(w http.ResponseWriter, r *http.Request) { func apiBase(w http.ResponseWriter, r *http.Request) {

View file

@ -1,12 +1,14 @@
package registry package registry
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"github.com/docker/docker-registry/api/v2" "github.com/docker/docker-registry/api/v2"
_ "github.com/docker/docker-registry/auth/silly"
"github.com/docker/docker-registry/configuration" "github.com/docker/docker-registry/configuration"
) )
@ -124,3 +126,64 @@ func TestAppDispatcher(t *testing.T) {
} }
} }
} }
// TestNewApp covers the creation of an application via NewApp with a
// configuration.
func TestNewApp(t *testing.T) {
config := configuration.Configuration{
Storage: configuration.Storage{
"inmemory": nil,
},
Auth: configuration.Auth{
// For now, we simply test that new auth results in a viable
// application.
"silly": {
"realm": "realm-test",
"service": "service-test",
},
},
}
// Mostly, with this test, given a sane configuration, we are simply
// ensuring that NewApp doesn't panic. We might want to tweak this
// behavior.
app := NewApp(config)
server := httptest.NewServer(app)
builder, err := v2.NewURLBuilderFromString(server.URL)
if err != nil {
t.Fatalf("error creating urlbuilder: %v", err)
}
baseURL, err := builder.BuildBaseURL()
if err != nil {
t.Fatalf("error creating baseURL: %v", err)
}
// TODO(stevvooe): The rest of this test might belong in the API tests.
// Just hit the app and make sure we get a 401 Unauthorized error.
req, err := http.Get(baseURL)
if err != nil {
t.Fatalf("unexpected error during GET: %v", err)
}
defer req.Body.Close()
if req.StatusCode != http.StatusUnauthorized {
t.Fatalf("unexpected status code during request: %v", err)
}
if req.Header.Get("Content-Type") != "application/json" {
t.Fatalf("unexpected content-type: %v != %v", req.Header.Get("Content-Type"), "application/json")
}
var errs v2.Errors
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&errs); err != nil {
t.Fatalf("error decoding error response: %v", err)
}
if errs.Errors[0].Code != v2.ErrorCodeUnauthorized {
t.Fatalf("unexpected error code: %v != %v", errs.Errors[0].Code, v2.ErrorCodeUnauthorized)
}
}

89
auth/silly/access.go Normal file
View file

@ -0,0 +1,89 @@
// Package silly provides a simple authentication scheme that checks for the
// existence of an Authorization header and issues access if is present and
// non-empty.
//
// This package is present as an example implementation of a minimal
// auth.AccessController and for testing. This is not suitable for any kind of
// production security.
package silly
import (
"fmt"
"net/http"
"strings"
"github.com/docker/docker-registry/auth"
)
// accessController provides a simple implementation of auth.AccessController
// that simply checks for a non-empty Authorization header. It is useful for
// demonstration and testing.
type accessController struct {
realm string
service string
}
var _ auth.AccessController = &accessController{}
func newAccessController(options map[string]interface{}) (auth.AccessController, error) {
realm, present := options["realm"]
if _, ok := realm.(string); !present || !ok {
return nil, fmt.Errorf(`"realm" must be set for silly access controller`)
}
service, present := options["service"]
if _, ok := service.(string); !present || !ok {
return nil, fmt.Errorf(`"service" must be set for silly access controller`)
}
return &accessController{realm: realm.(string), service: service.(string)}, nil
}
// Authorized simply checks for the existence of the authorization header,
// responding with a bearer challenge if it doesn't exist.
func (ac *accessController) Authorized(req *http.Request, accessRecords ...auth.Access) error {
if req.Header.Get("Authorization") == "" {
challenge := challenge{
realm: ac.realm,
service: ac.service,
}
if len(accessRecords) > 0 {
var scopes []string
for _, access := range accessRecords {
scopes = append(scopes, fmt.Sprintf("%s:%s:%s", access.Type, access.Resource, access.Action))
}
challenge.scope = strings.Join(scopes, " ")
}
return &challenge
}
return nil
}
type challenge struct {
realm string
service string
scope string
}
func (ch *challenge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
header := fmt.Sprintf("Bearer realm=%q,service=%q", ch.realm, ch.service)
if ch.scope != "" {
header = fmt.Sprintf("%s,scope=%q", header, ch.scope)
}
w.Header().Set("Authorization", header)
w.WriteHeader(http.StatusUnauthorized)
}
func (ch *challenge) Error() string {
return fmt.Sprintf("silly authentication challenge: %#v", ch)
}
// init registers the silly auth backend.
func init() {
auth.Register("silly", auth.InitFunc(newAccessController))
}

58
auth/silly/access_test.go Normal file
View file

@ -0,0 +1,58 @@
package silly
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/docker/docker-registry/auth"
)
func TestSillyAccessController(t *testing.T) {
ac := &accessController{
realm: "test-realm",
service: "test-service",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := ac.Authorized(r); err != nil {
switch err := err.(type) {
case auth.Challenge:
err.ServeHTTP(w, r)
return
default:
t.Fatalf("unexpected error authorizing request: %v", err)
}
}
w.WriteHeader(http.StatusNoContent)
}))
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("unexpected error during GET: %v", err)
}
defer resp.Body.Close()
// Request should not be authorized
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unexpected response status: %v != %v", resp.StatusCode, http.StatusUnauthorized)
}
req, err := http.NewRequest("GET", server.URL, nil)
if err != nil {
t.Fatalf("unexpected error creating new request: %v", err)
}
req.Header.Set("Authorization", "seriously, anything")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unexpected error during GET: %v", err)
}
defer resp.Body.Close()
// Request should not be authorized
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("unexpected response status: %v != %v", resp.StatusCode, http.StatusNoContent)
}
}

View file

@ -7,14 +7,14 @@ import (
_ "net/http/pprof" _ "net/http/pprof"
"os" "os"
"github.com/gorilla/handlers"
log "github.com/Sirupsen/logrus" log "github.com/Sirupsen/logrus"
"github.com/bugsnag/bugsnag-go" "github.com/bugsnag/bugsnag-go"
"github.com/gorilla/handlers"
"github.com/yvasiyarov/gorelic" "github.com/yvasiyarov/gorelic"
"github.com/docker/docker-registry" "github.com/docker/docker-registry"
_ "github.com/docker/docker-registry/auth/silly"
_ "github.com/docker/docker-registry/auth/token"
"github.com/docker/docker-registry/configuration" "github.com/docker/docker-registry/configuration"
_ "github.com/docker/docker-registry/storagedriver/filesystem" _ "github.com/docker/docker-registry/storagedriver/filesystem"
_ "github.com/docker/docker-registry/storagedriver/inmemory" _ "github.com/docker/docker-registry/storagedriver/inmemory"

View file

@ -20,6 +20,10 @@ type Configuration struct {
// Storage is the configuration for the registry's storage driver // Storage is the configuration for the registry's storage driver
Storage Storage `yaml:"storage"` Storage Storage `yaml:"storage"`
// Auth allows configuration of various authorization methods that may be
// used to gate requests.
Auth Auth `yaml:"auth"`
// Reporting is the configuration for error reporting // Reporting is the configuration for error reporting
Reporting Reporting `yaml:"reporting"` Reporting Reporting `yaml:"reporting"`
@ -85,6 +89,9 @@ func (loglevel *Loglevel) UnmarshalYAML(unmarshal func(interface{}) error) error
return nil return nil
} }
// Parameters defines a key-value parameters mapping
type Parameters map[string]interface{}
// Storage defines the configuration for registry object storage // Storage defines the configuration for registry object storage
type Storage map[string]Parameters type Storage map[string]Parameters
@ -137,13 +144,71 @@ func (storage *Storage) UnmarshalYAML(unmarshal func(interface{}) error) error {
// MarshalYAML implements the yaml.Marshaler interface // MarshalYAML implements the yaml.Marshaler interface
func (storage Storage) MarshalYAML() (interface{}, error) { func (storage Storage) MarshalYAML() (interface{}, error) {
if storage.Parameters() == nil { if storage.Parameters() == nil {
return storage.Type, nil return storage.Type(), nil
} }
return map[string]Parameters(storage), nil return map[string]Parameters(storage), nil
} }
// Parameters defines a key-value parameters mapping // Auth defines the configuration for registry authorization.
type Parameters map[string]interface{} type Auth map[string]Parameters
// Type returns the storage driver type, such as filesystem or s3
func (auth Auth) Type() string {
// Return only key in this map
for k := range auth {
return k
}
return ""
}
// Parameters returns the Parameters map for an Auth configuration
func (auth Auth) Parameters() Parameters {
return auth[auth.Type()]
}
// setParameter changes the parameter at the provided key to the new value
func (auth Auth) setParameter(key string, value interface{}) {
auth[auth.Type()][key] = value
}
// UnmarshalYAML implements the yaml.Unmarshaler interface
// Unmarshals a single item map into a Storage or a string into a Storage type with no parameters
func (auth *Auth) UnmarshalYAML(unmarshal func(interface{}) error) error {
var m map[string]Parameters
err := unmarshal(&m)
if err == nil {
if len(m) > 1 {
types := make([]string, 0, len(m))
for k := range m {
types = append(types, k)
}
// TODO(stevvooe): May want to change this slightly for
// authorization to allow multiple challenges.
return fmt.Errorf("must provide exactly one type. Provided: %v", types)
}
*auth = m
return nil
}
var authType string
err = unmarshal(&authType)
if err == nil {
*auth = Auth{authType: Parameters{}}
return nil
}
return err
}
// MarshalYAML implements the yaml.Marshaler interface
func (auth Auth) MarshalYAML() (interface{}, error) {
if auth.Parameters() == nil {
return auth.Type(), nil
}
return map[string]Parameters(auth), nil
}
// Reporting defines error reporting methods. // Reporting defines error reporting methods.
type Reporting struct { type Reporting struct {

View file

@ -29,6 +29,12 @@ var configStruct = Configuration{
"port": 42, "port": 42,
}, },
}, },
Auth: Auth{
"silly": Parameters{
"realm": "silly",
"service": "silly",
},
},
Reporting: Reporting{ Reporting: Reporting{
Bugsnag: BugsnagReporting{ Bugsnag: BugsnagReporting{
APIKey: "BugsnagApiKey", APIKey: "BugsnagApiKey",
@ -51,6 +57,10 @@ storage:
secretkey: SUPERSECRET secretkey: SUPERSECRET
host: ~ host: ~
port: 42 port: 42
auth:
silly:
realm: silly
service: silly
reporting: reporting:
bugsnag: bugsnag:
apikey: BugsnagApiKey apikey: BugsnagApiKey
@ -62,6 +72,10 @@ var inmemoryConfigYamlV0_1 = `
version: 0.1 version: 0.1
loglevel: info loglevel: info
storage: inmemory storage: inmemory
auth:
silly:
realm: silly
service: silly
` `
type ConfigSuite struct { type ConfigSuite struct {
@ -113,10 +127,13 @@ func (suite *ConfigSuite) TestParseIncomplete(c *C) {
c.Assert(err, NotNil) c.Assert(err, NotNil)
suite.expectedConfig.Storage = Storage{"filesystem": Parameters{"rootdirectory": "/tmp/testroot"}} suite.expectedConfig.Storage = Storage{"filesystem": Parameters{"rootdirectory": "/tmp/testroot"}}
suite.expectedConfig.Auth = Auth{"silly": Parameters{"realm": "silly"}}
suite.expectedConfig.Reporting = Reporting{} suite.expectedConfig.Reporting = Reporting{}
os.Setenv("REGISTRY_STORAGE", "filesystem") os.Setenv("REGISTRY_STORAGE", "filesystem")
os.Setenv("REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY", "/tmp/testroot") os.Setenv("REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY", "/tmp/testroot")
os.Setenv("REGISTRY_AUTH", "silly")
os.Setenv("REGISTRY_AUTH_SILLY_REALM", "silly")
config, err := Parse(bytes.NewReader([]byte(incompleteConfigYaml))) config, err := Parse(bytes.NewReader([]byte(incompleteConfigYaml)))
c.Assert(err, IsNil) c.Assert(err, IsNil)
@ -259,5 +276,10 @@ func copyConfig(config Configuration) *Configuration {
NewRelic: NewRelicReporting{config.Reporting.NewRelic.LicenseKey, config.Reporting.NewRelic.Name}, NewRelic: NewRelicReporting{config.Reporting.NewRelic.LicenseKey, config.Reporting.NewRelic.Name},
} }
configCopy.Auth = Auth{config.Auth.Type(): Parameters{}}
for k, v := range config.Auth.Parameters() {
configCopy.Auth.setParameter(k, v)
}
return configCopy return configCopy
} }