Merge pull request #2067 from dmcgowan/add-repo-class

Add support for repository class
This commit is contained in:
Derek McGowan 2016-11-21 20:23:04 -08:00 committed by GitHub
commit a6bf3dd064
9 changed files with 217 additions and 8 deletions

View file

@ -203,6 +203,19 @@ type Configuration struct {
} `yaml:"urls,omitempty"` } `yaml:"urls,omitempty"`
} `yaml:"manifests,omitempty"` } `yaml:"manifests,omitempty"`
} `yaml:"validation,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. // LogHook is composed of hook Level and Type.

View file

@ -18,6 +18,10 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
var (
enforceRepoClass bool
)
func main() { func main() {
var ( var (
issuer = &TokenIssuer{} issuer = &TokenIssuer{}
@ -44,6 +48,8 @@ func main() {
flag.StringVar(&cert, "tlscert", "", "Certificate file for TLS") flag.StringVar(&cert, "tlscert", "", "Certificate file for TLS")
flag.StringVar(&certKey, "tlskey", "", "Certificate key for TLS") flag.StringVar(&certKey, "tlskey", "", "Certificate key for TLS")
flag.BoolVar(&enforceRepoClass, "enforce-class", false, "Enforce policy for single repository class")
flag.Parse() flag.Parse()
if debug { if debug {
@ -157,6 +163,8 @@ type tokenResponse struct {
ExpiresIn int `json:"expires_in,omitempty"` ExpiresIn int `json:"expires_in,omitempty"`
} }
var repositoryClassCache = map[string]string{}
func filterAccessList(ctx context.Context, scope string, requestedAccessList []auth.Access) []auth.Access { func filterAccessList(ctx context.Context, scope string, requestedAccessList []auth.Access) []auth.Access {
if !strings.HasSuffix(scope, "/") { if !strings.HasSuffix(scope, "/") {
scope = 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) context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name)
continue 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" { } else if access.Type == "registry" {
if access.Name != "catalog" { if access.Name != "catalog" {
context.GetLogger(ctx).Debugf("Unknown registry resource: %s", access.Name) context.GetLogger(ctx).Debugf("Unknown registry resource: %s", access.Name)

View file

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"regexp"
"strings" "strings"
"time" "time"
@ -32,11 +33,17 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc
resourceType, resourceName, actions := parts[0], parts[1], parts[2] 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. // Actions should be a comma-separated list of actions.
for _, action := range strings.Split(actions, ",") { for _, action := range strings.Split(actions, ",") {
requestedAccess := auth.Access{ requestedAccess := auth.Access{
Resource: auth.Resource{ Resource: auth.Resource{
Type: resourceType, Type: resourceType,
Class: resourceClass,
Name: resourceName, Name: resourceName,
}, },
Action: action, Action: action,
@ -55,6 +62,19 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc
return requestedAccessList 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 // ResolveScopeList converts a scope list from a token request's
// `scope` parameter into a list of standard access objects. // `scope` parameter into a list of standard access objects.
func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access { 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) 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 // ToScopeList converts a list of access to a
// scope list string // scope list string
func ToScopeList(access []auth.Access) string { func ToScopeList(access []auth.Access) string {
var s []string var s []string
for _, a := range access { 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, ",") return strings.Join(s, ",")
} }
@ -102,6 +129,7 @@ func (issuer *TokenIssuer) CreateJWT(subject string, audience string, grantedAcc
accessEntries = append(accessEntries, &token.ResourceActions{ accessEntries = append(accessEntries, &token.ResourceActions{
Type: resource.Type, Type: resource.Type,
Class: resource.Class,
Name: resource.Name, Name: resource.Name,
Actions: actions, Actions: actions,
}) })

View file

@ -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 be understood by the authorization server in order to validate the subject
is authorized for a specific resource. 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 #### Example Resource Types
- `repository` - represents a single repository within a registry. A - `repository` - represents a single repository within a registry. A
repository may represent many manifest or content blobs, but the resource type 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 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 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 ### Resource Name
@ -78,7 +88,8 @@ scopes.
``` ```
scope := resourcescope [ ' ' resourcescope ]* scope := resourcescope [ ' ' resourcescope ]*
resourcescope := resourcetype ":" resourcename ":" action [ ',' action ]* resourcescope := resourcetype ":" resourcename ":" action [ ',' action ]*
resourcetype := /[a-z]*/ resourcetype := resourcetypevalue [ '(' resourcetypevalue ')' ]
resourcetypevalue := /[a-z0-9]+/
resourcename := [ hostname '/' ] component [ '/' component ]* resourcename := [ hostname '/' ] component [ '/' component ]*
hostname := hostcomponent ['.' hostcomponent]* [':' port-number] hostname := hostcomponent ['.' hostcomponent]* [':' port-number]
hostcomponent := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ hostcomponent := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/

View file

@ -67,6 +67,7 @@ type UserInfo struct {
// Resource describes a resource by type and name. // Resource describes a resource by type and name.
type Resource struct { type Resource struct {
Type string Type string
Class string
Name string Name string
} }
@ -135,6 +136,39 @@ func (uic userInfoContext) Value(key interface{}) interface{} {
return uic.Context.Value(key) 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 // InitFunc is the type of an AccessController factory function and is used
// to register the constructor for different AccesController backends. // to register the constructor for different AccesController backends.
type InitFunc func(options map[string]interface{}) (AccessController, error) type InitFunc func(options map[string]interface{}) (AccessController, error)

View file

@ -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 return auth.WithUser(ctx, auth.UserInfo{Name: token.Claims.Subject}), nil
} }

View file

@ -34,6 +34,7 @@ var (
// ResourceActions stores allowed actions on a named and typed resource. // ResourceActions stores allowed actions on a named and typed resource.
type ResourceActions struct { type ResourceActions struct {
Type string `json:"type"` Type string `json:"type"`
Class string `json:"class,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Actions []string `json:"actions"` Actions []string `json:"actions"`
} }
@ -349,6 +350,29 @@ func (t *Token) accessSet() accessSet {
return 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 { func (t *Token) compactRaw() string {
return fmt.Sprintf("%s.%s", t.Raw, joseBase64UrlEncode(t.Signature)) return fmt.Sprintf("%s.%s", t.Raw, joseBase64UrlEncode(t.Signature))
} }

View file

@ -147,13 +147,18 @@ type Scope interface {
// to a repository. // to a repository.
type RepositoryScope struct { type RepositoryScope struct {
Repository string Repository string
Class string
Actions []string Actions []string
} }
// String returns the string representation of the repository // String returns the string representation of the repository
// using the scope grammar // using the scope grammar
func (rs RepositoryScope) String() string { 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 // RegistryScope represents a token scope for access

View file

@ -15,6 +15,7 @@ import (
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/docker/distribution/registry/api/errcode" "github.com/docker/distribution/registry/api/errcode"
"github.com/docker/distribution/registry/api/v2" "github.com/docker/distribution/registry/api/v2"
"github.com/docker/distribution/registry/auth"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
) )
@ -269,6 +270,12 @@ func (imh *imageManifestHandler) PutImageManifest(w http.ResponseWriter, r *http
if imh.Tag != "" { if imh.Tag != "" {
options = append(options, distribution.WithTag(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...) _, err = manifests.Put(imh, manifest, options...)
if err != nil { if err != nil {
// TODO(stevvooe): These error handling switches really need to be // 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) 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. // DeleteImageManifest removes the manifest with the given digest from the registry.
func (imh *imageManifestHandler) DeleteImageManifest(w http.ResponseWriter, r *http.Request) { func (imh *imageManifestHandler) DeleteImageManifest(w http.ResponseWriter, r *http.Request) {
ctxu.GetLogger(imh).Debug("DeleteImageManifest") ctxu.GetLogger(imh).Debug("DeleteImageManifest")