Merge pull request #2067 from dmcgowan/add-repo-class
Add support for repository class
This commit is contained in:
commit
a6bf3dd064
9 changed files with 217 additions and 8 deletions
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -32,12 +33,18 @@ 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,
|
||||||
Name: resourceName,
|
Class: resourceClass,
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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])/
|
||||||
|
|
|
@ -66,8 +66,9 @@ 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
|
||||||
Name string
|
Class string
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access describes a specific action that is
|
// Access describes a specific action that is
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in a new issue