From 609d85d875394160c53e6eea640342f6adf26061 Mon Sep 17 00:00:00 2001 From: Petu Eusebiu Date: Thu, 13 May 2021 21:59:12 +0300 Subject: [PATCH] Add identity-based access control, closes #51 Add a cli subcommand to verify config files validity --- README.md | 11 + docs/docs.go | 4 +- examples/README.md | 5 + examples/config-minimal.json | 3 +- examples/config-policy.json | 54 +++++ go.mod | 3 +- go.sum | 3 +- pkg/api/{auth.go => authn.go} | 6 +- pkg/api/authz.go | 236 +++++++++++++++++++ pkg/api/config.go | 69 ++++-- pkg/api/controller_test.go | 410 +++++++++++++++++++++++++++++++++- pkg/api/routes.go | 20 +- pkg/cli/root.go | 95 ++++++-- pkg/cli/root_test.go | 47 ++++ 14 files changed, 915 insertions(+), 51 deletions(-) create mode 100644 examples/README.md create mode 100755 examples/config-policy.json rename pkg/api/{auth.go => authn.go} (96%) create mode 100755 pkg/api/authz.go diff --git a/README.md b/README.md index cce0924b..4f1bab10 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ https://anuvu.github.io/zot/ * TLS mutual authentication * HTTP *Basic* (local _htpasswd_ and LDAP) * HTTP *Bearer* token +* [Authorization](#Authorization) Supports Identity-Based Access Control: [Configuration](./examples/config-policy.json) +* Supports live modifications on the config file while zot is running (Authorization config only) * Doesn't require _root_ privileges * Storage optimizations: * Automatic garbage collection of orphaned blobs @@ -222,6 +224,15 @@ c3/openjdk-dev commit-2674e8a-squashfs b545b8ba 321MB c3/openjdk-dev commit-d5024ec-squashfs cd45f8cf 321MB ``` +## Authorization + +zot follows the next logic for deciding who has rights on what resource: +1) First it checks the requested resource in "repositories" as specified in configuration +2) Searches in all policies for the user who requested the resource +3) If found, applies the policy, otherwise it falls back to the default policy for that repository +4) If not found, it falls back to the adminPolicy + + # Ecosystem diff --git a/docs/docs.go b/docs/docs.go index b3eb545a..1ec12f30 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -729,13 +729,13 @@ type swaggerInfo struct { } // SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = swaggerInfo{ Schemes: []string{}} +var SwaggerInfo = swaggerInfo{Schemes: []string{}} type s struct{} func (s *s) ReadDoc() string { t, err := template.New("swagger_info").Funcs(template.FuncMap{ - "marshal": func(v interface {}) string { + "marshal": func(v interface{}) string { a, _ := json.Marshal(v) return string(a) }, diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..9beb0aad --- /dev/null +++ b/examples/README.md @@ -0,0 +1,5 @@ +You can check a configuration file validity by running the verify command: + +```console +$ zot verify /path/to/config +``` \ No newline at end of file diff --git a/examples/config-minimal.json b/examples/config-minimal.json index 5b6bc60d..795e1698 100644 --- a/examples/config-minimal.json +++ b/examples/config-minimal.json @@ -9,6 +9,7 @@ "ReadOnly": false }, "log": { - "level": "debug" + "level": "debug", + "output": "/tmp/zot.log" } } diff --git a/examples/config-policy.json b/examples/config-policy.json new file mode 100755 index 00000000..fda5b019 --- /dev/null +++ b/examples/config-policy.json @@ -0,0 +1,54 @@ +{ + "version": "0.1.0-dev", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "test/data/htpasswd" + }, + "failDelay": 1 + }, + "accessControl": { + "repos1/repo": { + "policies": [ + { + "users": ["alice", "bob"], + "actions": ["create", "read", "update", "delete"] + }, + { + "users": ["mallory"], + "actions": ["create", "read"] + } + ], + "defaultPolicy": ["read"] + }, + "repos2/repo": { + "policies": [ + { + "users": ["bob"], + "actions": ["read", "create"] + }, + { + "users": ["mallory"], + "actions": ["create", "read"] + } + ], + "defaultPolicy": ["read"] + }, + "adminPolicy": { + "users": ["admin"], + "actions": ["read", "create", "update", "delete"] + } + } + }, + "log": { + "level": "debug", + "output": "/tmp/zot.log" + } +} + diff --git a/go.mod b/go.mod index 479f9ba3..e377e0d6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/briandowns/spinner v1.12.0 github.com/chartmuseum/auth v0.4.5 github.com/dustin/go-humanize v1.0.0 + github.com/fsnotify/fsnotify v1.4.9 github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a github.com/go-ldap/ldap/v3 v3.3.0 github.com/gofrs/uuid v4.0.0+incompatible @@ -40,7 +41,7 @@ require ( github.com/swaggo/swag v1.7.0 github.com/vektah/gqlparser/v2 v2.2.0 go.etcd.io/bbolt v1.3.5 - golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 gopkg.in/resty.v1 v1.12.0 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 45a4ae42..9fda5591 100644 --- a/go.sum +++ b/go.sum @@ -1148,9 +1148,8 @@ golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/pkg/api/auth.go b/pkg/api/authn.go similarity index 96% rename from pkg/api/auth.go rename to pkg/api/authn.go index 355f0379..d8581c4a 100644 --- a/pkg/api/auth.go +++ b/pkg/api/authn.go @@ -23,11 +23,7 @@ const ( ) func AuthHandler(c *Controller) mux.MiddlewareFunc { - if c.Config.HTTP.Auth != nil && - c.Config.HTTP.Auth.Bearer != nil && - c.Config.HTTP.Auth.Bearer.Cert != "" && - c.Config.HTTP.Auth.Bearer.Realm != "" && - c.Config.HTTP.Auth.Bearer.Service != "" { + if isBearerAuthEnabled(c.Config) { return bearerAuthHandler(c) } diff --git a/pkg/api/authz.go b/pkg/api/authz.go new file mode 100755 index 00000000..b0550797 --- /dev/null +++ b/pkg/api/authz.go @@ -0,0 +1,236 @@ +package api + +import ( + "context" + "encoding/base64" + "net/http" + "strings" + "time" + + "github.com/anuvu/zot/pkg/log" + "github.com/gorilla/mux" +) + +type contextKey int + +const ( + // actions + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + + // request-local context key + authzCtxKey contextKey = 0 +) + +type AccessControlConfig struct { + Repositories Repositories + AdminPolicy Policy +} + +type Repositories map[string]PolicyGroup + +type PolicyGroup struct { + Policies []Policy + DefaultPolicy []string +} + +type Policy struct { + Users []string + Actions []string +} + +// AccessController authorizes users to act on resources. +type AccessController struct { + Config *AccessControlConfig + Log log.Logger +} + +// AccessControlContext context passed down to http.Handlers. +type AccessControlContext struct { + userAllowedRepos []string + isAdmin bool +} + +func NewAccessController(config *Config) *AccessController { + return &AccessController{ + Config: config.AccessControl, + Log: log.NewLogger(config.Log.Level, config.Log.Output), + } +} + +// getReadRepos get repositories from config file that the user has READ perms. +func (ac *AccessController) getReadRepos(username string) []string { + var repos []string + + for r, pg := range ac.Config.Repositories { + for _, p := range pg.Policies { + if (contains(p.Users, username) && contains(p.Actions, READ)) || + contains(pg.DefaultPolicy, READ) { + repos = append(repos, r) + } + } + } + + return repos +} + +// can verifies if a user can do action on repository. +func (ac *AccessController) can(username, action, repository string) bool { + can := false + // check repo based policy + pg, ok := ac.Config.Repositories[repository] + if ok { + can = isPermitted(username, action, pg) + } + + //check admins based policy + if !can { + if ac.isAdmin(username) && contains(ac.Config.AdminPolicy.Actions, action) { + can = true + } + } + + return can +} + +// isAdmin . +func (ac *AccessController) isAdmin(username string) bool { + return contains(ac.Config.AdminPolicy.Users, username) +} + +// getContext builds ac context(allowed to read repos and if user is admin) and returns it. +func (ac *AccessController) getContext(username string, r *http.Request) context.Context { + userAllowedRepos := ac.getReadRepos(username) + acCtx := AccessControlContext{userAllowedRepos: userAllowedRepos} + + if ac.isAdmin(username) { + acCtx.isAdmin = true + } else { + acCtx.isAdmin = false + } + + ctx := context.WithValue(r.Context(), authzCtxKey, acCtx) + + return ctx +} + +// isPermitted returns true if username can do action on a repository policy. +func isPermitted(username, action string, pg PolicyGroup) bool { + var result bool + // check repo/system based policies + for _, p := range pg.Policies { + if contains(p.Users, username) && contains(p.Actions, action) { + result = true + break + } + } + + // check defaultPolicy + if !result { + if contains(pg.DefaultPolicy, action) { + result = true + } + } + + return result +} + +func contains(slice []string, item string) bool { + for _, v := range slice { + if item == v { + return true + } + } + + return false +} + +func containsRepo(slice []string, item string) bool { + for _, v := range slice { + if strings.HasPrefix(item, v) { + return true + } + } + + return false +} + +func AuthzHandler(c *Controller) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + resource := vars["name"] + reference, ok := vars["reference"] + + ac := NewAccessController(c.Config) + username := getUsername(r) + ctx := ac.getContext(username, r) + + if r.RequestURI == "/v2/_catalog" || r.RequestURI == "/v2/" { + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + var action string + if r.Method == http.MethodGet || r.Method == http.MethodHead { + action = READ + } + + if r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodPost { + // assume user wants to create + action = CREATE + // if we get a reference (tag) + if ok { + is := c.StoreController.GetImageStore(resource) + tags, err := is.GetImageTags(resource) + // if repo exists and request's tag doesn't exist yet then action is UPDATE + if err == nil && contains(tags, reference) && reference != "latest" { + action = UPDATE + } + } + } + + if r.Method == http.MethodDelete { + action = DELETE + } + + can := ac.can(username, action, resource) + if !can { + authzFail(w, c.Config.HTTP.Realm, c.Config.HTTP.Auth.FailDelay) + } else { + next.ServeHTTP(w, r.WithContext(ctx)) + } + }) + } +} + +func getUsername(r *http.Request) string { + // this should work because it worked in auth middleware + basicAuth := r.Header.Get("Authorization") + s := strings.SplitN(basicAuth, " ", 2) + b, _ := base64.StdEncoding.DecodeString(s[1]) + pair := strings.SplitN(string(b), ":", 2) + + return pair[0] +} + +func isBearerAuthEnabled(config *Config) bool { + if config.HTTP.Auth != nil && + config.HTTP.Auth.Bearer != nil && + config.HTTP.Auth.Bearer.Cert != "" && + config.HTTP.Auth.Bearer.Realm != "" && + config.HTTP.Auth.Bearer.Service != "" { + return true + } + + return false +} + +func authzFail(w http.ResponseWriter, realm string, delay int) { + time.Sleep(time.Duration(delay) * time.Second) + w.Header().Set("WWW-Authenticate", realm) + w.Header().Set("Content-Type", "application/json") + WriteJSON(w, http.StatusForbidden, NewErrorList(NewError(DENIED))) +} diff --git a/pkg/api/config.go b/pkg/api/config.go index 07b69a58..f5c6d492 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -1,11 +1,14 @@ package api import ( + "fmt" + "github.com/anuvu/zot/errors" ext "github.com/anuvu/zot/pkg/extensions" "github.com/anuvu/zot/pkg/log" "github.com/getlantern/deepcopy" distspec "github.com/opencontainers/distribution-spec/specs-go" + "github.com/spf13/viper" ) var ( @@ -43,13 +46,14 @@ type BearerConfig struct { } type HTTPConfig struct { - Address string - Port string - TLS *TLSConfig - Auth *AuthConfig - Realm string - AllowReadAccess bool `mapstructure:",omitempty"` - ReadOnly bool `mapstructure:",omitempty"` + Address string + Port string + TLS *TLSConfig + Auth *AuthConfig + RawAccessControl map[string]interface{} `mapstructure:"accessControl,omitempty"` + Realm string + AllowReadAccess bool `mapstructure:",omitempty"` + ReadOnly bool `mapstructure:",omitempty"` } type LDAPConfig struct { @@ -80,13 +84,14 @@ type GlobalStorageConfig struct { } type Config struct { - Version string - Commit string - BinaryType string - Storage GlobalStorageConfig - HTTP HTTPConfig - Log *LogConfig - Extensions *ext.ExtensionConfig + Version string + Commit string + BinaryType string + AccessControl *AccessControlConfig + Storage GlobalStorageConfig + HTTP HTTPConfig + Log *LogConfig + Extensions *ext.ExtensionConfig } func NewConfig() *Config { @@ -134,3 +139,39 @@ func (c *Config) Validate(log log.Logger) error { return nil } + +// LoadAccessControlConfig populates config.AccessControl struct with values from config. +func (c *Config) LoadAccessControlConfig() error { + if c.HTTP.RawAccessControl == nil { + return nil + } + + c.AccessControl = &AccessControlConfig{} + c.AccessControl.Repositories = make(map[string]PolicyGroup) + + for k := range c.HTTP.RawAccessControl { + var policies []Policy + + var policyGroup PolicyGroup + + if k == "adminpolicy" { + adminPolicy := viper.GetStringMapStringSlice("http.accessControl.adminPolicy") + c.AccessControl.AdminPolicy.Actions = adminPolicy["actions"] + c.AccessControl.AdminPolicy.Users = adminPolicy["users"] + + continue + } + + err := viper.UnmarshalKey(fmt.Sprintf("http.accessControl.%s.policies", k), &policies) + if err != nil { + return err + } + + defaultPolicy := viper.GetStringSlice(fmt.Sprintf("http.accessControl.%s.defaultPolicy", k)) + policyGroup.Policies = policies + policyGroup.DefaultPolicy = defaultPolicy + c.AccessControl.Repositories[k] = policyGroup + } + + return nil +} diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index cd9c132f..e40f164a 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -39,16 +39,17 @@ import ( ) const ( - BaseURL = "http://127.0.0.1:%s" - BaseSecureURL = "https://127.0.0.1:%s" - username = "test" - passphrase = "test" - ServerCert = "../../test/data/server.cert" - ServerKey = "../../test/data/server.key" - CACert = "../../test/data/ca.crt" - AuthorizedNamespace = "everyone/isallowed" - UnauthorizedNamespace = "fortknox/notallowed" - ALICE = "alice" + BaseURL = "http://127.0.0.1:%s" + BaseSecureURL = "https://127.0.0.1:%s" + username = "test" + passphrase = "test" + ServerCert = "../../test/data/server.cert" + ServerKey = "../../test/data/server.key" + CACert = "../../test/data/ca.crt" + AuthorizedNamespace = "everyone/isallowed" + UnauthorizedNamespace = "fortknox/notallowed" + ALICE = "alice" + AuthorizationNamespace = "authz/image" ) type ( @@ -1639,6 +1640,395 @@ func parseBearerAuthHeader(authHeaderRaw string) *authHeader { return &h } +func TestAuthorizationWithBasicAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + port := getFreePort() + baseURL := getBaseURL(port, false) + + config := api.NewConfig() + config.HTTP.Port = port + htpasswdPath := makeHtpasswdFile() + defer os.Remove(htpasswdPath) + + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + config.AccessControl = &api.AccessControlConfig{ + Repositories: api.Repositories{ + AuthorizationNamespace: api.PolicyGroup{ + Policies: []api.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: api.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + c := api.NewController(config) + dir, err := ioutil.TempDir("", "oci-repo-test") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + err = copyFiles("../../test/data", dir) + if err != nil { + panic(err) + } + c.Config.Storage.RootDirectory = dir + go func() { + // this blocks + if err := c.Run(); err != nil { + return + } + }() + + // wait till ready + for { + _, err := resty.R().Get(baseURL) + if err == nil { + break + } + time.Sleep(100 * time.Millisecond) + } + + defer func() { + ctx := context.Background() + _ = c.Server.Shutdown(ctx) + }() + + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + // everybody should have access to /v2/ + resp, err := resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // everybody should have access to /v2/_catalog + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + var e api.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // first let's use only repositories based policies + // should get 403 without create + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add test user to repo's policy with create perm + config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users = + append(config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test") + config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = + append(config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create") + + // now it should get 202 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 202) + loc := resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + // head blob should get 403 with read perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // get blob should get 403 without read perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // get tags without read access should get 403 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // get tags with read access should get 200 + config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = + append(config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read") + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // head blob should get 200 now + resp, err = resty.R().SetBasicAuth(username, passphrase). + Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // get blob should get 200 now + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // delete blob should get 403 without delete perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add delete perm on repo + config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = + append(config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "delete") + + // delete blob should get 202 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 202) + + // get manifest should get 403, we don't have perm at all on this repo + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/zot-test/manifests/0.0.1") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add read perm on repo + config.AccessControl.Repositories["zot-test"] = api.PolicyGroup{Policies: []api.Policy{ + { + []string{"test"}, + []string{"read"}, + }, + }, DefaultPolicy: []string{}} + + // get manifest should get 200 now + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/zot-test/manifests/0.0.1") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + manifestBlob := resp.Body() + + // put manifest should get 403 without create perm + resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add create perm on repo + config.AccessControl.Repositories["zot-test"].Policies[0].Actions = + append(config.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create") + + // should get 201 with create perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + // update manifest should get 403 without update perm + resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add update perm on repo + config.AccessControl.Repositories["zot-test"].Policies[0].Actions = + append(config.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") + + // update manifest should get 201 with update perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + // now use default repo policy + config.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} + repoPolicy := config.AccessControl.Repositories["zot-test"] + repoPolicy.DefaultPolicy = []string{"update"} + config.AccessControl.Repositories["zot-test"] = repoPolicy + + // update manifest should get 201 with update perm on repo's default policy + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + // with default read on repo should still get 200 + config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{} + repoPolicy = config.AccessControl.Repositories[AuthorizationNamespace] + repoPolicy.DefaultPolicy = []string{"read"} + config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // upload blob without user create but with default create should get 200 + repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "create") + config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 202) + + //remove per repo policy + repoPolicy = config.AccessControl.Repositories[AuthorizationNamespace] + repoPolicy.Policies = []api.Policy{} + repoPolicy.DefaultPolicy = []string{} + config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // let's use admin policy + // remove all repo based policy + delete(config.AccessControl.Repositories, AuthorizationNamespace) + delete(config.AccessControl.Repositories, "zot-test") + + // whithout any perm should get 403 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add read perm + config.AccessControl.AdminPolicy.Users = append(config.AccessControl.AdminPolicy.Users, "test") + config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "read") + // with read perm should get 200 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + // without create perm should 403 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add create perm + config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "create") + // with create perm should get 202 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 202) + loc = resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + // without delete perm should 403 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add delete perm + config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "delete") + // with delete perm should get 202 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 202) + + // without update perm should 403 + resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + + // add update perm + config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "update") + // update manifest should get 201 with update perm + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 201) + + config.AccessControl = &api.AccessControlConfig{} + + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 403) + }) +} + func TestInvalidCases(t *testing.T) { Convey("Invalid repo dir", t, func() { port := getFreePort() diff --git a/pkg/api/routes.go b/pkg/api/routes.go index a16a5aae..4902b6fe 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -54,6 +54,11 @@ func NewRouteHandler(c *Controller) *RouteHandler { func (rh *RouteHandler) SetupRoutes() { rh.c.Router.Use(AuthHandler(rh.c)) + + if !isBearerAuthEnabled(rh.c.Config) && rh.c.Config.AccessControl != nil { + rh.c.Router.Use(AuthzHandler(rh.c)) + } + g := rh.c.Router.PathPrefix(RoutePrefix).Subrouter() { g.HandleFunc(fmt.Sprintf("/{name:%s}/tags/list", NameRegexp.String()), @@ -1137,7 +1142,20 @@ func (rh *RouteHandler) ListRepositories(w http.ResponseWriter, r *http.Request) combineRepoList = append(combineRepoList, repos...) } - is := RepositoryList{Repositories: combineRepoList} + var repos []string + // get passed context from authzHandler and filter out repos based on permissions + if authCtx := r.Context().Value(authzCtxKey); authCtx != nil { + acCtx := authCtx.(AccessControlContext) + for _, r := range combineRepoList { + if containsRepo(acCtx.userAllowedRepos, r) || acCtx.isAdmin { + repos = append(repos, r) + } + } + } else { + repos = combineRepoList + } + + is := RepositoryList{Repositories: repos} WriteJSON(w, http.StatusOK, is) } diff --git a/pkg/cli/root.go b/pkg/cli/root.go index aff3b062..f25bacad 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -4,6 +4,7 @@ import ( "github.com/anuvu/zot/errors" "github.com/anuvu/zot/pkg/api" "github.com/anuvu/zot/pkg/storage" + "github.com/fsnotify/fsnotify" "github.com/mitchellh/mapstructure" distspec "github.com/opencontainers/distribution-spec/specs-go" "github.com/rs/zerolog/log" @@ -31,29 +32,66 @@ func NewRootCmd() *cobra.Command { Long: "`serve` stores and distributes OCI images", Run: func(cmd *cobra.Command, args []string) { if len(args) > 0 { - viper.SetConfigFile(args[0]) - if err := viper.ReadInConfig(); err != nil { - panic(err) - } - - md := &mapstructure.Metadata{} - if err := viper.Unmarshal(&config, metadataConfig(md)); err != nil { - panic(err) - } - - // if haven't found a single key or there were unused keys, report it as - // a error - if len(md.Keys) == 0 || len(md.Unused) > 0 { - panic(errors.ErrBadConfig) - } + LoadConfiguration(config, args[0]) } c := api.NewController(config) + + // creates a new file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + panic(err) + } + defer watcher.Close() + + done := make(chan bool) + // run watcher + go func() { + go func() { + for { + select { + // watch for events + case event := <-watcher.Events: + if event.Op == fsnotify.Write { + log.Info().Msg("Config file changed, trying to reload accessControl config") + newConfig := api.NewConfig() + LoadConfiguration(newConfig, args[0]) + c.Config.AccessControl = newConfig.AccessControl + } + // watch for errors + case err := <-watcher.Errors: + log.Error().Err(err).Msgf("FsNotify error while watching config %s", args[0]) + panic(err) + } + } + }() + + if err := watcher.Add(args[0]); err != nil { + log.Error().Err(err).Msgf("Error adding config file %s to FsNotify watcher", args[0]) + panic(err) + } + <-done + }() + if err := c.Run(); err != nil { panic(err) } }, } + verifyCmd := &cobra.Command{ + Use: "verify ", + Aliases: []string{"verify"}, + Short: "`verify` validates a zot config file", + Long: "`verify` validates a zot config file", + Run: func(cmd *cobra.Command, args []string) { + if len(args) > 0 { + config := api.NewConfig() + LoadConfiguration(config, args[0]) + log.Info().Msgf("Config file %s is valid", args[0]) + } + }, + } + // "garbage-collect" gcDelUntagged := false gcDryRun := false @@ -98,6 +136,7 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(gcCmd) + rootCmd.AddCommand(verifyCmd) enableCli(rootCmd) @@ -105,3 +144,29 @@ func NewRootCmd() *cobra.Command { return rootCmd } + +func LoadConfiguration(config *api.Config, configPath string) { + viper.SetConfigFile(configPath) + + if err := viper.ReadInConfig(); err != nil { + log.Error().Err(err).Msg("Error while reading configuration") + panic(err) + } + + md := &mapstructure.Metadata{} + if err := viper.Unmarshal(&config, metadataConfig(md)); err != nil { + log.Error().Err(err).Msg("Error while unmarshalling new config") + panic(err) + } + + if len(md.Keys) == 0 || len(md.Unused) > 0 { + log.Error().Err(errors.ErrBadConfig).Msg("Bad configuration, retry writing it") + panic(errors.ErrBadConfig) + } + + err := config.LoadAccessControlConfig() + if err != nil { + log.Error().Err(errors.ErrBadConfig).Msg("Unable to unmarshal http.accessControl.key.policies") + panic(err) + } +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 37a98b96..1b5f0638 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -6,8 +6,10 @@ import ( "path" "testing" + "github.com/anuvu/zot/pkg/api" "github.com/anuvu/zot/pkg/cli" . "github.com/smartystreets/goconvey/convey" + "github.com/spf13/viper" ) func TestUsage(t *testing.T) { @@ -65,6 +67,51 @@ func TestServe(t *testing.T) { }) } +func TestVerify(t *testing.T) { + oldArgs := os.Args + + defer func() { os.Args = oldArgs }() + + Convey("Test verify bad config", t, func(c C) { + tmpfile, err := ioutil.TempFile("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"log":{}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic) + }) + + Convey("Test verify good config", t, func(c C) { + tmpfile, err := ioutil.TempFile("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"version": "0.1.0-dev", "storage": {"rootDirectory": "/tmp/zot"}, + "http": {"address": "127.0.0.1", "port": "8080", "ReadOnly": false}, + "log": {"level": "debug"}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + err = cli.NewRootCmd().Execute() + So(err, ShouldBeNil) + }) +} + +func TestLoadConfig(t *testing.T) { + Convey("Test viper load config", t, func(c C) { + config := api.NewConfig() + So(func() { cli.LoadConfiguration(config, "../../examples/config-policy.json") }, ShouldNotPanic) + adminPolicy := viper.GetStringMapStringSlice("http.accessControl.adminPolicy") + So(config.AccessControl.AdminPolicy.Actions, ShouldResemble, adminPolicy["actions"]) + So(config.AccessControl.AdminPolicy.Users, ShouldResemble, adminPolicy["users"]) + }) +} + func TestGC(t *testing.T) { oldArgs := os.Args