diff --git a/configuration/configuration.go b/configuration/configuration.go index 55b9fcba..ec50a6b1 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -203,6 +203,19 @@ type Configuration struct { } `yaml:"urls,omitempty"` } `yaml:"manifests,omitempty"` } `yaml:"validation,omitempty"` + + // Policy configures registry policy options. + Policy struct { + // Repository configures policies for repositories + Repository struct { + // Classes is a list of repository classes which the + // registry allows content for. This class is matched + // against the configuration media type inside uploaded + // manifests. When non-empty, the registry will enforce + // the class in authorized resources. + Classes []string `yaml:"classes"` + } `yaml:"repository,omitempty"` + } `yaml:"policy,omitempty"` } // LogHook is composed of hook Level and Type. diff --git a/contrib/token-server/main.go b/contrib/token-server/main.go index 1cebc0e1..e9d6d64f 100644 --- a/contrib/token-server/main.go +++ b/contrib/token-server/main.go @@ -18,6 +18,10 @@ import ( "github.com/gorilla/mux" ) +var ( + enforceRepoClass bool +) + func main() { var ( issuer = &TokenIssuer{} @@ -44,6 +48,8 @@ func main() { flag.StringVar(&cert, "tlscert", "", "Certificate file for TLS") flag.StringVar(&certKey, "tlskey", "", "Certificate key for TLS") + flag.BoolVar(&enforceRepoClass, "enforce-class", false, "Enforce policy for single repository class") + flag.Parse() if debug { @@ -157,6 +163,8 @@ type tokenResponse struct { ExpiresIn int `json:"expires_in,omitempty"` } +var repositoryClassCache = map[string]string{} + func filterAccessList(ctx context.Context, scope string, requestedAccessList []auth.Access) []auth.Access { if !strings.HasSuffix(scope, "/") { scope = scope + "/" @@ -168,6 +176,16 @@ func filterAccessList(ctx context.Context, scope string, requestedAccessList []a context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name) continue } + if enforceRepoClass { + if class, ok := repositoryClassCache[access.Name]; ok { + if class != access.Class { + context.GetLogger(ctx).Debugf("Different repository class: %q, previously %q", access.Class, class) + continue + } + } else if strings.EqualFold(access.Action, "push") { + repositoryClassCache[access.Name] = access.Class + } + } } else if access.Type == "registry" { if access.Name != "catalog" { context.GetLogger(ctx).Debugf("Unknown registry resource: %s", access.Name) diff --git a/contrib/token-server/token.go b/contrib/token-server/token.go index e69fb9c1..b0c2abff 100644 --- a/contrib/token-server/token.go +++ b/contrib/token-server/token.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "strings" "time" @@ -32,12 +33,18 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc resourceType, resourceName, actions := parts[0], parts[1], parts[2] + resourceType, resourceClass := splitResourceClass(resourceType) + if resourceType == "" { + continue + } + // Actions should be a comma-separated list of actions. for _, action := range strings.Split(actions, ",") { requestedAccess := auth.Access{ Resource: auth.Resource{ - Type: resourceType, - Name: resourceName, + Type: resourceType, + Class: resourceClass, + Name: resourceName, }, Action: action, } @@ -55,6 +62,19 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc return requestedAccessList } +var typeRegexp = regexp.MustCompile(`^([a-z0-9]+)(\([a-z0-9]+\))?$`) + +func splitResourceClass(t string) (string, string) { + matches := typeRegexp.FindStringSubmatch(t) + if len(matches) < 2 { + return "", "" + } + if len(matches) == 2 || len(matches[2]) < 2 { + return matches[1], "" + } + return matches[1], matches[2][1 : len(matches[2])-1] +} + // ResolveScopeList converts a scope list from a token request's // `scope` parameter into a list of standard access objects. func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access { @@ -62,12 +82,19 @@ func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access { return ResolveScopeSpecifiers(ctx, scopes) } +func scopeString(a auth.Access) string { + if a.Class != "" { + return fmt.Sprintf("%s(%s):%s:%s", a.Type, a.Class, a.Name, a.Action) + } + return fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action) +} + // ToScopeList converts a list of access to a // scope list string func ToScopeList(access []auth.Access) string { var s []string for _, a := range access { - s = append(s, fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action)) + s = append(s, scopeString(a)) } return strings.Join(s, ",") } @@ -102,6 +129,7 @@ func (issuer *TokenIssuer) CreateJWT(subject string, audience string, grantedAcc accessEntries = append(accessEntries, &token.ResourceActions{ Type: resource.Type, + Class: resource.Class, Name: resource.Name, Actions: actions, }) diff --git a/docs/spec/auth/scope.md b/docs/spec/auth/scope.md index eecb8f6f..6ef61edf 100644 --- a/docs/spec/auth/scope.md +++ b/docs/spec/auth/scope.md @@ -39,13 +39,23 @@ intended to represent. This type may be specific to a resource provider but must be understood by the authorization server in order to validate the subject is authorized for a specific resource. +#### Resource Class + +The resource type might have a resource class which further classifies the +the resource name within the resource type. A class is not required and +is specific to the resource type. + #### Example Resource Types - `repository` - represents a single repository within a registry. A repository may represent many manifest or content blobs, but the resource type is considered the collections of those items. Actions which may be performed on a `repository` are `pull` for accessing the collection and `push` for adding to -it. +it. By default the `repository` type has the class of `image`. + - `repository(plugin)` - represents a single repository of plugins within a +registry. A plugin repository has the same content and actions as a repository. + - `registry` - represents the entire registry. Used for administrative actions +or lookup operations that span an entire registry. ### Resource Name @@ -78,7 +88,8 @@ scopes. ``` scope := resourcescope [ ' ' resourcescope ]* resourcescope := resourcetype ":" resourcename ":" action [ ',' action ]* -resourcetype := /[a-z]*/ +resourcetype := resourcetypevalue [ '(' resourcetypevalue ')' ] +resourcetypevalue := /[a-z0-9]+/ resourcename := [ hostname '/' ] component [ '/' component ]* hostname := hostcomponent ['.' hostcomponent]* [':' port-number] hostcomponent := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ diff --git a/registry/auth/auth.go b/registry/auth/auth.go index 0cb37235..1c9af882 100644 --- a/registry/auth/auth.go +++ b/registry/auth/auth.go @@ -66,8 +66,9 @@ type UserInfo struct { // Resource describes a resource by type and name. type Resource struct { - Type string - Name string + Type string + Class string + Name string } // Access describes a specific action that is @@ -135,6 +136,39 @@ func (uic userInfoContext) Value(key interface{}) interface{} { return uic.Context.Value(key) } +// WithResources returns a context with the authorized resources. +func WithResources(ctx context.Context, resources []Resource) context.Context { + return resourceContext{ + Context: ctx, + resources: resources, + } +} + +type resourceContext struct { + context.Context + resources []Resource +} + +type resourceKey struct{} + +func (rc resourceContext) Value(key interface{}) interface{} { + if key == (resourceKey{}) { + return rc.resources + } + + return rc.Context.Value(key) +} + +// AuthorizedResources returns the list of resources which have +// been authorized for this request. +func AuthorizedResources(ctx context.Context) []Resource { + if resources, ok := ctx.Value(resourceKey{}).([]Resource); ok { + return resources + } + + return nil +} + // InitFunc is the type of an AccessController factory function and is used // to register the constructor for different AccesController backends. type InitFunc func(options map[string]interface{}) (AccessController, error) diff --git a/registry/auth/token/accesscontroller.go b/registry/auth/token/accesscontroller.go index 52b7f369..4e8b7f1c 100644 --- a/registry/auth/token/accesscontroller.go +++ b/registry/auth/token/accesscontroller.go @@ -261,6 +261,8 @@ func (ac *accessController) Authorized(ctx context.Context, accessItems ...auth. } } + ctx = auth.WithResources(ctx, token.resources()) + return auth.WithUser(ctx, auth.UserInfo{Name: token.Claims.Subject}), nil } diff --git a/registry/auth/token/token.go b/registry/auth/token/token.go index 634eb75b..850f5813 100644 --- a/registry/auth/token/token.go +++ b/registry/auth/token/token.go @@ -34,6 +34,7 @@ var ( // ResourceActions stores allowed actions on a named and typed resource. type ResourceActions struct { Type string `json:"type"` + Class string `json:"class,omitempty"` Name string `json:"name"` Actions []string `json:"actions"` } @@ -349,6 +350,29 @@ func (t *Token) accessSet() accessSet { return accessSet } +func (t *Token) resources() []auth.Resource { + if t.Claims == nil { + return nil + } + + resourceSet := map[auth.Resource]struct{}{} + for _, resourceActions := range t.Claims.Access { + resource := auth.Resource{ + Type: resourceActions.Type, + Class: resourceActions.Class, + Name: resourceActions.Name, + } + resourceSet[resource] = struct{}{} + } + + resources := make([]auth.Resource, 0, len(resourceSet)) + for resource := range resourceSet { + resources = append(resources, resource) + } + + return resources +} + func (t *Token) compactRaw() string { return fmt.Sprintf("%s.%s", t.Raw, joseBase64UrlEncode(t.Signature)) } diff --git a/registry/client/auth/session.go b/registry/client/auth/session.go index ffc3384b..d6d884ff 100644 --- a/registry/client/auth/session.go +++ b/registry/client/auth/session.go @@ -147,13 +147,18 @@ type Scope interface { // to a repository. type RepositoryScope struct { Repository string + Class string Actions []string } // String returns the string representation of the repository // using the scope grammar func (rs RepositoryScope) String() string { - return fmt.Sprintf("repository:%s:%s", rs.Repository, strings.Join(rs.Actions, ",")) + repoType := "repository" + if rs.Class != "" { + repoType = fmt.Sprintf("%s(%s)", repoType, rs.Class) + } + return fmt.Sprintf("%s:%s:%s", repoType, rs.Repository, strings.Join(rs.Actions, ",")) } // RegistryScope represents a token scope for access diff --git a/registry/handlers/images.go b/registry/handlers/images.go index 96316348..9518f685 100644 --- a/registry/handlers/images.go +++ b/registry/handlers/images.go @@ -15,6 +15,7 @@ import ( "github.com/docker/distribution/reference" "github.com/docker/distribution/registry/api/errcode" "github.com/docker/distribution/registry/api/v2" + "github.com/docker/distribution/registry/auth" "github.com/gorilla/handlers" ) @@ -269,6 +270,12 @@ func (imh *imageManifestHandler) PutImageManifest(w http.ResponseWriter, r *http if imh.Tag != "" { options = append(options, distribution.WithTag(imh.Tag)) } + + if err := imh.applyResourcePolicy(manifest); err != nil { + imh.Errors = append(imh.Errors, err) + return + } + _, err = manifests.Put(imh, manifest, options...) if err != nil { // TODO(stevvooe): These error handling switches really need to be @@ -339,6 +346,73 @@ func (imh *imageManifestHandler) PutImageManifest(w http.ResponseWriter, r *http w.WriteHeader(http.StatusCreated) } +// applyResourcePolicy checks whether the resource class matches what has +// been authorized and allowed by the policy configuration. +func (imh *imageManifestHandler) applyResourcePolicy(manifest distribution.Manifest) error { + allowedClasses := imh.App.Config.Policy.Repository.Classes + if len(allowedClasses) == 0 { + return nil + } + + var class string + switch m := manifest.(type) { + case *schema1.SignedManifest: + class = "image" + case *schema2.DeserializedManifest: + switch m.Config.MediaType { + case schema2.MediaTypeConfig: + class = "image" + case schema2.MediaTypePluginConfig: + class = "plugin" + default: + message := fmt.Sprintf("unknown manifest class for %s", m.Config.MediaType) + return errcode.ErrorCodeDenied.WithMessage(message) + } + } + + if class == "" { + return nil + } + + // Check to see if class is allowed in registry + var allowedClass bool + for _, c := range allowedClasses { + if class == c { + allowedClass = true + break + } + } + if !allowedClass { + message := fmt.Sprintf("registry does not allow %s manifest", class) + return errcode.ErrorCodeDenied.WithMessage(message) + } + + resources := auth.AuthorizedResources(imh) + n := imh.Repository.Named().Name() + + var foundResource bool + for _, r := range resources { + if r.Name == n { + if r.Class == "" { + r.Class = "image" + } + if r.Class == class { + return nil + } + foundResource = true + } + } + + // resource was found but no matching class was found + if foundResource { + message := fmt.Sprintf("repository not authorized for %s manifest", class) + return errcode.ErrorCodeDenied.WithMessage(message) + } + + return nil + +} + // DeleteImageManifest removes the manifest with the given digest from the registry. func (imh *imageManifestHandler) DeleteImageManifest(w http.ResponseWriter, r *http.Request) { ctxu.GetLogger(imh).Debug("DeleteImageManifest")