diff --git a/examples/config-default-authz.json b/examples/config-default-authz.json new file mode 100644 index 00000000..5c7d7304 --- /dev/null +++ b/examples/config-default-authz.json @@ -0,0 +1,30 @@ +{ + "distSpecVersion": "1.0.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "accessControl": { + "**": { + "defaultPolicy": ["read", "create"] + }, + "tmp/**": { + "defaultPolicy": ["read", "create", "update"] + }, + "infra/**": { + "defaultPolicy": ["read"] + }, + "repos2/repo": { + "defaultPolicy": ["read"] + } + } + }, + "log": { + "level": "debug" + } + } + + \ No newline at end of file diff --git a/pkg/api/authz.go b/pkg/api/authz.go index aaca073e..dcac48a4 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -180,7 +180,14 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { } acCtrlr := NewAccessController(ctlr.Config) - username := getUsername(request) + + // allow anonymous authz if no authn present and only default policies are present + username := "" + + if isAuthnEnabled(ctlr.Config) { + username = getUsername(request) + } + ctx := acCtrlr.getContext(username, request) // will return only repos on which client is authorized to read @@ -202,7 +209,7 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { if ok { is := ctlr.StoreController.GetImageStore(resource) tags, err := is.GetImageTags(resource) - // if repo exists and request's tag doesn't exist yet then action is UPDATE + // if repo exists and request's tag exists then action is UPDATE if err == nil && common.Contains(tags, reference) && reference != "latest" { action = UPDATE } diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 181db10d..c1649db2 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -142,7 +142,7 @@ func New() *Config { Commit: Commit, BinaryType: BinaryType, Storage: GlobalStorageConfig{GC: true, GCDelay: storage.DefaultGCDelay, Dedupe: true}, - HTTP: HTTPConfig{Address: "127.0.0.1", Port: "8080"}, + HTTP: HTTPConfig{Address: "127.0.0.1", Port: "8080", Auth: &AuthConfig{FailDelay: 0}}, Log: &LogConfig{Level: "debug"}, } } diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index bdbad423..698390b4 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -2096,6 +2096,9 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusOK) manifestBlob := resp.Body() + var manifest ispec.Manifest + err = json.Unmarshal(manifestBlob, &manifest) + So(err, ShouldBeNil) // put manifest should get 403 without create perm resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob). @@ -2116,25 +2119,100 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + // create update config and post it. + cblob, cdigest := test.GetRandomImageConfig() + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/zot-test/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // create updated layer and post it + updateBlob := []byte("Hello, blob update!") + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/zot-test/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + // uploading blob should get 201 + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). + SetBody(updateBlob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updatedManifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(updateBlob), + Size: int64(len(updateBlob)), + }, + }, + } + updatedManifest.SchemaVersion = 2 + updatedManifestBlob, err := json.Marshal(updatedManifest) + So(err, ShouldBeNil) + // update manifest should get 403 without update perm - resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob). + resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(updatedManifestBlob). Put(baseURL + "/v2/zot-test/manifests/0.0.2") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + // get the manifest and check if it's the old one + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, manifestBlob) + // add update perm on repo conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") //nolint:lll // gofumpt conflicts with lll // 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). + SetBody(updatedManifestBlob). Put(baseURL + "/v2/zot-test/manifests/0.0.2") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + // get the manifest and check if it's the new updated one + resp, err = resty.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, updatedManifestBlob) + // now use default repo policy conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} repoPolicy = conf.AccessControl.Repositories["zot-test"] @@ -2280,6 +2358,200 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { }) } +func TestAuthorizationWithOnlyDefaultPolicy(t *testing.T) { + Convey("Make a new controller", t, func() { + const TestRepo = "my-repos/repo" + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{} + conf.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + TestRepo: config.PolicyGroup{ + DefaultPolicy: []string{}, + }, + }, + } + + ctlr := api.NewController(conf) + dir := t.TempDir() + ctlr.Config.Storage.RootDirectory = dir + + go startServer(ctlr) + defer stopServer(ctlr) + test.WaitTillServerReady(baseURL) + + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var e api.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // should get 403 without create + resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + if entry, ok := conf.AccessControl.Repositories[TestRepo]; ok { + entry.DefaultPolicy = []string{"create", "read"} + conf.AccessControl.Repositories[TestRepo] = entry + } + + // now it should get 202 + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + 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, http.StatusCreated) + + cblob, cdigest := test.GetRandomImageConfig() + + resp, err = resty.R().SetBasicAuth(username, passphrase). + Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = resty.R().SetBasicAuth(username, passphrase). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(blob), + Size: int64(len(blob)), + }, + }, + } + manifest.SchemaVersion = 2 + manifestBlob, err := json.Marshal(manifest) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updateBlob := []byte("Hello, blob update!") + + resp, err = resty.R(). + Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + // uploading blob should get 201 + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). + SetBody(updateBlob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updatedManifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(updateBlob), + Size: int64(len(updateBlob)), + }, + }, + } + updatedManifest.SchemaVersion = 2 + updatedManifestBlob, err := json.Marshal(updatedManifest) + So(err, ShouldBeNil) + + // update manifest should get 403 without update perm + resp, err = resty.R().SetBody(updatedManifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get the manifest and check if it's the old one + resp, err = resty.R(). + Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, manifestBlob) + + // add update perm on repo + if entry, ok := conf.AccessControl.Repositories[TestRepo]; ok { + entry.DefaultPolicy = []string{"create", "read", "update"} + conf.AccessControl.Repositories[TestRepo] = entry + } + + // update manifest should get 201 with update perm + resp, err = resty.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(updatedManifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // get the manifest and check if it's the new updated one + resp, err = resty.R(). + Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, updatedManifestBlob) + }) +} + func TestInvalidCases(t *testing.T) { Convey("Invalid repo dir", t, func() { port := test.GetFreePort() diff --git a/pkg/api/routes.go b/pkg/api/routes.go index a94301f8..382b7f71 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -57,9 +57,15 @@ func allowedMethods(method string) []string { // nolint: contextcheck func (rh *RouteHandler) SetupRoutes() { rh.c.Router.Use(AuthHandler(rh.c)) - // authz is being enabled because authn is found - if rh.c.Config.AccessControl != nil && !isBearerAuthEnabled(rh.c.Config) && isAuthnEnabled(rh.c.Config) { - rh.c.Log.Info().Msg("access control is being enabled") + // authz is being enabled if AccessControl is specified + // if Authn is not present AccessControl will have only default policies + if rh.c.Config.AccessControl != nil && !isBearerAuthEnabled(rh.c.Config) { + if isAuthnEnabled(rh.c.Config) { + rh.c.Log.Info().Msg("access control is being enabled") + } else { + rh.c.Log.Info().Msg("default policy only access control is being enabled") + } + rh.c.Router.Use(AuthzHandler(rh.c)) } diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 0567426b..15dbebb9 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -231,10 +231,8 @@ func validateConfiguration(config *config.Config) error { // check authorization config, it should have basic auth enabled or ldap if config.HTTP.RawAccessControl != nil { if config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil) { - log.Error().Err(errors.ErrBadConfig). - Msg("access control config requires httpasswd or ldap authentication to be enabled") - - return errors.ErrBadConfig + // checking for default policy only authorization config: no users, no policies but default policy + checkForDefaultPolicyConfig(config) } } @@ -311,6 +309,15 @@ func validateConfiguration(config *config.Config) error { return nil } +func checkForDefaultPolicyConfig(config *config.Config) { + if !isDefaultPolicyConfig(config) { + log.Error().Err(errors.ErrBadConfig). + Msg("access control config requires httpasswd, ldap authentication " + + "or using only 'defaultPolicy' policies") + panic(errors.ErrBadConfig) + } +} + func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { defaultVal := true @@ -431,3 +438,30 @@ func LoadConfiguration(config *config.Config, configPath string) error { return nil } + +func isDefaultPolicyConfig(cfg *config.Config) bool { + adminPolicy := cfg.AccessControl.AdminPolicy + + log.Info().Msg("checking if default authorization is possible") + + if len(adminPolicy.Actions)+len(adminPolicy.Users) > 0 { + log.Info().Msg("admin policy detected, default authorization disabled") + + return false + } + + for _, repository := range cfg.AccessControl.Repositories { + for _, policy := range repository.Policies { + if len(policy.Actions)+len(policy.Users) > 0 { + log.Info().Interface("repository", repository). + Msg("repository with non-empty policy detected, default authorization disabled") + + return false + } + } + } + + log.Info().Msg("default authorization detected") + + return true +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 0330acd5..3072aaa8 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -174,6 +174,62 @@ func TestVerify(t *testing.T) { So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldNotPanic) }) + Convey("Test verify default authorization", t, func(c C) { + tmpfile, err := ioutil.TempFile("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "accessControl":{"**":{"defaultPolicy": ["read", "create"]}, + "/repo":{"defaultPolicy": ["read", "create"]} + }}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldNotPanic) + }) + + Convey("Test verify default authorization fail", t, func(c C) { + tmpfile, err := ioutil.TempFile("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "accessControl":{"**":{"defaultPolicy": ["read", "create"]}, + "/repo":{"defaultPolicy": ["read", "create"]}, + "adminPolicy":{"users":["admin"], + "actions":["read","create","update","delete"]} + }}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + + Convey("Test verify default authorization fail", t, func(c C) { + tmpfile, err := ioutil.TempFile("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "accessControl":{"**":{"defaultPolicy": ["read", "create"]}, + "/repo":{"defaultPolicy": ["read", "create"]}, + "/repo2":{"policies": [{ + "users": ["charlie"], + "actions": ["read", "create", "update"]}]} + }}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + Convey("Test verify w/ sync and w/o filesystem storage", t, func(c C) { tmpfile, err := ioutil.TempFile("", "zot-test*.json") So(err, ShouldBeNil)