commit
						a6bf3dd064
					
				|  | @ -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.
 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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, | ||||
| 		}) | ||||
|  |  | |||
|  | @ -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])/ | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue