diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 74e508cc..3f390137 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,7 +39,7 @@ jobs: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed env: CGO_ENABLED: 0 - GOFLAGS: "-tags=sync,search,scrub,metrics,userprefs,apikey,containers_image_openpgp" + GOFLAGS: "-tags=sync,search,scrub,metrics,userprefs,mgmt,imagetrust,containers_image_openpgp" steps: - name: Checkout repository diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 22431394..876236a4 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -32,7 +32,7 @@ jobs: # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 - args: --config ./golangcilint.yaml --enable-all --build-tags debug,needprivileges,sync,scrub,search,userprefs,metrics,containers_image_openpgp,lint,mgmt,apikey ./cmd/... ./pkg/... + args: --config ./golangcilint.yaml --enable-all --build-tags debug,needprivileges,sync,scrub,search,userprefs,metrics,containers_image_openpgp,lint,mgmt,imagetrust ./cmd/... ./pkg/... # Optional: show only new issues if it's a pull request. The default value is `false`. # only-new-issues: true diff --git a/.gitignore b/.gitignore index 22fc769a..6724673e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ coverage.html tags vendor/ .vscode/ -examples/config-sync-localhost.json \ No newline at end of file +examples/config-sync-localhost.json +node_modules diff --git a/Makefile b/Makefile index fe3960a3..dc91b01e 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,8 @@ TESTDATA := $(TOP_LEVEL)/test/data OS ?= linux ARCH ?= amd64 BENCH_OUTPUT ?= stdout -EXTENSIONS ?= sync,search,scrub,metrics,lint,ui,mgmt,userprefs,apikey -UI_DEPENDENCIES := search,mgmt,userprefs,apikey +EXTENSIONS ?= sync,search,scrub,metrics,lint,ui,mgmt,userprefs,imagetrust +UI_DEPENDENCIES := search,mgmt,userprefs # freebsd/arm64 not supported for pie builds BUILDMODE_FLAGS := -buildmode=pie ifeq ($(OS),freebsd) diff --git a/errors/errors.go b/errors/errors.go index d34562af..3e4c65e9 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -107,6 +107,7 @@ var ( ErrInvalidTruststoreType = errors.New("signatures: invalid truststore type") ErrInvalidTruststoreName = errors.New("signatures: invalid truststore name") ErrInvalidCertificateContent = errors.New("signatures: invalid certificate content") + ErrInvalidPublicKeyContent = errors.New("signatures: invalid public key content") ErrInvalidStateCookie = errors.New("auth: state cookie not present or differs from original state") ErrSyncNoURLsLeft = errors.New("sync: no valid registry urls left after filtering local ones") ) diff --git a/examples/README.md b/examples/README.md index a97f6f62..6a2699d6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -207,12 +207,12 @@ zot can be configured to use the above providers with: } ``` -the login with either provider use http://127.0.0.1:8080/auth/login?provider=\&callback_ui=http://127.0.0.1:8080/home +The login with either provider use http://127.0.0.1:8080/auth/login?provider=\&callback_ui=http://127.0.0.1:8080/home for example to login with github use http://127.0.0.1:8080/auth/login?provider=github&callback_ui=http://127.0.0.1:8080/home callback_ui query parameter is used by zot to redirect to UI after a successful openid/oauth2 authentication -the callback url which should be used when making oauth2 provider setup is http://127.0.0.1:8080/auth/callback/\ +The callback url which should be used when making oauth2 provider setup is http://127.0.0.1:8080/auth/callback/\ for example github callback url would be http://127.0.0.1:8080/auth/callback/github If network policy doesn't allow inbound connections, this callback wont work! @@ -220,7 +220,7 @@ If network policy doesn't allow inbound connections, this callback wont work! dex is an identity service that uses OpenID Connect to drive authentication for other apps https://github.com/dexidp/dex To setup dex service see https://dexidp.io/docs/getting-started/ -to configure zot as a client in dex (assuming zot is hosted at 127.0.0.1:8080), we need to configure dex with: +To configure zot as a client in dex (assuming zot is hosted at 127.0.0.1:8080), we need to configure dex with: ``` staticClients: @@ -251,7 +251,12 @@ zot can be configured to use dex with: } ``` -to login using openid dex provider use http://127.0.0.1:8080/auth/login?provider=dex +To login using openid dex provider use http://127.0.0.1:8080/auth/login?provider=dex + +NOTE: Social login is not supported by command line tools, or other software responsible for pushing/pulling +images to/from zot. +Given this limitation, if openif authentication is enabled in the configuration, API keys are also enabled +implicitly, as a viable alternative authentication method for pushing and pulling container images. ### Session based login @@ -261,6 +266,90 @@ Using that cookie on subsequent calls will authenticate them, asumming the cooki In case of using filesystem storage sessions are saved in zot's root directory. In case of using cloud storage sessions are saved in memory. +#### API keys + +zot allows authentication for REST API calls using your API key as an alternative to your password. +The user can create or revoke his API keys after he has already authenticated using a different authentication mechanism. +An API key is shown to the user only when it is created. It can not be retrieved from zot with any other call. +An API key has the same permissions as the user who generated it. + +Below are several use cases where API keys offer advantages: + +- OpenID/OAuth2 social login is not supported by command-line tools or other such clients. In this case, the user +can login to zot using OpenID/OAuth2 and generate API keys to use later when pushing and pulling images. +- In cases where LDAP authentication is used and the user has scripts pushing or pulling images, he will probably not +want to store his LDAP username and password in a shared environment where there is a chance they are compromised. +If he generates and uses an API key instead, the security impact of that key being compromised is limited to zot, +the other services he accesses based on LDAP would not be affected. + +To activate API keys use: + +``` + "http": { + "auth": { + "apikey: true +``` + +##### How to create an API Key + +Create an API key for the current user using the REST API + +**Usage**: POST /auth/apikey + +**Produces**: application/json + +**Sample input**: + +``` +POST /auth/apiKey +Body: {"label": "git", "scopes": ["repo1", "repo2"]}' +``` + +**Example cURL** + +```bash +curl -u user:password -X POST http://localhost:8080/auth/apikey -d '{"label": "myLabel"}' +``` + +**Sample output**: + +```json +{ + "createdAt": "2023-05-05T15:39:28.420926+03:00", + "creatorUa": "curl/7.68.0", + "generatedBy": "manual", + "lastUsed": "2023-05-05T15:39:28.4209282+03:00", + "label": "git", + "scopes": null, + "uuid": "46a45ce7-5d92-498a-a9cb-9654b1da3da1", + "apiKey": "zak_e77bcb9e9f634f1581756abbf9ecd269" +} +``` + +##### How to use API Keys + +**Using API keys with cURL** + +```bash +curl -u user:zak_e77bcb9e9f634f1581756abbf9ecd269 http://localhost:8080/v2/_catalog +``` + +Other command line tools will similarly accept the API key instead of a password. + +##### How to revoke an API Key + +How to revoke an API key for the current user + +**Usage**: DELETE /auth/apiKey?id=$uuid + +**Produces**: application/json + +**Example cURL** + +```bash +curl -u user:password -X DELETE http://localhost:8080/v2/auth/apikey?id=46a45ce7-5d92-498a-a9cb-9654b1da3da1 +``` + #### Authentication Failures Should authentication fail, to prevent automated attacks, a delayed response can be configured with: @@ -271,21 +360,6 @@ Should authentication fail, to prevent automated attacks, a delayed response can "failDelay": 5 ``` -#### API keys - -zot allows authentication for REST API calls using your API key as an alternative to your password. -for more info see [API keys doc](../pkg/extensions/README_apikey.md) - -To activate API keys use: - -``` -"extensions": { - "apikey": { - "enable": true - } -} -``` - ## Identity-based Authorization Allowing actions on one or more repository paths can be tied to user diff --git a/examples/config-allextensions.json b/examples/config-allextensions.json index 202641ed..5ee0ec3d 100644 --- a/examples/config-allextensions.json +++ b/examples/config-allextensions.json @@ -48,9 +48,6 @@ "scrub": { "enable": true, "interval": "24h" - }, - "mgmt": { - "enable": true } } } diff --git a/examples/config-openid.json b/examples/config-openid.json index b85e1063..1061c164 100644 --- a/examples/config-openid.json +++ b/examples/config-openid.json @@ -12,6 +12,7 @@ "htpasswd": { "path": "test/data/htpasswd" }, + "apikey": true, "openid": { "providers": { "github": { @@ -64,12 +65,5 @@ "log": { "level": "debug" }, - "extensions": { - "apikey": { - "enable": true - }, - "mgmt": { - "enable": true - } - } + "extensions": {} } diff --git a/examples/config-ui.json b/examples/config-ui.json index b10aa9db..4dfed6e8 100644 --- a/examples/config-ui.json +++ b/examples/config-ui.json @@ -18,9 +18,6 @@ }, "ui": { "enable": true - }, - "mgmt": { - "enable": true } } } diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 779cf5f9..b61e5e93 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -18,6 +18,7 @@ import ( "time" "github.com/chartmuseum/auth" + guuid "github.com/gofrs/uuid" "github.com/google/go-github/v52/github" "github.com/google/uuid" "github.com/gorilla/mux" @@ -353,7 +354,7 @@ func (amw *AuthnMiddleware) TryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun return } - isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix + isMgmtRequested := request.RequestURI == constants.FullMgmt allowAnonymous := ctlr.Config.HTTP.AccessControl.AnonymousPolicyExists() // try basic auth if authorization header is given @@ -443,7 +444,7 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { name := vars["name"] // we want to bypass auth for mgmt route - isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix + isMgmtRequested := request.RequestURI == constants.FullMgmt header := request.Header.Get("Authorization") @@ -849,3 +850,25 @@ func GetAuthUserFromRequestSession(cookieStore sessions.Store, request *http.Req return identity, true } + +func GenerateAPIKey(uuidGenerator guuid.Generator, log log.Logger, +) (string, string, error) { + apiKeyBase, err := uuidGenerator.NewV4() + if err != nil { + log.Error().Err(err).Msg("unable to generate uuid for api key base") + + return "", "", err + } + + apiKey := strings.ReplaceAll(apiKeyBase.String(), "-", "") + + // will be used for identifying a specific api key + apiKeyID, err := uuidGenerator.NewV4() + if err != nil { + log.Error().Err(err).Msg("unable to generate uuid for api key id") + + return "", "", err + } + + return apiKey, apiKeyID.String(), err +} diff --git a/pkg/extensions/extension_api_key_test.go b/pkg/api/authn_test.go similarity index 78% rename from pkg/extensions/extension_api_key_test.go rename to pkg/api/authn_test.go index 72515f33..fcc86c5f 100644 --- a/pkg/extensions/extension_api_key_test.go +++ b/pkg/api/authn_test.go @@ -1,16 +1,19 @@ -//go:build apikey -// +build apikey +//go:build mgmt +// +build mgmt -package extensions_test +package api_test import ( "context" "encoding/json" "errors" + "fmt" "net/http" + "net/http/httptest" "os" "testing" + guuid "github.com/gofrs/uuid" "github.com/project-zot/mockoidc" . "github.com/smartystreets/goconvey/convey" "gopkg.in/resty.v1" @@ -18,14 +21,16 @@ import ( "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" - "zotregistry.io/zot/pkg/extensions" extconf "zotregistry.io/zot/pkg/extensions/config" + "zotregistry.io/zot/pkg/log" mTypes "zotregistry.io/zot/pkg/meta/types" localCtx "zotregistry.io/zot/pkg/requestcontext" "zotregistry.io/zot/pkg/test" "zotregistry.io/zot/pkg/test/mocks" ) +var ErrUnexpectedError = errors.New("error: unexpected error") + type ( apiKeyResponse struct { mTypes.APIKeyDetails @@ -33,7 +38,30 @@ type ( } ) -var ErrUnexpectedError = errors.New("unexpected err") +func TestAllowedMethodsHeaderAPIKey(t *testing.T) { + defaultVal := true + + Convey("Test http options response", t, func() { + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + conf.HTTP.Auth.APIKey = defaultVal + baseURL := test.GetBaseURL(port) + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + ctrlManager := test.NewControllerManager(ctlr) + + ctrlManager.StartAndWait(port) + defer ctrlManager.StopServer() + + resp, _ := resty.R().Options(baseURL + constants.APIKeyPath) + So(resp, ShouldNotBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,DELETE,OPTIONS") + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + }) +} func TestAPIKeys(t *testing.T) { Convey("Make a new controller", t, func() { @@ -59,6 +87,8 @@ func TestAPIKeys(t *testing.T) { }() mockOIDCConfig := mockOIDCServer.Config() + defaultVal := true + conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, @@ -74,23 +104,17 @@ func TestAPIKeys(t *testing.T) { }, }, }, + APIKey: defaultVal, } conf.HTTP.AccessControl = &config.AccessControlConfig{} - defaultVal := true - apiKeyConfig := &extconf.APIKeyConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - mgmtConfg := &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - APIKey: apiKeyConfig, - Mgmt: mgmtConfg, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal ctlr := api.NewController(conf) dir := t.TempDir() @@ -103,7 +127,7 @@ func TestAPIKeys(t *testing.T) { defer cm.StopServer() test.WaitTillServerReady(baseURL) - payload := extensions.APIKeyPayload{ + payload := api.APIKeyPayload{ Label: "test", Scopes: []string{"test"}, } @@ -115,7 +139,7 @@ func TestAPIKeys(t *testing.T) { resp, err := resty.R(). SetBody(reqBody). SetBasicAuth("test", "test"). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) @@ -141,7 +165,7 @@ func TestAPIKeys(t *testing.T) { resp, err = resty.R(). SetBody(reqBody). SetBasicAuth("test", "test"). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) @@ -175,7 +199,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) @@ -186,7 +210,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) @@ -260,7 +284,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) @@ -270,8 +294,7 @@ func TestAPIKeys(t *testing.T) { client := resty.New() // mgmt should work both unauthenticated and authenticated - resp, err := client.R(). - Get(baseURL + constants.FullMgmtPrefix) + resp, err := client.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -292,7 +315,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) @@ -326,7 +349,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -334,7 +357,7 @@ func TestAPIKeys(t *testing.T) { // invalid api keys resp, err = client.R(). SetBasicAuth("invalidEmail", apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) @@ -366,7 +389,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) @@ -375,8 +398,7 @@ func TestAPIKeys(t *testing.T) { client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) // without creds should work - resp, err = client.R(). - Get(baseURL + constants.FullMgmtPrefix) + resp, err = client.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -395,7 +417,7 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.FullAPIKeyPrefix) + Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) @@ -406,7 +428,7 @@ func TestAPIKeys(t *testing.T) { // should work with session resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -414,7 +436,7 @@ func TestAPIKeys(t *testing.T) { // should work with api key resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -433,14 +455,14 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). SetQueryParam("id", apiKeyResponse.UUID). - Delete(baseURL + constants.FullAPIKeyPrefix) + Delete(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Delete(baseURL + constants.FullAPIKeyPrefix) + Delete(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) @@ -455,18 +477,31 @@ func TestAPIKeys(t *testing.T) { resp, err = client.R(). SetBasicAuth("test", "test"). SetQueryParam("id", apiKeyResponse.UUID). - Delete(baseURL + constants.FullAPIKeyPrefix) + Delete(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) // unsupported method resp, err = client.R(). - Put(baseURL + constants.FullAPIKeyPrefix) + Put(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) }) + + Convey("Test error handling when API Key handler reads the request body", func() { + request, _ := http.NewRequestWithContext(context.TODO(), + http.MethodPost, "baseURL", errReader(0)) + response := httptest.NewRecorder() + + rthdlr := api.NewRouteHandler(ctlr) + rthdlr.CreateAPIKey(response, request) + + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) }) } @@ -489,6 +524,8 @@ func TestAPIKeysOpenDBError(t *testing.T) { }() mockOIDCConfig := mockOIDCServer.Config() + defaultVal := true + conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, @@ -505,14 +542,8 @@ func TestAPIKeysOpenDBError(t *testing.T) { }, }, }, - } - defaultVal := true - apiKeyConfig := &extconf.APIKeyConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - conf.Extensions = &extconf.ExtensionConfig{ - APIKey: apiKeyConfig, + APIKey: defaultVal, } ctlr := api.NewController(conf) @@ -529,3 +560,58 @@ func TestAPIKeysOpenDBError(t *testing.T) { }, ShouldPanic) }) } + +func TestAPIKeysGeneratorErrors(t *testing.T) { + Convey("Test API keys - unable to generate API keys and API Key IDs", t, func() { + log := log.NewLogger("debug", "") + + apiKey, apiKeyID, err := api.GenerateAPIKey(guuid.DefaultGenerator, log) + So(err, ShouldBeNil) + So(apiKey, ShouldNotEqual, "") + So(apiKeyID, ShouldNotEqual, "") + + generator := &mockUUIDGenerator{ + guuid.DefaultGenerator, 0, 0, + } + + apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) + So(err, ShouldNotBeNil) + So(apiKey, ShouldEqual, "") + So(apiKeyID, ShouldEqual, "") + + generator = &mockUUIDGenerator{ + guuid.DefaultGenerator, 1, 0, + } + + apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) + So(err, ShouldNotBeNil) + So(apiKey, ShouldEqual, "") + So(apiKeyID, ShouldEqual, "") + }) +} + +type mockUUIDGenerator struct { + guuid.Generator + succeedAttempts int + attemptCount int +} + +func (gen *mockUUIDGenerator) NewV4() ( + guuid.UUID, error, +) { + defer func() { + gen.attemptCount += 1 + }() + + if gen.attemptCount >= gen.succeedAttempts { + return guuid.UUID{}, ErrUnexpectedError + } + + return guuid.DefaultGenerator.NewV4() +} + +type errReader int + +func (errReader) Read(p []byte) (int, error) { + return 0, fmt.Errorf("test error") //nolint:goerr113 +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 9d07e6fb..0fd81087 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -50,6 +50,7 @@ type AuthConfig struct { LDAP *LDAPConfig Bearer *BearerConfig OpenID *OpenIDConfig + APIKey bool } type BearerConfig struct { @@ -274,8 +275,7 @@ func (c *Config) IsOpenIDAuthEnabled() bool { } func (c *Config) IsAPIKeyEnabled() bool { - if c.Extensions != nil && c.Extensions.APIKey != nil && - *c.Extensions.APIKey.Enable { + if c.HTTP.Auth != nil && c.HTTP.Auth.APIKey { return true } @@ -308,6 +308,38 @@ func isOpenIDAuthProviderEnabled(config *Config, provider string) bool { return false } +func (c *Config) IsSearchEnabled() bool { + return c.Extensions != nil && c.Extensions.Search != nil && *c.Extensions.Search.Enable +} + +func (c *Config) IsUIEnabled() bool { + return c.Extensions != nil && c.Extensions.UI != nil && *c.Extensions.UI.Enable +} + +func (c *Config) AreUserPrefsEnabled() bool { + return c.IsSearchEnabled() && c.IsUIEnabled() +} + +func (c *Config) IsMgmtEnabled() bool { + return c.IsSearchEnabled() && c.IsUIEnabled() +} + +func (c *Config) IsImageTrustEnabled() bool { + return c.Extensions != nil && c.Extensions.Trust != nil && *c.Extensions.Trust.Enable +} + +func (c *Config) IsCosignEnabled() bool { + return c.IsImageTrustEnabled() && c.Extensions.Trust.Cosign +} + +func (c *Config) IsNotationEnabled() bool { + return c.IsImageTrustEnabled() && c.Extensions.Trust.Notation +} + +func (c *Config) IsSyncEnabled() bool { + return c.Extensions != nil && c.Extensions.Sync != nil && *c.Extensions.Sync.Enable +} + func IsOpenIDSupported(provider string) bool { for _, supportedProvider := range openIDSupportedProviders { if supportedProvider == provider { diff --git a/pkg/api/constants/consts.go b/pkg/api/constants/consts.go index d5cf28da..d7ac8999 100644 --- a/pkg/api/constants/consts.go +++ b/pkg/api/constants/consts.go @@ -15,6 +15,7 @@ const ( CallbackBasePath = "/auth/callback" LoginPath = "/auth/login" LogoutPath = "/auth/logout" + APIKeyPath = "/auth/apikey" //nolint: gosec SessionClientHeaderName = "X-ZOT-API-CLIENT" SessionClientHeaderValue = "zot-ui" APIKeysPrefix = "zak_" diff --git a/pkg/api/constants/extensions.go b/pkg/api/constants/extensions.go index ca490bc4..1fa02461 100644 --- a/pkg/api/constants/extensions.go +++ b/pkg/api/constants/extensions.go @@ -4,21 +4,31 @@ package constants const ( ExtCatalogPrefix = "/_catalog" ExtOciDiscoverPrefix = "/_oci/ext/discover" - // zot specific extensions. - ExtPrefix = "/_zot/ext" + // zot specific extensions. + BasePrefix = "/_zot" + ExtPrefix = BasePrefix + "/ext" + + // search extension. ExtSearch = "/search" ExtSearchPrefix = ExtPrefix + ExtSearch FullSearchPrefix = RoutePrefix + ExtSearchPrefix - ExtMgmt = "/mgmt" - ExtMgmtPrefix = ExtPrefix + ExtMgmt - FullMgmtPrefix = RoutePrefix + ExtMgmtPrefix + // mgmt extension. + Mgmt = "/mgmt" + ExtMgmt = ExtPrefix + Mgmt + FullMgmt = RoutePrefix + ExtMgmt - ExtUserPreferences = "/userprefs" - ExtUserPreferencesPrefix = ExtPrefix + ExtUserPreferences - FullUserPreferencesPrefix = RoutePrefix + ExtUserPreferencesPrefix - ExtAPIKey = "/apikey" - ExtAPIKeyPrefix = ExtPrefix + ExtAPIKey //nolint: gosec - FullAPIKeyPrefix = RoutePrefix + ExtAPIKeyPrefix + // signatures extension. + Notation = "/notation" + ExtNotation = ExtPrefix + Notation + FullNotation = RoutePrefix + ExtNotation + Cosign = "/cosign" + ExtCosign = ExtPrefix + Cosign + FullCosign = RoutePrefix + ExtCosign + + // user preferences extension. + UserPrefs = "/userprefs" + ExtUserPrefs = ExtPrefix + UserPrefs + FullUserPrefs = RoutePrefix + ExtUserPrefs ) diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 38728b3d..0d26d782 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -258,9 +258,8 @@ func (c *Controller) InitImageStore() error { } func (c *Controller) InitMetaDB(reloadCtx context.Context) error { - // init metaDB if search is enabled or authn enabled (need to store user profiles) or apikey ext is enabled - if (c.Config.Extensions != nil && c.Config.Extensions.Search != nil && *c.Config.Extensions.Search.Enable) || - c.Config.IsBasicAuthnEnabled() { + // init metaDB if search is enabled or we need to store user profiles, api keys or signatures + if c.Config.IsSearchEnabled() || c.Config.IsBasicAuthnEnabled() || c.Config.IsImageTrustEnabled() { driver, err := meta.New(c.Config.Storage.StorageConfig, c.Log) //nolint:contextcheck if err != nil { return err @@ -368,11 +367,8 @@ func (c *Controller) StartBackgroundTasks(reloadCtx context.Context) { c.SyncOnDemand = syncOnDemand } - if c.Config.Extensions != nil { - if c.Config.Extensions.Mgmt != nil && *c.Config.Extensions.Mgmt.Enable { - ext.EnablePeriodicSignaturesVerification(c.Config, taskScheduler, c.MetaDB, c.Log) //nolint: contextcheck - } - } + // we can later move enabling the other scheduled tasks inside the call below + ext.EnableScheduledTasks(c.Config, taskScheduler, c.MetaDB, c.Log) //nolint: contextcheck } type SyncOnDemand interface { diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 1162c85e..e034bbc1 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -1,5 +1,5 @@ -//go:build sync && scrub && metrics && search && lint && apikey && mgmt -// +build sync,scrub,metrics,search,lint,apikey,mgmt +//go:build sync && scrub && metrics && search && lint && userprefs && mgmt && imagetrust && ui +// +build sync,scrub,metrics,search,lint,userprefs,mgmt,imagetrust,ui package api_test @@ -2659,12 +2659,18 @@ func TestOpenIDMiddleware(t *testing.T) { }, } - mgmtConfg := &extconf.MgmtConfig{ + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + // UI is enabled because we also want to test access on the mgmt route + uiConfig := &extconf.UIConfig{ BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, } conf.Extensions = &extconf.ExtensionConfig{ - Mgmt: mgmtConfg, + Search: searchConfig, + UI: uiConfig, } ctlr := api.NewController(conf) @@ -2769,7 +2775,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") So(err, ShouldBeNil) @@ -2778,7 +2784,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R(). SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -2795,7 +2801,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -2835,7 +2841,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/") So(err, ShouldBeNil) @@ -2844,7 +2850,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R(). SetBasicAuth(username, passphrase). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -2861,7 +2867,7 @@ func TestOpenIDMiddleware(t *testing.T) { resp, err = client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -2888,7 +2894,7 @@ func TestOpenIDMiddleware(t *testing.T) { // mgmt should work both unauthenticated and authenticated resp, err := client.R(). - Get(baseURL + constants.FullMgmtPrefix) + Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -3063,12 +3069,17 @@ func TestAuthnSessionErrors(t *testing.T) { }, } - mgmtConfg := &extconf.MgmtConfig{ + uiConfig := &extconf.UIConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + searchConfig := &extconf.SearchConfig{ BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, } conf.Extensions = &extconf.ExtensionConfig{ - Mgmt: mgmtConfg, + UI: uiConfig, + Search: searchConfig, } ctlr := api.NewController(conf) @@ -8391,7 +8402,7 @@ func TestSearchRoutes(t *testing.T) { } func TestDistSpecExtensions(t *testing.T) { - Convey("start zot server with search extension", t, func(c C) { + Convey("start zot server with search, ui and trust extensions", t, func(c C) { conf := config.New() port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -8400,13 +8411,16 @@ func TestDistSpecExtensions(t *testing.T) { defaultVal := true - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultVal + conf.Extensions.Trust.Cosign = defaultVal + conf.Extensions.Trust.Notation = defaultVal logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) @@ -8427,16 +8441,23 @@ func TestDistSpecExtensions(t *testing.T) { So(resp.StatusCode(), ShouldEqual, 200) err = json.Unmarshal(resp.Body(), &extensionList) So(err, ShouldBeNil) + t.Log(extensionList.Extensions) So(len(extensionList.Extensions), ShouldEqual, 1) - So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 2) + So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 5) So(extensionList.Extensions[0].Name, ShouldEqual, "_zot") So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) + // Verify the endpoints below are enabled by search So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullUserPreferencesPrefix) + // Verify the endpoints below are enabled by trust + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullCosign) + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullNotation) + // Verify the endpint below are enabled by having both the UI and the Search enabled + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmt) + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullUserPrefs) }) - Convey("start zot server with search and mgmt extensions", t, func(c C) { + Convey("start zot server with only the search extension enabled", t, func(c C) { conf := config.New() port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -8445,18 +8466,9 @@ func TestDistSpecExtensions(t *testing.T) { defaultVal := true - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - mgmtConfg := &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - Mgmt: mgmtConfg, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) @@ -8477,14 +8489,51 @@ func TestDistSpecExtensions(t *testing.T) { So(resp.StatusCode(), ShouldEqual, 200) err = json.Unmarshal(resp.Body(), &extensionList) So(err, ShouldBeNil) + t.Log(extensionList.Extensions) So(len(extensionList.Extensions), ShouldEqual, 1) - So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 3) + So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 1) So(extensionList.Extensions[0].Name, ShouldEqual, "_zot") So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) + // Verify the endpoints below are enabled by search So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullUserPreferencesPrefix) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmtPrefix) + // Verify the endpoints below are not enabled since trust is not enabled + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullCosign) + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullNotation) + // Verify the endpoints below are not enabled since the UI is not enabled + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullMgmt) + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullUserPrefs) + }) + + Convey("start zot server with no enabled extensions", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up + + ctlr := makeController(conf, t.TempDir(), "") + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + var extensionList distext.ExtensionList + + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + err = json.Unmarshal(resp.Body(), &extensionList) + So(err, ShouldBeNil) + t.Log(extensionList.Extensions) + // Verify all endpoints which are disabled (even signing urls depend on search being enabled) + So(len(extensionList.Extensions), ShouldEqual, 0) }) Convey("start minimal zot server", t, func(c C) { diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 3c809a77..fe7ed7a5 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -19,9 +19,12 @@ import ( "sort" "strconv" "strings" + "time" + guuid "github.com/gofrs/uuid" "github.com/google/go-github/v52/github" "github.com/gorilla/mux" + jsoniter "github.com/json-iterator/go" "github.com/opencontainers/distribution-spec/specs-go/v1/extensions" godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -40,6 +43,7 @@ import ( syncConstants "zotregistry.io/zot/pkg/extensions/sync/constants" "zotregistry.io/zot/pkg/log" "zotregistry.io/zot/pkg/meta" + mTypes "zotregistry.io/zot/pkg/meta/types" zreg "zotregistry.io/zot/pkg/regexp" localCtx "zotregistry.io/zot/pkg/requestcontext" storageCommon "zotregistry.io/zot/pkg/storage/common" @@ -80,6 +84,19 @@ func (rh *RouteHandler) SetupRoutes() { } } + if rh.c.Config.IsAPIKeyEnabled() { + // enable api key management urls + apiKeyRouter := rh.c.Router.PathPrefix(constants.APIKeyPath).Subrouter() + apiKeyRouter.Use(authHandler) + apiKeyRouter.Use(BaseAuthzHandler(rh.c)) + apiKeyRouter.Use(zcommon.ACHeadersMiddleware(rh.c.Config, + http.MethodPost, http.MethodDelete, http.MethodOptions)) + apiKeyRouter.Use(zcommon.CORSHeadersMiddleware(rh.c.Config.HTTP.AllowOrigin)) + + apiKeyRouter.Methods(http.MethodPost, http.MethodOptions).HandlerFunc(rh.CreateAPIKey) + apiKeyRouter.Methods(http.MethodDelete).HandlerFunc(rh.RevokeAPIKey) + } + /* on every route which may be used by UI we set OPTIONS as allowed METHOD to enable preflight request from UI to backend */ if rh.c.Config.IsBasicAuthnEnabled() { @@ -157,61 +174,42 @@ func (rh *RouteHandler) SetupRoutes() { // swagger debug.SetupSwaggerRoutes(rh.c.Config, rh.c.Router, authHandler, rh.c.Log) + // gql playground + gqlPlayground.SetupGQLPlaygroundRoutes(prefixedRouter, rh.c.StoreController, rh.c.Log) - // Setup Extensions Routes + // setup extension routes if rh.c.Config != nil { + // This logic needs to be reviewed, it should depend on build options + // not the general presence of the extensions in config if rh.c.Config.Extensions == nil { // minimal build prefixedRouter.HandleFunc("/metrics", rh.GetMetrics).Methods("GET") } else { // extended build - prefixedExtensionsRouter := prefixedRouter.PathPrefix(constants.ExtPrefix).Subrouter() - prefixedExtensionsRouter.Use(CORSHeadersMiddleware(rh.c.Config.HTTP.AllowOrigin)) - - ext.SetupMgmtRoutes(rh.c.Config, prefixedExtensionsRouter, rh.c.Log) - ext.SetupSearchRoutes(rh.c.Config, prefixedExtensionsRouter, rh.c.StoreController, rh.c.MetaDB, rh.c.CveInfo, - rh.c.Log) - ext.SetupUserPreferencesRoutes(rh.c.Config, prefixedExtensionsRouter, rh.c.StoreController, rh.c.MetaDB, - rh.c.CveInfo, rh.c.Log) - ext.SetupAPIKeyRoutes(rh.c.Config, prefixedExtensionsRouter, rh.c.MetaDB, rh.c.CookieStore, rh.c.Log) - ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, authHandler, rh.c.Log) - - gqlPlayground.SetupGQLPlaygroundRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.Log) - - // last should always be UI because it will setup a http.FileServer and paths will be resolved by this FileServer. - ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, rh.c.Log) + ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, authHandler, rh.c.Log) } } -} -func CORSHeadersMiddleware(allowOrigin string) mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - addCORSHeaders(allowOrigin, response) - - next.ServeHTTP(response, request) - }) - } + // Preconditions for enabling the actual extension routes are part of extensions themselves + ext.SetupSearchRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.MetaDB, rh.c.CveInfo, + rh.c.Log) + ext.SetupImageTrustRoutes(rh.c.Config, prefixedRouter, rh.c.Log) + ext.SetupMgmtRoutes(rh.c.Config, prefixedRouter, rh.c.Log) + ext.SetupUserPreferencesRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) + // last should always be UI because it will setup a http.FileServer and paths will be resolved by this FileServer. + ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.Log) } func getCORSHeadersHandler(allowOrigin string) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - addCORSHeaders(allowOrigin, response) + zcommon.AddCORSHeaders(allowOrigin, response) next.ServeHTTP(response, request) }) } } -func addCORSHeaders(allowOrigin string, response http.ResponseWriter) { - if allowOrigin == "" { - response.Header().Set("Access-Control-Allow-Origin", "*") - } else { - response.Header().Set("Access-Control-Allow-Origin", allowOrigin) - } -} - func getUIHeadersHandler(config *config.Config, allowedMethods ...string) func(http.HandlerFunc) http.HandlerFunc { allowedMethodsValue := strings.Join(allowedMethods, ",") @@ -1980,6 +1978,123 @@ func (rh *RouteHandler) GetOrasReferrers(response http.ResponseWriter, request * zcommon.WriteJSON(response, http.StatusOK, rs) } +type APIKeyPayload struct { //nolint:revive + Label string `json:"label"` + Scopes []string `json:"scopes"` +} + +// CreateAPIKey godoc +// @Summary Create an API key for the current user +// @Description Can create an api key for a logged in user, based on the provided label and scopes. +// @Accept json +// @Produce json +// @Param id body APIKeyPayload true "api token id (UUID)" +// @Success 201 {string} string "created" +// @Failure 400 {string} string "bad request" +// @Failure 401 {string} string "unauthorized" +// @Failure 500 {string} string "internal server error" +// @Router /auth/apikey [post]. +func (rh *RouteHandler) CreateAPIKey(resp http.ResponseWriter, req *http.Request) { + var payload APIKeyPayload + + body, err := io.ReadAll(req.Body) + if err != nil { + rh.c.Log.Error().Msg("unable to read request body") + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + err = json.Unmarshal(body, &payload) + if err != nil { + resp.WriteHeader(http.StatusBadRequest) + + return + } + + apiKey, apiKeyID, err := GenerateAPIKey(guuid.DefaultGenerator, rh.c.Log) + if err != nil { + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + hashedAPIKey := hashUUID(apiKey) + + apiKeyDetails := &mTypes.APIKeyDetails{ + CreatedAt: time.Now(), + LastUsed: time.Now(), + CreatorUA: req.UserAgent(), + GeneratedBy: "manual", + Label: payload.Label, + Scopes: payload.Scopes, + UUID: apiKeyID, + } + + err = rh.c.MetaDB.AddUserAPIKey(req.Context(), hashedAPIKey, apiKeyDetails) + if err != nil { + rh.c.Log.Error().Err(err).Msg("error storing API key") + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + apiKeyResponse := struct { + mTypes.APIKeyDetails + APIKey string `json:"apiKey"` + }{ + APIKey: fmt.Sprintf("%s%s", constants.APIKeysPrefix, apiKey), + APIKeyDetails: *apiKeyDetails, + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + + data, err := json.Marshal(apiKeyResponse) + if err != nil { + rh.c.Log.Error().Err(err).Msg("unable to marshal api key response") + + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + resp.Header().Set("Content-Type", constants.DefaultMediaType) + resp.WriteHeader(http.StatusCreated) + _, _ = resp.Write(data) +} + +// RevokeAPIKey godoc +// @Summary Revokes one current user API key +// @Description Revokes one current user API key based on given key ID +// @Accept json +// @Produce json +// @Param id query string true "api token id (UUID)" +// @Success 200 {string} string "ok" +// @Failure 500 {string} string "internal server error" +// @Failure 401 {string} string "unauthorized" +// @Failure 400 {string} string "bad request" +// @Router /auth/apikey [delete]. +func (rh *RouteHandler) RevokeAPIKey(resp http.ResponseWriter, req *http.Request) { + ids, ok := req.URL.Query()["id"] + if !ok || len(ids) != 1 { + resp.WriteHeader(http.StatusBadRequest) + + return + } + + keyID := ids[0] + + err := rh.c.MetaDB.DeleteUserAPIKey(req.Context(), keyID) + if err != nil { + rh.c.Log.Error().Err(err).Str("keyID", keyID).Msg("error deleting API key") + resp.WriteHeader(http.StatusInternalServerError) + + return + } + + resp.WriteHeader(http.StatusOK) +} + // GetBlobUploadSessionLocation returns actual blob location to start/resume uploading blobs. // e.g. /v2//blobs/uploads/. func getBlobUploadSessionLocation(url *url.URL, sessionID string) string { @@ -2009,9 +2124,7 @@ func getBlobUploadLocation(url *url.URL, name string, digest godigest.Digest) st } func isSyncOnDemandEnabled(ctlr Controller) bool { - if ctlr.Config.Extensions != nil && - ctlr.Config.Extensions.Sync != nil && - *ctlr.Config.Extensions.Sync.Enable && + if ctlr.Config.IsSyncEnabled() && fmt.Sprintf("%v", ctlr.SyncOnDemand) != fmt.Sprintf("%v", nil) { return true } diff --git a/pkg/api/routes_test.go b/pkg/api/routes_test.go index f7e37ed8..afb21ec0 100644 --- a/pkg/api/routes_test.go +++ b/pkg/api/routes_test.go @@ -1,5 +1,5 @@ -//go:build sync && scrub && metrics && search && lint && apikey && mgmt -// +build sync,scrub,metrics,search,lint,apikey,mgmt +//go:build sync && scrub && metrics && search && lint && mgmt +// +build sync,scrub,metrics,search,lint,mgmt package api_test @@ -7,7 +7,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" @@ -28,8 +27,6 @@ import ( "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" - "zotregistry.io/zot/pkg/extensions" - extconf "zotregistry.io/zot/pkg/extensions/config" mTypes "zotregistry.io/zot/pkg/meta/types" localCtx "zotregistry.io/zot/pkg/requestcontext" storageTypes "zotregistry.io/zot/pkg/storage/types" @@ -37,8 +34,6 @@ import ( "zotregistry.io/zot/pkg/test/mocks" ) -var ErrUnexpectedError = errors.New("error: unexpected error") - const sessionStr = "session" func TestRoutes(t *testing.T) { @@ -62,6 +57,8 @@ func TestRoutes(t *testing.T) { }() mockOIDCConfig := mockOIDCServer.Config() + defaultVal := true + conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, @@ -77,14 +74,7 @@ func TestRoutes(t *testing.T) { }, }, }, - } - - defaultVal := true - apiKeyConfig := &extconf.APIKeyConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - conf.Extensions = &extconf.ExtensionConfig{ - APIKey: apiKeyConfig, + APIKey: defaultVal, } ctlr := api.NewController(conf) @@ -1434,14 +1424,14 @@ func TestRoutes(t *testing.T) { request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) response := httptest.NewRecorder() - extensions.CreateAPIKey(response, request, ctlr.MetaDB, ctlr.CookieStore, ctlr.Log) + rthdlr.CreateAPIKey(response, request) resp := response.Result() defer resp.Body.Close() - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) acCtx := localCtx.AccessControlContext{ - Username: username, + Username: "test", } ctx = context.TODO() @@ -1451,14 +1441,14 @@ func TestRoutes(t *testing.T) { request, _ = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) response = httptest.NewRecorder() - extensions.CreateAPIKey(response, request, ctlr.MetaDB, ctlr.CookieStore, ctlr.Log) + rthdlr.CreateAPIKey(response, request) resp = response.Result() defer resp.Body.Close() - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - payload := extensions.APIKeyPayload{ + payload := api.APIKeyPayload{ Label: "test", Scopes: []string{"test"}, } @@ -1468,11 +1458,12 @@ func TestRoutes(t *testing.T) { request, _ = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(reqBody)) response = httptest.NewRecorder() - extensions.CreateAPIKey(response, request, mocks.MetaDBMock{ + ctlr.MetaDB = mocks.MetaDBMock{ AddUserAPIKeyFn: func(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { return ErrUnexpectedError }, - }, ctlr.CookieStore, ctlr.Log) + } + rthdlr.CreateAPIKey(response, request) resp = response.Result() defer resp.Body.Close() @@ -1486,11 +1477,12 @@ func TestRoutes(t *testing.T) { q.Add("id", "apikeyid") request.URL.RawQuery = q.Encode() - extensions.RevokeAPIKey(response, request, mocks.MetaDBMock{ + ctlr.MetaDB = mocks.MetaDBMock{ DeleteUserAPIKeyFn: func(ctx context.Context, id string) error { return ErrUnexpectedError }, - }, ctlr.CookieStore, ctlr.Log) + } + rthdlr.RevokeAPIKey(response, request) resp = response.Result() defer resp.Body.Close() diff --git a/pkg/cli/extensions_test.go b/pkg/cli/extensions_test.go index 4ae19ecc..eeb7abfc 100644 --- a/pkg/cli/extensions_test.go +++ b/pkg/cli/extensions_test.go @@ -1,14 +1,12 @@ -//go:build sync && scrub && metrics && search && apikey -// +build sync,scrub,metrics,search,apikey +//go:build sync && scrub && metrics && search && userprefs && mgmt && imagetrust +// +build sync,scrub,metrics,search,userprefs,mgmt,imagetrust package cli_test import ( - "context" "fmt" "net/http" "os" - "strings" "testing" "time" @@ -640,7 +638,7 @@ func TestServeSearchEnabled(t *testing.T) { substring := `"Extensions":{"Search":{"Enable":true,"CVE":null}` - found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) + found, err := ReadLogFileAndSearchString(logPath, substring, readLogFileTimeout) if !found { data, err := os.ReadFile(logPath) @@ -691,7 +689,7 @@ func TestServeSearchEnabledCVE(t *testing.T) { substring := "\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":7200000000000,\"Trivy\":" + "{\"DBRepository\":\"ghcr.io/aquasecurity/trivy-db\",\"JavaDBRepository\":\"ghcr.io/aquasecurity/trivy-java-db\"}}}" - found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) + found, err := ReadLogFileAndSearchString(logPath, substring, readLogFileTimeout) defer func() { if !found { @@ -704,7 +702,7 @@ func TestServeSearchEnabledCVE(t *testing.T) { So(found, ShouldBeTrue) So(err, ShouldBeNil) - found, err = readLogFileAndSearchString(logPath, "updating the CVE database", readLogFileTimeout) + found, err = ReadLogFileAndSearchString(logPath, "updating the CVE database", readLogFileTimeout) So(found, ShouldBeTrue) So(err, ShouldBeNil) }) @@ -741,7 +739,7 @@ func TestServeSearchEnabledNoCVE(t *testing.T) { defer os.Remove(logPath) // clean up substring := `"Extensions":{"Search":{"Enable":true,"CVE":null}` //nolint:lll // gofumpt conflicts with lll - found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) + found, err := ReadLogFileAndSearchString(logPath, substring, readLogFileTimeout) if !found { data, err := os.ReadFile(logPath) @@ -815,20 +813,31 @@ func TestServeMgmtExtension(t *testing.T) { "output": "%s" }, "extensions": { - "Mgmt": { + "ui": { + "enable": true + }, + "search": { + "enable": true } } }` logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) - data, err := os.ReadFile(logPath) - So(err, ShouldBeNil) defer os.Remove(logPath) // clean up - So(string(data), ShouldContainSubstring, "\"Mgmt\":{\"Enable\":true}") + found, err := ReadLogFileAndSearchString(logPath, "setting up mgmt routes", 10*time.Second) + + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) }) - Convey("Mgmt disabled", t, func(c C) { + Convey("Mgmt disabled - UI unconfigured", t, func(c C) { content := `{ "storage": { "rootDirectory": "%s" @@ -842,27 +851,66 @@ func TestServeMgmtExtension(t *testing.T) { "output": "%s" }, "extensions": { - "Mgmt": { - "enable": "false" + "search": { + "enable": true } } }` logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) - data, err := os.ReadFile(logPath) + defer os.Remove(logPath) // clean up + found, err := ReadLogFileAndSearchString(logPath, + "skip enabling the mgmt route as the config prerequisites are not met", 10*time.Second) + + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + }) + + Convey("Mgmt disabled - extensions missing", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s" + }, + "log": { + "level": "debug", + "output": "%s" + } + }` + + logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) defer os.Remove(logPath) // clean up - So(string(data), ShouldContainSubstring, "\"Mgmt\":{\"Enable\":false}") + found, err := ReadLogFileAndSearchString(logPath, + "skip enabling the mgmt route as the config prerequisites are not met", 10*time.Second) + + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) }) } -func TestServeAPIKeyExtension(t *testing.T) { +func TestServeImageTrustExtension(t *testing.T) { oldArgs := os.Args defer func() { os.Args = oldArgs }() - Convey("apikey implicitly enabled", t, func(c C) { + Convey("Trust explicitly disabled", t, func(c C) { content := `{ "storage": { "rootDirectory": "%s" @@ -876,20 +924,29 @@ func TestServeAPIKeyExtension(t *testing.T) { "output": "%s" }, "extensions": { - "apikey": { + "trust": { + "enable": false } } }` logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) - data, err := os.ReadFile(logPath) - So(err, ShouldBeNil) defer os.Remove(logPath) // clean up - So(string(data), ShouldContainSubstring, "\"APIKey\":{\"Enable\":true}") + found, err := ReadLogFileAndSearchString(logPath, + "skip enabling the image trust routes as the config prerequisites are not met", 10*time.Second) + + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) }) - Convey("apikey disabled", t, func(c C) { + Convey("Trust explicitly enabled - but cosign and notation disabled", t, func(c C) { content := `{ "storage": { "rootDirectory": "%s" @@ -903,79 +960,75 @@ func TestServeAPIKeyExtension(t *testing.T) { "output": "%s" }, "extensions": { - "apikey": { - "enable": "false" + "trust": { + "enable": true } } }` logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) - data, err := os.ReadFile(logPath) + defer os.Remove(logPath) // clean up + found, err := ReadLogFileAndSearchString(logPath, + "skip enabling the image trust routes as the config prerequisites are not met", 10*time.Second) + + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + }) + + Convey("Trust explicitly enabled - cosign and notation enabled", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s" + }, + "log": { + "level": "debug", + "output": "%s" + }, + "extensions": { + "trust": { + "enable": true, + "cosign": true, + "notation": true + } + } + }` + + logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) defer os.Remove(logPath) // clean up - So(string(data), ShouldContainSubstring, "\"APIKey\":{\"Enable\":false}") + found, err := ReadLogFileAndSearchString(logPath, + "setting up image trust routes", 10*time.Second) + + defer func() { + if !found { + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = ReadLogFileAndSearchString(logPath, + "setting up notation route", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = ReadLogFileAndSearchString(logPath, + "setting up cosign route", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) }) } - -func readLogFileAndSearchString(logPath string, stringToMatch string, timeout time.Duration) (bool, error) { //nolint:unparam,lll - ctx, cancelFunc := context.WithTimeout(context.Background(), timeout) - defer cancelFunc() - - for { - select { - case <-ctx.Done(): - return false, nil - default: - content, err := os.ReadFile(logPath) - if err != nil { - return false, err - } - - if strings.Contains(string(content), stringToMatch) { - return true, nil - } - } - } -} - -// run cli and return output. -func runCLIWithConfig(tempDir string, config string) (string, error) { - port := GetFreePort() - baseURL := GetBaseURL(port) - - logFile, err := os.CreateTemp(tempDir, "zot-log*.txt") - if err != nil { - return "", err - } - - cfgfile, err := os.CreateTemp(tempDir, "zot-test*.json") - if err != nil { - return "", err - } - - config = fmt.Sprintf(config, tempDir, port, logFile.Name()) - - _, err = cfgfile.Write([]byte(config)) - if err != nil { - return "", err - } - - err = cfgfile.Close() - if err != nil { - return "", err - } - - os.Args = []string{"cli_test", "serve", cfgfile.Name()} - - go func() { - err = cli.NewServerRootCmd().Execute() - if err != nil { - panic(err) - } - }() - - WaitTillServerReady(baseURL) - - return logFile.Name(), nil -} diff --git a/pkg/cli/image_cmd_test.go b/pkg/cli/image_cmd_test.go index 82c4c9d5..4b26b4d1 100644 --- a/pkg/cli/image_cmd_test.go +++ b/pkg/cli/image_cmd_test.go @@ -757,10 +757,10 @@ func TestOutputFormat(t *testing.T) { `"variant":""},"isSigned":false,"downloadCount":0,`+ `"layers":[{"size":"","digest":"sha256:c122a146f0d02349be211bb95cc2530f4a5793f96edbdfa00860f741e5d8c0e6",`+ `"score":0}],"history":null,"vulnerabilities":{"maxSeverity":"","count":0},`+ - `"referrers":null,"artifactType":""}],"size":"123445",`+ + `"referrers":null,"artifactType":"","signatureInfo":null}],"size":"123445",`+ `"downloadCount":0,"lastUpdated":"0001-01-01T00:00:00Z","description":"","isSigned":false,"licenses":"",`+ `"labels":"","title":"","source":"","documentation":"","authors":"","vendor":"",`+ - `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null}`+"\n") + `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null,"signatureInfo":null}`+"\n") So(err, ShouldBeNil) }) @@ -788,10 +788,10 @@ func TestOutputFormat(t *testing.T) { `issigned: false downloadcount: 0 layers: - size: "" `+ `digest: sha256:c122a146f0d02349be211bb95cc2530f4a5793f96edbdfa00860f741e5d8c0e6 score: 0 `+ `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" `+ - `size: "123445" downloadcount: 0 `+ + `signatureinfo: [] size: "123445" downloadcount: 0 `+ `lastupdated: 0001-01-01T00:00:00Z description: "" issigned: false licenses: "" labels: "" `+ `title: "" source: "" documentation: "" authors: "" vendor: "" vulnerabilities: maxseverity: "" `+ - `count: 0 referrers: []`, + `count: 0 referrers: [] signatureinfo: []`, ) So(err, ShouldBeNil) @@ -822,10 +822,10 @@ func TestOutputFormat(t *testing.T) { `issigned: false downloadcount: 0 layers: - size: "" `+ `digest: sha256:c122a146f0d02349be211bb95cc2530f4a5793f96edbdfa00860f741e5d8c0e6 score: 0 `+ `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" `+ - `size: "123445" downloadcount: 0 `+ + `signatureinfo: [] size: "123445" downloadcount: 0 `+ `lastupdated: 0001-01-01T00:00:00Z description: "" issigned: false licenses: "" labels: "" `+ `title: "" source: "" documentation: "" authors: "" vendor: "" vulnerabilities: maxseverity: `+ - `"" count: 0 referrers: []`, + `"" count: 0 referrers: [] signatureinfo: []`, ) So(err, ShouldBeNil) }) @@ -886,10 +886,11 @@ func TestOutputFormatGQL(t *testing.T) { `"lastUpdated":"2023-01-01T12:00:00Z","size":"528","platform":{"os":"linux","arch":"amd64",` + `"variant":""},"isSigned":false,"downloadCount":0,"layers":[{"size":"15","digest":` + `"sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6","score":0}],` + - `"history":null,"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null,"artifactType":""}],` + + `"history":null,"vulnerabilities":{"maxSeverity":"","count":0},` + + `"referrers":null,"artifactType":"","signatureInfo":null}],` + `"size":"528","downloadCount":0,"lastUpdated":"2023-01-01T12:00:00Z","description":"","isSigned":false,` + `"licenses":"","labels":"","title":"","source":"","documentation":"","authors":"","vendor":"",` + - `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null}` + "\n" + + `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null,"signatureInfo":null}` + "\n" + `{"repoName":"repo7","tag":"test:2.0",` + `"digest":"sha256:51e18f508fd7125b0831ff9a22ba74cd79f0b934e77661ff72cfb54896951a06",` + `"mediaType":"application/vnd.oci.image.manifest.v1+json",` + @@ -898,10 +899,11 @@ func TestOutputFormatGQL(t *testing.T) { `"lastUpdated":"2023-01-01T12:00:00Z","size":"528","platform":{"os":"linux","arch":"amd64",` + `"variant":""},"isSigned":false,"downloadCount":0,"layers":[{"size":"15","digest":` + `"sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6","score":0}],` + - `"history":null,"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null,"artifactType":""}],` + + `"history":null,"vulnerabilities":{"maxSeverity":"","count":0},` + + `"referrers":null,"artifactType":"","signatureInfo":null}],` + `"size":"528","downloadCount":0,"lastUpdated":"2023-01-01T12:00:00Z","description":"","isSigned":false,` + `"licenses":"","labels":"","title":"","source":"","documentation":"","authors":"","vendor":"",` + - `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null}` + "\n" + `"vulnerabilities":{"maxSeverity":"","count":0},"referrers":null,"signatureInfo":null}` + "\n" // Output is supposed to be in json lines format, keep all spaces as is for verification So(buff.String(), ShouldEqual, expectedStr) So(err, ShouldBeNil) @@ -928,10 +930,11 @@ func TestOutputFormatGQL(t *testing.T) { `lastupdated: 2023-01-01T12:00:00Z size: "528" platform: os: linux arch: amd64 variant: "" ` + `issigned: false downloadcount: 0 layers: - size: "15" ` + `digest: sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6 score: 0 ` + - `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" ` + + `history: [] vulnerabilities: maxseverity: "" ` + + `count: 0 referrers: [] artifacttype: "" signatureinfo: [] ` + `size: "528" downloadcount: 0 lastupdated: 2023-01-01T12:00:00Z description: "" ` + `issigned: false licenses: "" labels: "" title: "" source: "" documentation: "" ` + - `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: [] ` + + `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: [] signatureinfo: [] ` + `--- reponame: repo7 tag: test:2.0 ` + `digest: sha256:51e18f508fd7125b0831ff9a22ba74cd79f0b934e77661ff72cfb54896951a06 ` + `mediatype: application/vnd.oci.image.manifest.v1+json manifests: - ` + @@ -940,10 +943,11 @@ func TestOutputFormatGQL(t *testing.T) { `lastupdated: 2023-01-01T12:00:00Z size: "528" platform: os: linux arch: amd64 variant: "" ` + `issigned: false downloadcount: 0 layers: - size: "15" ` + `digest: sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6 score: 0 ` + - `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" ` + + `history: [] vulnerabilities: maxseverity: "" ` + + `count: 0 referrers: [] artifacttype: "" signatureinfo: [] ` + `size: "528" downloadcount: 0 lastupdated: 2023-01-01T12:00:00Z description: "" ` + `issigned: false licenses: "" labels: "" title: "" source: "" documentation: "" ` + - `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: []` + `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: [] signatureinfo: []` So(strings.TrimSpace(str), ShouldEqual, expectedStr) So(err, ShouldBeNil) }) @@ -969,10 +973,12 @@ func TestOutputFormatGQL(t *testing.T) { `lastupdated: 2023-01-01T12:00:00Z size: "528" platform: os: linux arch: amd64 variant: "" ` + `issigned: false downloadcount: 0 layers: - size: "15" ` + `digest: sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6 score: 0 ` + - `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" ` + + `history: [] vulnerabilities: maxseverity: "" ` + + `count: 0 referrers: [] artifacttype: "" signatureinfo: [] ` + `size: "528" downloadcount: 0 lastupdated: 2023-01-01T12:00:00Z description: "" ` + `issigned: false licenses: "" labels: "" title: "" source: "" documentation: "" ` + - `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: [] ` + + `authors: "" vendor: "" vulnerabilities: maxseverity: "" ` + + `count: 0 referrers: [] signatureinfo: [] ` + `--- reponame: repo7 tag: test:2.0 ` + `digest: sha256:51e18f508fd7125b0831ff9a22ba74cd79f0b934e77661ff72cfb54896951a06 ` + `mediatype: application/vnd.oci.image.manifest.v1+json manifests: - ` + @@ -981,10 +987,11 @@ func TestOutputFormatGQL(t *testing.T) { `lastupdated: 2023-01-01T12:00:00Z size: "528" platform: os: linux arch: amd64 variant: "" ` + `issigned: false downloadcount: 0 layers: - size: "15" ` + `digest: sha256:b8781e8844f5b7bf6f2f8fa343de18ec471c3b278027355bc34c120585ff04f6 score: 0 ` + - `history: [] vulnerabilities: maxseverity: "" count: 0 referrers: [] artifacttype: "" ` + + `history: [] vulnerabilities: maxseverity: "" ` + + `count: 0 referrers: [] artifacttype: "" signatureinfo: [] ` + `size: "528" downloadcount: 0 lastupdated: 2023-01-01T12:00:00Z description: "" ` + `issigned: false licenses: "" labels: "" title: "" source: "" documentation: "" ` + - `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: []` + `authors: "" vendor: "" vulnerabilities: maxseverity: "" count: 0 referrers: [] signatureinfo: []` So(strings.TrimSpace(str), ShouldEqual, expectedStr) So(err, ShouldBeNil) }) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 3a9d6572..ecb5d3c9 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -313,13 +313,18 @@ func validateCacheConfig(cfg *config.Config) error { } func validateExtensionsConfig(cfg *config.Config) error { + if cfg.Extensions != nil && cfg.Extensions.Mgmt != nil { + log.Warn().Msg("The mgmt extensions configuration option has been made redundant and will be ignored.") + } + + if cfg.Extensions != nil && cfg.Extensions.APIKey != nil { + log.Warn().Msg("The apikey extension configuration will be ignored as API keys " + + "are now configurable in the HTTP settings.") + } + if cfg.Extensions != nil && cfg.Extensions.UI != nil && cfg.Extensions.UI.Enable != nil && *cfg.Extensions.UI.Enable { - if cfg.Extensions.Mgmt == nil || !*cfg.Extensions.Mgmt.Enable { - log.Warn().Err(errors.ErrBadConfig).Msg("UI functionality can't be used without mgmt extension.") - - return errors.ErrBadConfig - } - + // it would make sense to also check for mgmt and user prefs to be enabled, + // but those are both enabled by having the search and ui extensions enabled if cfg.Extensions.Search == nil || !*cfg.Extensions.Search.Enable { log.Warn().Err(errors.ErrBadConfig).Msg("UI functionality can't be used without search extension.") @@ -513,18 +518,18 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { config.Extensions.Scrub = &extconf.ScrubConfig{} } - _, ok = extMap["mgmt"] + _, ok = extMap["trust"] if ok { - // we found a config like `"extensions": {"mgmt:": {}}` - // Note: In case mgmt is not empty the config.Extensions will not be nil and we will not reach here - config.Extensions.Mgmt = &extconf.MgmtConfig{} + // we found a config like `"extensions": {"trust:": {}}` + // Note: In case trust is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.Trust = &extconf.ImageTrustConfig{} } - _, ok = extMap["apikey"] + _, ok = extMap["ui"] if ok { - // we found a config like `"extensions": {"mgmt:": {}}` - // Note: In case mgmt is not empty the config.Extensions will not be nil and we will not reach here - config.Extensions.APIKey = &extconf.APIKeyConfig{} + // we found a config like `"extensions": {"ui:": {}}` + // Note: In case UI is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.UI = &extconf.UIConfig{} } } @@ -586,18 +591,6 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { } } - if config.Extensions.Mgmt != nil { - if config.Extensions.Mgmt.Enable == nil { - config.Extensions.Mgmt.Enable = &defaultVal - } - } - - if config.Extensions.APIKey != nil { - if config.Extensions.APIKey.Enable == nil { - config.Extensions.APIKey.Enable = &defaultVal - } - } - if config.Extensions.Scrub != nil { if config.Extensions.Scrub.Enable == nil { config.Extensions.Scrub.Enable = &defaultVal @@ -607,6 +600,18 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { config.Extensions.Scrub.Interval = 24 * time.Hour //nolint: gomnd } } + + if config.Extensions.UI != nil { + if config.Extensions.UI.Enable == nil { + config.Extensions.UI.Enable = &defaultVal + } + } + + if config.Extensions.Trust != nil { + if config.Extensions.Trust.Enable == nil { + config.Extensions.Trust.Enable = &defaultVal + } + } } if !config.Storage.GC && viperInstance.Get("storage::gcdelay") == nil { @@ -663,6 +668,12 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { config.Storage.SubPaths[name] = storageConfig } } + + // if OpenID authentication is enabled, + // API Keys are also enabled in order to provide data path authentication + if config.HTTP.Auth != nil && config.HTTP.Auth.OpenID != nil { + config.HTTP.Auth.APIKey = true + } } func updateDistSpecVersion(config *config.Config) { diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index ef57e7ac..65aed63f 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -1083,7 +1083,7 @@ func TestVerify(t *testing.T) { } func TestValidateExtensionsConfig(t *testing.T) { - Convey("Test missing extensions for UI to work", t, func(c C) { + Convey("Legacy extensions should not error", t, func(c C) { config := config.New() tmpfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -1100,40 +1100,39 @@ func TestValidateExtensionsConfig(t *testing.T) { "level": "debug" }, "extensions": { - "ui": { - "enable": "true" - } - } - }`) - err = os.WriteFile(tmpfile.Name(), content, 0o0600) - So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) - So(err, ShouldNotBeNil) - }) - - Convey("Test missing extensions for UI to work", t, func(c C) { - config := config.New() - tmpfile, err := os.CreateTemp("", "zot-test*.json") - So(err, ShouldBeNil) - defer os.Remove(tmpfile.Name()) - - content := []byte(`{ - "storage": { - "rootDirectory": "%/tmp/zot" - }, - "http": { - "address": "127.0.0.1", - "port": "8080" - }, - "log": { - "level": "debug" - }, - "extensions": { - "ui": { - "enable": "true" - }, "mgmt": { "enable": "true" + }, + "apikey": { + "enable": "true" + } + } + }`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + err = cli.LoadConfiguration(config, tmpfile.Name()) + So(err, ShouldBeNil) + }) + + Convey("Test missing extensions for UI to work", t, func(c C) { + config := config.New() + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) + content := []byte(`{ + "storage": { + "rootDirectory": "%/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080" + }, + "log": { + "level": "debug" + }, + "extensions": { + "ui": { + "enable": "true" } } }`) @@ -1143,7 +1142,7 @@ func TestValidateExtensionsConfig(t *testing.T) { So(err, ShouldNotBeNil) }) - Convey("Test missing mgmt extension for UI to work", t, func(c C) { + Convey("Test enabling UI extension with all prerequisites", t, func(c C) { config := config.New() tmpfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -1172,7 +1171,165 @@ func TestValidateExtensionsConfig(t *testing.T) { err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) err = cli.LoadConfiguration(config, tmpfile.Name()) - So(err, ShouldNotBeNil) + So(err, ShouldBeNil) + }) + + Convey("Test extension are implicitly enabled", t, func(c C) { + config := config.New() + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) + + content := []byte(`{ + "storage": { + "rootDirectory": "%/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080" + }, + "log": { + "level": "debug" + }, + "extensions": { + "ui": {}, + "search": {}, + "metrics": {}, + "trust": {}, + "scrub": {} + } + }`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + err = cli.LoadConfiguration(config, tmpfile.Name()) + So(err, ShouldBeNil) + So(config.Extensions.UI, ShouldNotBeNil) + So(*config.Extensions.UI.Enable, ShouldBeTrue) + So(config.Extensions.Search, ShouldNotBeNil) + So(*config.Extensions.Search.Enable, ShouldBeTrue) + So(config.Extensions.Trust, ShouldNotBeNil) + So(*config.Extensions.Trust.Enable, ShouldBeTrue) + So(*config.Extensions.Metrics, ShouldNotBeNil) + So(*config.Extensions.Metrics.Enable, ShouldBeTrue) + So(config.Extensions.Scrub, ShouldNotBeNil) + So(*config.Extensions.Scrub.Enable, ShouldBeTrue) + }) +} + +func TestApiKeyConfig(t *testing.T) { + Convey("Test API Keys are enabled if OpenID is enabled", t, func(c C) { + config := config.New() + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) + + content := []byte(`{"distSpecVersion":"1.1.0-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "auth":{"openid":{"providers":{"dex":{"issuer":"http://127.0.0.1:5556/dex", + "clientid":"client_id","scopes":["openid"]}}}}}, + "log":{"level":"debug"}}`) + + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + err = cli.LoadConfiguration(config, tmpfile.Name()) + So(err, ShouldBeNil) + So(config.HTTP.Auth, ShouldNotBeNil) + So(config.HTTP.Auth.APIKey, ShouldBeTrue) + }) + + Convey("Test API Keys are not enabled by default", t, func(c C) { + config := config.New() + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) + + content := []byte(`{"distSpecVersion":"1.1.0-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot"}, + "log":{"level":"debug"}}`) + + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + err = cli.LoadConfiguration(config, tmpfile.Name()) + So(err, ShouldBeNil) + So(config.HTTP.Auth, ShouldNotBeNil) + So(config.HTTP.Auth.APIKey, ShouldBeFalse) + }) + + Convey("Test API Keys are not enabled if OpenID is not enabled", t, func(c C) { + config := config.New() + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) + + content := []byte(`{"distSpecVersion":"1.1.0-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "auth":{"htpasswd":{"path":"test/data/htpasswd"}}}, + "log":{"level":"debug"}}`) + + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + err = cli.LoadConfiguration(config, tmpfile.Name()) + So(err, ShouldBeNil) + So(config.HTTP.Auth, ShouldNotBeNil) + So(config.HTTP.Auth.APIKey, ShouldBeFalse) + }) +} + +func TestServeAPIKey(t *testing.T) { + oldArgs := os.Args + + defer func() { os.Args = oldArgs }() + + Convey("apikey implicitly enabled", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "auth": { + "apikey": true + } + }, + "log": { + "level": "debug", + "output": "%s" + } + }` + + logPath, err := runCLIWithConfig(t.TempDir(), content) + So(err, ShouldBeNil) + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + defer os.Remove(logPath) // clean up + So(string(data), ShouldContainSubstring, "\"APIKey\":true") + }) + + Convey("apikey disabled", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "auth": { + "apikey": false + } + }, + "log": { + "level": "debug", + "output": "%s" + } + }` + + logPath, err := runCLIWithConfig(t.TempDir(), content) + So(err, ShouldBeNil) + data, err := os.ReadFile(logPath) + So(err, ShouldBeNil) + defer os.Remove(logPath) // clean up + So(string(data), ShouldContainSubstring, "\"APIKey\":false") }) } @@ -1557,3 +1714,44 @@ func TestScrub(t *testing.T) { }) }) } + +// run cli and return output. +func runCLIWithConfig(tempDir string, config string) (string, error) { + port := GetFreePort() + baseURL := GetBaseURL(port) + + logFile, err := os.CreateTemp(tempDir, "zot-log*.txt") + if err != nil { + return "", err + } + + cfgfile, err := os.CreateTemp(tempDir, "zot-test*.json") + if err != nil { + return "", err + } + + config = fmt.Sprintf(config, tempDir, port, logFile.Name()) + + _, err = cfgfile.Write([]byte(config)) + if err != nil { + return "", err + } + + err = cfgfile.Close() + if err != nil { + return "", err + } + + os.Args = []string{"cli_test", "serve", cfgfile.Name()} + + go func() { + err = cli.NewServerRootCmd().Execute() + if err != nil { + panic(err) + } + }() + + WaitTillServerReady(baseURL) + + return logFile.Name(), nil +} diff --git a/pkg/common/http_server.go b/pkg/common/http_server.go index 2923d670..c09d2911 100644 --- a/pkg/common/http_server.go +++ b/pkg/common/http_server.go @@ -13,6 +13,7 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" apiErr "zotregistry.io/zot/pkg/api/errors" + localCtx "zotregistry.io/zot/pkg/requestcontext" ) func AllowedMethods(methods ...string) []string { @@ -29,7 +30,7 @@ func AddExtensionSecurityHeaders() mux.MiddlewareFunc { //nolint:varnamelen } } -func ACHeadersHandler(config *config.Config, allowedMethods ...string) mux.MiddlewareFunc { +func ACHeadersMiddleware(config *config.Config, allowedMethods ...string) mux.MiddlewareFunc { allowedMethodsValue := strings.Join(allowedMethods, ",") return func(next http.Handler) http.Handler { @@ -50,6 +51,54 @@ func ACHeadersHandler(config *config.Config, allowedMethods ...string) mux.Middl } } +func CORSHeadersMiddleware(allowOrigin string) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + AddCORSHeaders(allowOrigin, response) + + next.ServeHTTP(response, request) + }) + } +} + +func AddCORSHeaders(allowOrigin string, response http.ResponseWriter) { + if allowOrigin == "" { + response.Header().Set("Access-Control-Allow-Origin", "*") + } else { + response.Header().Set("Access-Control-Allow-Origin", allowOrigin) + } +} + +// AuthzOnlyAdminsMiddleware permits only admin user access if auth is enabled. +func AuthzOnlyAdminsMiddleware(conf *config.Config) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if !conf.IsBasicAuthnEnabled() { + next.ServeHTTP(response, request) + + return + } + + // get acCtx built in previous authn/authz middlewares + acCtx, err := localCtx.GetAccessControlContext(request.Context()) + if err != nil { // should not happen as this has been previously checked for errors + AuthzFail(response, request, conf.HTTP.Realm, conf.HTTP.Auth.FailDelay) + + return + } + + // reject non-admin access if authentication is enabled + if acCtx != nil && !acCtx.IsAdmin { + AuthzFail(response, request, conf.HTTP.Realm, conf.HTTP.Auth.FailDelay) + + return + } + + next.ServeHTTP(response, request) + }) + } +} + func AuthzFail(w http.ResponseWriter, r *http.Request, realm string, delay int) { time.Sleep(time.Duration(delay) * time.Second) diff --git a/pkg/common/model.go b/pkg/common/model.go index f0d4c685..cbb949fa 100644 --- a/pkg/common/model.go +++ b/pkg/common/model.go @@ -52,6 +52,7 @@ type ImageSummary struct { Vendor string `json:"vendor"` Vulnerabilities ImageVulnerabilitySummary `json:"vulnerabilities"` Referrers []Referrer `json:"referrers"` + SignatureInfo []SignatureSummary `json:"signatureInfo"` } type ManifestSummary struct { @@ -67,6 +68,13 @@ type ManifestSummary struct { Vulnerabilities ImageVulnerabilitySummary `json:"vulnerabilities"` Referrers []Referrer `json:"referrers"` ArtifactType string `json:"artifactType"` + SignatureInfo []SignatureSummary `json:"signatureInfo"` +} + +type SignatureSummary struct { + Tool string `json:"tool"` + IsTrusted bool `json:"isTrusted"` + Author string `json:"author"` } type Platform struct { diff --git a/pkg/debug/gqlplayground/gqlplayground.go b/pkg/debug/gqlplayground/gqlplayground.go index 3ce638ea..48e445c3 100644 --- a/pkg/debug/gqlplayground/gqlplayground.go +++ b/pkg/debug/gqlplayground/gqlplayground.go @@ -10,7 +10,6 @@ import ( "github.com/gorilla/mux" - "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" debugCst "zotregistry.io/zot/pkg/debug/constants" "zotregistry.io/zot/pkg/log" @@ -21,7 +20,7 @@ import ( var playgroundHTML embed.FS // SetupGQLPlaygroundRoutes ... -func SetupGQLPlaygroundRoutes(conf *config.Config, router *mux.Router, +func SetupGQLPlaygroundRoutes(router *mux.Router, storeController storage.StoreController, l log.Logger, ) { log := log.Logger{Logger: l.With().Caller().Timestamp().Logger()} diff --git a/pkg/debug/gqlplayground/gqlplayground_disabled.go b/pkg/debug/gqlplayground/gqlplayground_disabled.go index ba6dfd9a..2714e2a4 100644 --- a/pkg/debug/gqlplayground/gqlplayground_disabled.go +++ b/pkg/debug/gqlplayground/gqlplayground_disabled.go @@ -6,13 +6,12 @@ package debug import ( "github.com/gorilla/mux" - "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" "zotregistry.io/zot/pkg/storage" ) // SetupGQLPlaygroundRoutes ... -func SetupGQLPlaygroundRoutes(conf *config.Config, router *mux.Router, +func SetupGQLPlaygroundRoutes(router *mux.Router, storeController storage.StoreController, log log.Logger, ) { log.Warn().Msg("skipping enabling graphql playground extension because given zot binary " + diff --git a/pkg/extensions/README_apikey.md b/pkg/extensions/README_apikey.md deleted file mode 100644 index c8ad0fb2..00000000 --- a/pkg/extensions/README_apikey.md +++ /dev/null @@ -1,66 +0,0 @@ -# `API keys` - -zot allows authentication for REST API calls using your API key as an alternative to your password. - -* User can create/revoke his API key. - -* Can not be retrieved, it is shown to the user only the first time is created. - -* An API key has the same rights as the user who generated it. - -## API keys REST API - - -### Create API Key -**Description**: Create an API key for the current user. - -**Usage**: POST /v2/_zot/ext/apikey - -**Produces**: application/json - -**Sample input**: -``` -POST /api/security/apiKey -Body: {"label": "git", "scopes": ["repo1", "repo2"]}' -``` - -**Example cURL** -``` -curl -u user:password -X POST http://localhost:8080/v2/_zot/ext/apikey -d '{"label": "myLabel", "scopes": ["repo1", "repo2"]}' -``` - -**Sample output**: -```json -{ - "createdAt": "2023-05-05T15:39:28.420926+03:00", - "creatorUa": "curl/7.68.0", - "generatedBy": "manual", - "lastUsed": "2023-05-05T15:39:28.4209282+03:00", - "label": "git", - "scopes": [ - "repo1", - "repo2" - ], - "uuid": "46a45ce7-5d92-498a-a9cb-9654b1da3da1", - "apiKey": "zak_e77bcb9e9f634f1581756abbf9ecd269" -} -``` - -**Using API keys cURL** -``` -curl -u user:zak_e77bcb9e9f634f1581756abbf9ecd269 http://localhost:8080/v2/_catalog -``` - - -### Revoke API Key -**Description**: Revokes one current user API key by api key UUID - -**Usage**: DELETE /api/security/apiKey?id=$uuid - -**Produces**: application/json - - -**Example cURL** -``` -curl -u user:password -X DELETE http://localhost:8080/v2/_zot/ext/apikey?id=46a45ce7-5d92-498a-a9cb-9654b1da3da1 -``` diff --git a/pkg/meta/signatures/README.md b/pkg/extensions/README_imagetrust.md similarity index 50% rename from pkg/meta/signatures/README.md rename to pkg/extensions/README_imagetrust.md index 26ed4220..03006593 100644 --- a/pkg/meta/signatures/README.md +++ b/pkg/extensions/README_imagetrust.md @@ -1,48 +1,77 @@ -# Verifying signatures +# Image Trust + +The `imagetrust` extension provides a mechanism to verify image signatures using certificates and public keys ## How to configure zot for verifying signatures -In order to configure zot for verifying signatures, the user should provide: +In order to configure zot for verifying signatures, the user should first enable this feature: -1. public keys (which correspond to the private keys used to sign images with `cosign`) +```json + "extensions": { + "trust": { + "enable": true, + "cosign": true, + "notation": true + } + } +``` -or +In order for verification to run, the user needs to enable at least one of the cosign or notation options above. -2. certificates (used to sign images with `notation`) +## Uploading public keys or certificates -These files could be uploaded using one of these requests: +Next the user needs to upload the keys or certificates used for the verification. -1. upload a public key +| Supported queries | Input | Output | Description | +| --- | --- | --- | --- | +| Upload a certificate | certificate | None | Add certificate for verifying notation signatures| +| Upload a public key | public key | None | Add public key for verifying cosign signatures | - ***Example of request*** - ``` - curl --data-binary @file.pub -X POST "http://localhost:8080/v2/_zot/ext/mgmt?resource=signatures&tool=cosign" - ``` +### Uploading a Cosign public key -2. upload a certificate +The Cosign public keys uploaded correspond to the private keys used to sign images with `cosign`. - ***Example of request*** - ``` - curl --data-binary @filet.crt -X POST "http://localhost:8080/v2/_zot/ext/mgmt?resource=signatures&tool=notation&truststoreType=ca&truststoreName=upload-cert" - ``` +***Example of request*** -Besides the requested files, the user should also specify the `tool` which should be : - -- `cosign` for uploading public keys -- `notation` for uploading certificates +```bash +curl --data-binary @file.pub -X POST "http://localhost:8080/v2/_zot/ext/cosign +``` - Also, if the uploaded file is a certificate then the user should also specify the type of the truststore through `truststoreType` param and also its name through `truststoreName` param. +As a result of this request, the uploaded file will be stored in `_cosign` directory +under the rootDir specified in the zot config. + +### Uploading a Notation certificate + +Notation certificates are used to sign images with the `notation` tool. +The user needs to specify the type of the truststore through the `truststoreType` +query parameter and its name through the `truststoreName` parameter. +`truststoreType` defaults to `ca`, while `truststoreName` is a mandatory parameter. + +***Example of request*** + +```bash +curl --data-binary @certificate.crt -X POST "http://localhost:8080/v2/_zot/ext/notation?truststoreType=ca&truststoreName=upload-cert" +``` + +As a result of this request, the uploaded file will be stored in `_notation/truststore/x509/{truststoreType}/{truststoreName}` +directory under the rootDir specified in the zot config. +The `truststores` field found in `_notation/trustpolicy.json` file will be updated automatically as well. + +## Verification and results + +Based on the uploaded files, signatures verification will be performed for all the signed images. +The information determined about the signatures will be: - Based on the uploaded files, signatures verification will be performed for all the signed images. Then the information known about the signatures will be: - - the tool used to generate the signature (`cosign` or `notation`) - info about the trustworthiness of the signature (if there is a certificate or a public key which can successfully verify the signature) - the author of the signature which will be: - - - the public key -> for signatures generated using `cosign` - - the subject of the certificate -> for signatures generated using `notation` -**Example of GraphQL output** + - the public key -> for signatures generated using `cosign` + - the subject of the certificate -> for signatures generated using `notation` + +The information above will be included in the ManifestSummary objects returned by the `search` extension. + +***Example of GraphQL output*** ```json { @@ -90,12 +119,13 @@ Besides the requested files, the user should also specify the `tool` which shoul ## Notes - The files (public keys and certificates) uploaded using the exposed routes will be stored in some specific directories called `_cosign` and `_notation` under `$rootDir`. - + - `_cosign` directory will contain the uploaded public keys + ``` _cosign ├── $publicKey1 - └── $publicKey2 + └── $publicKey2 ``` - `_notation` directory will have this structure: @@ -103,15 +133,16 @@ Besides the requested files, the user should also specify the `tool` which shoul ``` _notation ├── trustpolicy.json - └── truststore - └── x509 - └── $truststoreType - └── $truststoreName - └── $certificate + └── truststore + └── x509 + └── $truststoreType + └── $truststoreName + └── $certificate ``` where `trustpolicy.json` file has this default content which can not be modified by the user and which is updated each time a new certificate is added to a new truststore: - ``` + + ```json { "version": "1.0", "trustPolicies": [ @@ -127,6 +158,5 @@ Besides the requested files, the user should also specify the `tool` which shoul ] } ] - } + } ``` - diff --git a/pkg/extensions/README_mgmt.md b/pkg/extensions/README_mgmt.md index 9d0421ed..4bb6a969 100644 --- a/pkg/extensions/README_mgmt.md +++ b/pkg/extensions/README_mgmt.md @@ -10,12 +10,6 @@ Response depends on the user privileges: | Supported queries | Input | Output | Description | | --- | --- | --- | --- | | [Get current configuration](#get-current-configuration) | None | config json | Get current zot configuration | -| [Upload a certificate](#post-certificate) | certificate | None | Add certificate for verifying notation signatures| -| [Upload a public key](#post-public-key) | public key | None | Add public key for verifying cosign signatures | - -## General usage -The mgmt endpoint accepts as a query parameter what `resource` is targeted by the request and then all other required parameters for the specified resource. The default value of this -query parameter is `config`. ## Get current configuration @@ -46,35 +40,3 @@ curl http://localhost:8080/v2/_zot/ext/mgmt | jq If ldap or htpasswd are enabled mgmt will return `{"htpasswd": {}}` indicating that clients can authenticate with basic auth credentials. If any key is present under `'auth'` key, in the mgmt response, it means that particular authentication method is enabled. - -## Configure zot for verifying signatures -If the `resource` is `signatures` then the mgmt endpoint accepts as a query parameter the `tool` that corresponds to the uploaded file and then all other required parameters for the specified tool. - -### Upload a certificate - -**Sample request** - -| Tool | Parameter | Parameter Type | Parameter Description | -| --- | --- | --- | --- | -| notation | truststoreType | string | The type of the truststore. This parameter is optional and its default value is `ca` | -| | truststoreName | string | The name of the truststore | - -```bash -curl --data-binary @certificate.crt -X POST http://localhost:8080/v2/_zot/ext/mgmt?resource=signature&tool=notation&truststoreType=ca&truststoreName=newtruststore -``` -As a result of this request, the uploaded file will be stored in `_notation/truststore/x509/{truststoreType}/{truststoreName}` directory under $rootDir. And `truststores` field from `_notation/trustpolicy.json` file will be updated. - -### Upload a public key - -**Sample request** - -| Tool | Parameter | Parameter Type | Parameter Description | -| --- | --- | --- | --- | -| cosign | - - -```bash -curl --data-binary @publicKey.pub -X POST http://localhost:8080/v2/_zot/ext/mgmt?resource=signature&tool=cosign -``` - -As a result of this request, the uploaded file will be stored in `_cosign` directory under $rootDir. diff --git a/pkg/extensions/userprefs.md b/pkg/extensions/README_userprefs.md similarity index 100% rename from pkg/extensions/userprefs.md rename to pkg/extensions/README_userprefs.md diff --git a/pkg/extensions/_zot.md b/pkg/extensions/_zot.md index 16c4d974..c675a555 100644 --- a/pkg/extensions/_zot.md +++ b/pkg/extensions/_zot.md @@ -6,9 +6,10 @@ Component | Endpoint | Description --- | --- | --- [`search`](search/search.md) | `/v2/_zot/ext/search` | efficient and enhanced registry search capabilities using graphQL backend -[`mgmt`](mgmt.md) | `/v2/_zot/ext/mgmt` | config management -[`userprefs`](userprefs.md) | `/v2/_zot/ext/userprefs` | change user preferences -[`apikey`](README_apikey.md) | `/v2/_zot/ext/apikey` | user api keys management +[`mgmt`](README_mgmt.md) | `/v2/_zot/ext/mgmt` | config management +[`userprefs`](README_userprefs.md) | `/v2/_zot/ext/userprefs` | change user preferences +[`imagetrust`](README_imagetrust.md) | `/v2/_zot/ext/cosign` | cosign public key management +[`imagetrust`](README_imagetrust.md) | `/v2/_zot/ext/notation` | notation certificate management # References diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index b13c015c..51c36f1f 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -20,6 +20,13 @@ type ExtensionConfig struct { UI *UIConfig Mgmt *MgmtConfig APIKey *APIKeyConfig + Trust *ImageTrustConfig +} + +type ImageTrustConfig struct { + BaseConfig `mapstructure:",squash"` + Cosign bool + Notation bool } type APIKeyConfig struct { diff --git a/pkg/extensions/extension_api_key.go b/pkg/extensions/extension_api_key.go deleted file mode 100644 index abcfb466..00000000 --- a/pkg/extensions/extension_api_key.go +++ /dev/null @@ -1,197 +0,0 @@ -//go:build apikey -// +build apikey - -package extensions - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - guuid "github.com/gofrs/uuid" - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - jsoniter "github.com/json-iterator/go" - godigest "github.com/opencontainers/go-digest" - - "zotregistry.io/zot/pkg/api/config" - "zotregistry.io/zot/pkg/api/constants" - zcommon "zotregistry.io/zot/pkg/common" - "zotregistry.io/zot/pkg/log" - mTypes "zotregistry.io/zot/pkg/meta/types" -) - -func SetupAPIKeyRoutes(config *config.Config, router *mux.Router, metaDB mTypes.MetaDB, - cookieStore sessions.Store, log log.Logger, -) { - if config.Extensions.APIKey != nil && *config.Extensions.APIKey.Enable { - log.Info().Msg("setting up api key routes") - - allowedMethods := zcommon.AllowedMethods(http.MethodPost, http.MethodDelete) - - apiKeyRouter := router.PathPrefix(constants.ExtAPIKey).Subrouter() - apiKeyRouter.Use(zcommon.ACHeadersHandler(config, allowedMethods...)) - apiKeyRouter.Use(zcommon.AddExtensionSecurityHeaders()) - apiKeyRouter.Methods(allowedMethods...).Handler(HandleAPIKeyRequest(metaDB, cookieStore, log)) - } -} - -type APIKeyPayload struct { //nolint:revive - Label string `json:"label"` - Scopes []string `json:"scopes"` -} - -func HandleAPIKeyRequest(metaDB mTypes.MetaDB, cookieStore sessions.Store, - log log.Logger, -) http.Handler { - return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodPost: - CreateAPIKey(resp, req, metaDB, cookieStore, log) //nolint:contextcheck - - return - case http.MethodDelete: - RevokeAPIKey(resp, req, metaDB, cookieStore, log) //nolint:contextcheck - - return - } - }) -} - -// CreateAPIKey godoc -// @Summary Create an API key for the current user -// @Description Can create an api key for a logged in user, based on the provided label and scopes. -// @Accept json -// @Produce json -// @Success 201 {string} string "created" -// @Failure 401 {string} string "unauthorized" -// @Failure 500 {string} string "internal server error" -// @Router /v2/_zot/ext/apikey [post]. -func CreateAPIKey(resp http.ResponseWriter, req *http.Request, metaDB mTypes.MetaDB, - cookieStore sessions.Store, log log.Logger, -) { - var payload APIKeyPayload - - body, err := io.ReadAll(req.Body) - if err != nil { - log.Error().Msg("unable to read request body") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - err = json.Unmarshal(body, &payload) - if err != nil { - log.Error().Err(err).Msg("unable to unmarshal body") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - apiKeyBase, err := guuid.NewV4() - if err != nil { - log.Error().Err(err).Msg("unable to generate uuid") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - apiKey := strings.ReplaceAll(apiKeyBase.String(), "-", "") - - hashedAPIKey := hashUUID(apiKey) - - // will be used for identifying a specific api key - apiKeyID, err := guuid.NewV4() - if err != nil { - log.Error().Err(err).Msg("unable to generate uuid") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - apiKeyDetails := &mTypes.APIKeyDetails{ - CreatedAt: time.Now(), - LastUsed: time.Now(), - CreatorUA: req.UserAgent(), - GeneratedBy: "manual", - Label: payload.Label, - Scopes: payload.Scopes, - UUID: apiKeyID.String(), - } - - err = metaDB.AddUserAPIKey(req.Context(), hashedAPIKey, apiKeyDetails) - if err != nil { - log.Error().Err(err).Msg("error storing API key") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - apiKeyResponse := struct { - mTypes.APIKeyDetails - APIKey string `json:"apiKey"` - }{ - APIKey: fmt.Sprintf("%s%s", constants.APIKeysPrefix, apiKey), - APIKeyDetails: *apiKeyDetails, - } - - json := jsoniter.ConfigCompatibleWithStandardLibrary - - data, err := json.Marshal(apiKeyResponse) - if err != nil { - log.Error().Err(err).Msg("unable to marshal api key response") - - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - resp.Header().Set("Content-Type", constants.DefaultMediaType) - resp.WriteHeader(http.StatusCreated) - _, _ = resp.Write(data) -} - -// RevokeAPIKey godoc -// @Summary Revokes one current user API key -// @Description Revokes one current user API key based on given key ID -// @Accept json -// @Produce json -// @Param id path string true "api token id (UUID)" -// @Success 200 {string} string "ok" -// @Failure 500 {string} string "internal server error" -// @Failure 401 {string} string "unauthorized" -// @Failure 400 {string} string "bad request" -// @Router /v2/_zot/ext/apikey?id=UUID [delete]. -func RevokeAPIKey(resp http.ResponseWriter, req *http.Request, metaDB mTypes.MetaDB, - cookieStore sessions.Store, log log.Logger, -) { - ids, ok := req.URL.Query()["id"] - if !ok || len(ids) != 1 { - resp.WriteHeader(http.StatusBadRequest) - - return - } - - keyID := ids[0] - - err := metaDB.DeleteUserAPIKey(req.Context(), keyID) - if err != nil { - log.Error().Err(err).Str("keyID", keyID).Msg("error deleting API key") - resp.WriteHeader(http.StatusInternalServerError) - - return - } - - resp.WriteHeader(http.StatusOK) -} - -func hashUUID(uuid string) string { - digester := sha256.New() - digester.Write([]byte(uuid)) - - return godigest.NewDigestFromEncoded(godigest.SHA256, fmt.Sprintf("%x", digester.Sum(nil))).Encoded() -} diff --git a/pkg/extensions/extension_api_key_disabled.go b/pkg/extensions/extension_api_key_disabled.go deleted file mode 100644 index df1e3c1f..00000000 --- a/pkg/extensions/extension_api_key_disabled.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !apikey -// +build !apikey - -package extensions - -import ( - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - - "zotregistry.io/zot/pkg/api/config" - "zotregistry.io/zot/pkg/log" - mTypes "zotregistry.io/zot/pkg/meta/types" -) - -func SetupAPIKeyRoutes(config *config.Config, router *mux.Router, metaDB mTypes.MetaDB, - cookieStore sessions.Store, log log.Logger, -) { - log.Warn().Msg("skipping setting up API key routes because given zot binary doesn't include this feature," + - "please build a binary that does so") -} diff --git a/pkg/extensions/extension_image_trust.go b/pkg/extensions/extension_image_trust.go new file mode 100644 index 00000000..19c41ceb --- /dev/null +++ b/pkg/extensions/extension_image_trust.go @@ -0,0 +1,183 @@ +//go:build imagetrust +// +build imagetrust + +package extensions + +import ( + "errors" + "io" + "net/http" + "time" + + "github.com/gorilla/mux" + + zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + zcommon "zotregistry.io/zot/pkg/common" + "zotregistry.io/zot/pkg/log" + "zotregistry.io/zot/pkg/meta/signatures" + mTypes "zotregistry.io/zot/pkg/meta/types" + "zotregistry.io/zot/pkg/scheduler" +) + +const ( + ConfigResource = "config" + SignaturesResource = "signatures" +) + +func IsBuiltWithImageTrustExtension() bool { + return true +} + +func SetupImageTrustRoutes(conf *config.Config, router *mux.Router, log log.Logger) { + if !conf.IsImageTrustEnabled() || (!conf.IsCosignEnabled() && !conf.IsNotationEnabled()) { + log.Info().Msg("skip enabling the image trust routes as the config prerequisites are not met") + + return + } + + log.Info().Msg("setting up image trust routes") + + trust := ImageTrust{Conf: conf, Log: log} + allowedMethods := zcommon.AllowedMethods(http.MethodPost) + + if conf.IsNotationEnabled() { + log.Info().Msg("setting up notation route") + + notationRouter := router.PathPrefix(constants.ExtNotation).Subrouter() + notationRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + notationRouter.Use(zcommon.AddExtensionSecurityHeaders()) + notationRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + // The endpoints for uploading signatures should be available only to admins + notationRouter.Use(zcommon.AuthzOnlyAdminsMiddleware(conf)) + notationRouter.Methods(allowedMethods...).HandlerFunc(trust.HandleNotationCertificateUpload) + } + + if conf.IsCosignEnabled() { + log.Info().Msg("setting up cosign route") + + cosignRouter := router.PathPrefix(constants.ExtCosign).Subrouter() + cosignRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + cosignRouter.Use(zcommon.AddExtensionSecurityHeaders()) + cosignRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + // The endpoints for uploading signatures should be available only to admins + cosignRouter.Use(zcommon.AuthzOnlyAdminsMiddleware(conf)) + cosignRouter.Methods(allowedMethods...).HandlerFunc(trust.HandleCosignPublicKeyUpload) + } + + log.Info().Msg("finished setting up image trust routes") +} + +type ImageTrust struct { + Conf *config.Config + Log log.Logger +} + +// Cosign handler godoc +// @Summary Upload cosign public keys for verifying signatures +// @Description Upload cosign public keys for verifying signatures +// @Router /v2/_zot/ext/cosign [post] +// @Accept octet-stream +// @Produce json +// @Param requestBody body string true "Public key content" +// @Success 200 {string} string "ok" +// @Failure 400 {string} string "bad request". +// @Failure 500 {string} string "internal server error". +func (trust *ImageTrust) HandleCosignPublicKeyUpload(response http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + trust.Log.Error().Err(err).Msg("image trust: couldn't read cosign key body") + response.WriteHeader(http.StatusInternalServerError) + + return + } + + err = signatures.UploadPublicKey(body) + if err != nil { + if errors.Is(err, zerr.ErrInvalidPublicKeyContent) { + response.WriteHeader(http.StatusBadRequest) + } else { + trust.Log.Error().Err(err).Msg("image trust: failed to save cosign key") + response.WriteHeader(http.StatusInternalServerError) + } + + return + } + + response.WriteHeader(http.StatusOK) +} + +// Notation handler godoc +// @Summary Upload notation certificates for verifying signatures +// @Description Upload notation certificates for verifying signatures +// @Router /v2/_zot/ext/notation [post] +// @Accept octet-stream +// @Produce json +// @Param truststoreType query string false "truststore type" +// @Param truststoreName query string false "truststore name" +// @Param requestBody body string true "Certificate content" +// @Success 200 {string} string "ok" +// @Failure 400 {string} string "bad request". +// @Failure 500 {string} string "internal server error". +func (trust *ImageTrust) HandleNotationCertificateUpload(response http.ResponseWriter, request *http.Request) { + var truststoreType string + + if !zcommon.QueryHasParams(request.URL.Query(), []string{"truststoreName"}) { + response.WriteHeader(http.StatusBadRequest) + + return + } + + if zcommon.QueryHasParams(request.URL.Query(), []string{"truststoreType"}) { + truststoreType = request.URL.Query().Get("truststoreType") + } else { + truststoreType = "ca" // default value of "truststoreType" query param + } + + truststoreName := request.URL.Query().Get("truststoreName") + + if truststoreType == "" || truststoreName == "" { + response.WriteHeader(http.StatusBadRequest) + + return + } + + body, err := io.ReadAll(request.Body) + if err != nil { + trust.Log.Error().Err(err).Msg("image trust: couldn't read notation certificate body") + response.WriteHeader(http.StatusInternalServerError) + + return + } + + err = signatures.UploadCertificate(body, truststoreType, truststoreName) + if err != nil { + if errors.Is(err, zerr.ErrInvalidTruststoreType) || + errors.Is(err, zerr.ErrInvalidTruststoreName) || + errors.Is(err, zerr.ErrInvalidCertificateContent) { + response.WriteHeader(http.StatusBadRequest) + } else { + trust.Log.Error().Err(err).Msg("image trust: failed to save notation certificate") + response.WriteHeader(http.StatusInternalServerError) + } + + return + } + + response.WriteHeader(http.StatusOK) +} + +func EnableImageTrustVerification(conf *config.Config, taskScheduler *scheduler.Scheduler, + metaDB mTypes.MetaDB, log log.Logger, +) { + if !conf.IsImageTrustEnabled() { + return + } + + generator := signatures.NewTaskGenerator(metaDB, log) + + numberOfHours := 2 + interval := time.Duration(numberOfHours) * time.Minute + taskScheduler.SubmitGenerator(generator, interval, scheduler.MediumPriority) +} diff --git a/pkg/extensions/extension_image_trust_disabled.go b/pkg/extensions/extension_image_trust_disabled.go new file mode 100644 index 00000000..0e123563 --- /dev/null +++ b/pkg/extensions/extension_image_trust_disabled.go @@ -0,0 +1,29 @@ +//go:build !imagetrust +// +build !imagetrust + +package extensions + +import ( + "github.com/gorilla/mux" + + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/log" + mTypes "zotregistry.io/zot/pkg/meta/types" + "zotregistry.io/zot/pkg/scheduler" +) + +func IsBuiltWithImageTrustExtension() bool { + return false +} + +func SetupImageTrustRoutes(config *config.Config, router *mux.Router, log log.Logger) { + log.Warn().Msg("skipping setting up image trust routes because given zot binary doesn't include this feature," + + "please build a binary that does so") +} + +func EnableImageTrustVerification(config *config.Config, taskScheduler *scheduler.Scheduler, + metaDB mTypes.MetaDB, log log.Logger, +) { + log.Warn().Msg("skipping adding to the scheduler a generator for updating signatures validity because " + + "given binary doesn't include this feature, please build a binary that does so") +} diff --git a/pkg/extensions/extension_mgmt_disabled_test.go b/pkg/extensions/extension_image_trust_disabled_test.go similarity index 79% rename from pkg/extensions/extension_mgmt_disabled_test.go rename to pkg/extensions/extension_image_trust_disabled_test.go index 15a03f10..e197c9a6 100644 --- a/pkg/extensions/extension_mgmt_disabled_test.go +++ b/pkg/extensions/extension_image_trust_disabled_test.go @@ -1,4 +1,4 @@ -//go:build !mgmt +//go:build !imagetrust package extensions_test @@ -14,8 +14,8 @@ import ( "zotregistry.io/zot/pkg/test" ) -func TestMgmtExtension(t *testing.T) { - Convey("periodic signature verification is skipped when binary doesn't include mgmt", t, func() { +func TestImageTrustExtension(t *testing.T) { + Convey("periodic signature verification is skipped when binary doesn't include imagetrust", t, func() { conf := config.New() port := test.GetFreePort() @@ -30,11 +30,10 @@ func TestMgmtExtension(t *testing.T) { conf.Storage.RootDirectory = globalDir conf.Storage.Commit = true conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Cosign = defaultValue + conf.Extensions.Trust.Notation = defaultValue conf.Log.Level = "warn" conf.Log.Output = logFile.Name() diff --git a/pkg/extensions/extension_image_trust_test.go b/pkg/extensions/extension_image_trust_test.go new file mode 100644 index 00000000..0b33b161 --- /dev/null +++ b/pkg/extensions/extension_image_trust_test.go @@ -0,0 +1,958 @@ +//go:build search && imagetrust +// +build search,imagetrust + +package extensions_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "testing" + "time" + + "github.com/sigstore/cosign/v2/cmd/cosign/cli/generate" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/resty.v1" + + "zotregistry.io/zot/pkg/api" + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + zcommon "zotregistry.io/zot/pkg/common" + "zotregistry.io/zot/pkg/extensions" + extconf "zotregistry.io/zot/pkg/extensions/config" + "zotregistry.io/zot/pkg/extensions/monitoring" + "zotregistry.io/zot/pkg/log" + "zotregistry.io/zot/pkg/storage" + "zotregistry.io/zot/pkg/storage/local" + "zotregistry.io/zot/pkg/test" +) + +type errReader int + +func (errReader) Read(p []byte) (int, error) { + return 0, fmt.Errorf("test error") //nolint:goerr113 +} + +func TestSignatureHandlers(t *testing.T) { + conf := config.New() + log := log.NewLogger("debug", "") + + trust := extensions.ImageTrust{ + Conf: conf, + Log: log, + } + + Convey("Test error handling when Cosign handler reads the request body", t, func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, "baseURL", errReader(0)) + response := httptest.NewRecorder() + + trust.HandleCosignPublicKeyUpload(response, request) + + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) + + Convey("Test error handling when Notation handler reads the request body", t, func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, "baseURL", errReader(0)) + query := request.URL.Query() + query.Add("truststoreName", "someName") + request.URL.RawQuery = query.Encode() + + response := httptest.NewRecorder() + trust.HandleNotationCertificateUpload(response, request) + + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) +} + +func TestSignaturesAllowedMethodsHeader(t *testing.T) { + defaultVal := true + + Convey("Test http options response", t, func() { + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultVal + conf.Extensions.Trust.Cosign = defaultVal + conf.Extensions.Trust.Notation = defaultVal + + baseURL := test.GetBaseURL(port) + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + ctrlManager := test.NewControllerManager(ctlr) + + ctrlManager.StartAndWait(port) + defer ctrlManager.StopServer() + + resp, _ := resty.R().Options(baseURL + constants.FullCosign) + So(resp, ShouldNotBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,OPTIONS") + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + + resp, _ = resty.R().Options(baseURL + constants.FullNotation) + So(resp, ShouldNotBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,OPTIONS") + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + }) +} + +func TestSignatureUploadAndVerification(t *testing.T) { + repo := "repo" + tag := "0.0.1" + certName := "test" + defaultValue := true + imageQuery := ` + { + Image(image:"%s:%s"){ + RepoName Tag Digest IsSigned + Manifests { + Digest + SignatureInfo { Tool IsTrusted Author } + } + SignatureInfo { Tool IsTrusted Author } + } + }` + + Convey("Verify cosign public key upload without search or notation being enabled", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Cosign = defaultValue + + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + imageStore := local.NewImageStore(globalDir, false, 0, false, false, + logger, monitoring.NewMetricsServer(false, logger), nil, nil) + + storeController := storage.StoreController{ + DefaultStore: imageStore, + } + + image := test.CreateRandomImage() + err = test.WriteImageToFileSystem(image, repo, tag, storeController) + So(err, ShouldBeNil) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + // generate a keypair + keyDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(keyDir) + + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(keyDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + // upload the public key + client := resty.New() + resp, err := client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // sign the image + err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, + options.KeyOpts{KeyRef: path.Join(keyDir, "cosign.key"), PassFunc: generate.GetPass}, + options.SignOptions{ + Registry: options.RegistryOptions{AllowInsecure: true}, + AnnotationOptions: options.AnnotationOptions{Annotations: []string{fmt.Sprintf("tag=%s", tag)}}, + Upload: true, + }, + []string{fmt.Sprintf("localhost:%s/%s@%s", port, repo, image.DigestStr())}) + So(err, ShouldBeNil) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", + time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody([]byte("wrong content")).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Get(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + + resp, err = client.R().Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + }) + + Convey("Verify notation certificate upload without search or cosign being enabled", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Notation = defaultValue + + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + imageStore := local.NewImageStore(globalDir, false, 0, false, false, + logger, monitoring.NewMetricsServer(false, logger), nil, nil) + + storeController := storage.StoreController{ + DefaultStore: imageStore, + } + + image := test.CreateRandomImage() + err = test.WriteImageToFileSystem(image, repo, tag, storeController) + So(err, ShouldBeNil) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err = test.GenerateNotationCerts(rootDir, certName) + So(err, ShouldBeNil) + + // upload the certificate + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", fmt.Sprintf("%s.crt", certName))) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + client := resty.New() + resp, err := client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", certName). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // sign the image + imageURL := fmt.Sprintf("localhost:%s/%s", port, fmt.Sprintf("%s:%s", repo, tag)) + + err = test.SignWithNotation(certName, imageURL, rootDir) + So(err, ShouldBeNil) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", + time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", ""). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", "test"). + SetQueryParam("truststoreType", "signatureAuthority"). + SetBody([]byte("wrong content")).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Get(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + + resp, err = client.R().Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + }) + + Convey("Verify uploading notation certificates", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Notation = defaultValue + + baseURL := test.GetBaseURL(port) + gqlEndpoint := fmt.Sprintf("%s%s?query=", baseURL, constants.FullSearchPrefix) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + imageStore := local.NewImageStore(globalDir, false, 0, false, false, + logger, monitoring.NewMetricsServer(false, logger), nil, nil) + + storeController := storage.StoreController{ + DefaultStore: imageStore, + } + + image := test.CreateRandomImage() + err = test.WriteImageToFileSystem(image, repo, tag, storeController) + So(err, ShouldBeNil) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + strQuery := fmt.Sprintf(imageQuery, repo, tag) + gqlTargetURL := fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + // Verify the image is initially shown as not being signed + resp, err := resty.R().Get(gqlTargetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + imgSummaryResponse := zcommon.ImageSummaryResult{} + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.ImageSummary, ShouldNotBeNil) + imgSummary := imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repo) + So(imgSummary.Tag, ShouldContainSubstring, tag) + So(imgSummary.Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.Manifests[0].Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.IsSigned, ShouldEqual, false) + So(imgSummary.SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.SignatureInfo), ShouldEqual, 0) + So(imgSummary.Manifests[0].SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.Manifests[0].SignatureInfo), ShouldEqual, 0) + + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err = test.GenerateNotationCerts(rootDir, certName) + So(err, ShouldBeNil) + + // upload the certificate + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", fmt.Sprintf("%s.crt", certName))) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + client := resty.New() + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", certName). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // sign the image + imageURL := fmt.Sprintf("localhost:%s/%s", port, fmt.Sprintf("%s:%s", repo, tag)) + + err = test.SignWithNotation(certName, imageURL, rootDir) + So(err, ShouldBeNil) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", + time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + // verify the image is shown as signed and trusted + resp, err = resty.R().Get(gqlTargetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + imgSummaryResponse = zcommon.ImageSummaryResult{} + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.ImageSummary, ShouldNotBeNil) + imgSummary = imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repo) + So(imgSummary.Tag, ShouldContainSubstring, tag) + So(imgSummary.Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.Manifests[0].Digest, ShouldContainSubstring, image.Digest().Encoded()) + t.Log(imgSummary.SignatureInfo) + So(imgSummary.IsSigned, ShouldEqual, true) + So(imgSummary.SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.SignatureInfo), ShouldEqual, 1) + So(imgSummary.SignatureInfo[0].IsTrusted, ShouldEqual, true) + So(imgSummary.SignatureInfo[0].Tool, ShouldEqual, "notation") + So(imgSummary.SignatureInfo[0].Author, + ShouldEqual, "CN=cert,O=Notary,L=Seattle,ST=WA,C=US") + So(imgSummary.Manifests[0].SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.Manifests[0].SignatureInfo), ShouldEqual, 1) + t.Log(imgSummary.Manifests[0].SignatureInfo) + So(imgSummary.Manifests[0].SignatureInfo[0].IsTrusted, ShouldEqual, true) + So(imgSummary.Manifests[0].SignatureInfo[0].Tool, ShouldEqual, "notation") + So(imgSummary.Manifests[0].SignatureInfo[0].Author, + ShouldEqual, "CN=cert,O=Notary,L=Seattle,ST=WA,C=US") + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", ""). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", "test"). + SetQueryParam("truststoreType", "signatureAuthority"). + SetBody([]byte("wrong content")).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Get(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + + resp, err = client.R().Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + + Convey("Verify uploading cosign public keys", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Cosign = defaultValue + + baseURL := test.GetBaseURL(port) + gqlEndpoint := fmt.Sprintf("%s%s?query=", baseURL, constants.FullSearchPrefix) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + imageStore := local.NewImageStore(globalDir, false, 0, false, false, + logger, monitoring.NewMetricsServer(false, logger), nil, nil) + + storeController := storage.StoreController{ + DefaultStore: imageStore, + } + + image := test.CreateRandomImage() + err = test.WriteImageToFileSystem(image, repo, tag, storeController) + So(err, ShouldBeNil) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + strQuery := fmt.Sprintf(imageQuery, repo, tag) + gqlTargetURL := fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + // Verify the image is initially shown as not being signed + resp, err := resty.R().Get(gqlTargetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + imgSummaryResponse := zcommon.ImageSummaryResult{} + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.ImageSummary, ShouldNotBeNil) + imgSummary := imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repo) + So(imgSummary.Tag, ShouldContainSubstring, tag) + So(imgSummary.Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.Manifests[0].Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.IsSigned, ShouldEqual, false) + So(imgSummary.SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.SignatureInfo), ShouldEqual, 0) + So(imgSummary.Manifests[0].SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.Manifests[0].SignatureInfo), ShouldEqual, 0) + + // generate a keypair + keyDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(keyDir) + + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(keyDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + // upload the public key + client := resty.New() + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // sign the image + err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, + options.KeyOpts{KeyRef: path.Join(keyDir, "cosign.key"), PassFunc: generate.GetPass}, + options.SignOptions{ + Registry: options.RegistryOptions{AllowInsecure: true}, + AnnotationOptions: options.AnnotationOptions{Annotations: []string{fmt.Sprintf("tag=%s", tag)}}, + Upload: true, + }, + []string{fmt.Sprintf("localhost:%s/%s@%s", port, repo, image.DigestStr())}) + So(err, ShouldBeNil) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", + time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + // verify the image is shown as signed and trusted + resp, err = resty.R().Get(gqlTargetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + imgSummaryResponse = zcommon.ImageSummaryResult{} + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.ImageSummary, ShouldNotBeNil) + imgSummary = imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repo) + So(imgSummary.Tag, ShouldContainSubstring, tag) + So(imgSummary.Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.Manifests[0].Digest, ShouldContainSubstring, image.Digest().Encoded()) + t.Log(imgSummary.SignatureInfo) + So(imgSummary.SignatureInfo, ShouldNotBeNil) + So(imgSummary.IsSigned, ShouldEqual, true) + So(len(imgSummary.SignatureInfo), ShouldEqual, 1) + So(imgSummary.SignatureInfo[0].IsTrusted, ShouldEqual, true) + So(imgSummary.SignatureInfo[0].Tool, ShouldEqual, "cosign") + So(imgSummary.SignatureInfo[0].Author, ShouldEqual, string(publicKeyContent)) + So(imgSummary.Manifests[0].SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.Manifests[0].SignatureInfo), ShouldEqual, 1) + t.Log(imgSummary.Manifests[0].SignatureInfo) + So(imgSummary.Manifests[0].SignatureInfo[0].IsTrusted, ShouldEqual, true) + So(imgSummary.Manifests[0].SignatureInfo[0].Tool, ShouldEqual, "cosign") + So(imgSummary.Manifests[0].SignatureInfo[0].Author, ShouldEqual, string(publicKeyContent)) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody([]byte("wrong content")).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R().Get(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + + resp, err = client.R().Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + + Convey("Verify uploading cosign public keys with auth configured", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + testCreds := test.GetCredString("admin", "admin") + "\n" + test.GetCredString("test", "test") + htpasswdPath := test.MakeHtpasswdFileFromString(testCreds) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth.HTPasswd.Path = htpasswdPath + conf.HTTP.AccessControl = &config.AccessControlConfig{ + AdminPolicy: config.Policy{ + Users: []string{"admin"}, + Actions: []string{}, + }, + } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Cosign = defaultValue + + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + // generate a keypair + keyDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(keyDir) + + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(keyDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + // fail to upload the public key without credentials + client := resty.New() + resp, err := client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // fail to upload the public key with bad credentials + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // upload the public key using credentials and non-admin user + resp, err = client.R().SetBasicAuth("test", "test").SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // upload the public key using credentials and admin user + resp, err = client.R().SetBasicAuth("admin", "admin").SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + + Convey("Verify signatures are read from the disk and updated in the DB when zot starts", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Cosign = defaultValue + + baseURL := test.GetBaseURL(port) + gqlEndpoint := fmt.Sprintf("%s%s?query=", baseURL, constants.FullSearchPrefix) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + defer os.Remove(logFile.Name()) // cleanup + So(err, ShouldBeNil) + + logger := log.NewLogger("debug", logFile.Name()) + writers := io.MultiWriter(os.Stdout, logFile) + logger.Logger = logger.Output(writers) + + imageStore := local.NewImageStore(globalDir, false, 0, false, false, + logger, monitoring.NewMetricsServer(false, logger), nil, nil) + + storeController := storage.StoreController{ + DefaultStore: imageStore, + } + + // Write image + image := test.CreateRandomImage() + err = test.WriteImageToFileSystem(image, repo, tag, storeController) + So(err, ShouldBeNil) + + // Write signature + signature := test.CreateImageWith().RandomLayers(1, 2).RandomConfig().Build() + So(err, ShouldBeNil) + ref, err := test.GetCosignSignatureTagForManifest(image.Manifest) + So(err, ShouldBeNil) + err = test.WriteImageToFileSystem(signature, repo, ref, storeController) + So(err, ShouldBeNil) + + ctlr := api.NewController(conf) + ctlr.Log.Logger = ctlr.Log.Output(writers) + + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + strQuery := fmt.Sprintf(imageQuery, repo, tag) + gqlTargetURL := fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up image trust routes", time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", + time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + + // verify the image is shown as signed and trusted + resp, err := resty.R().Get(gqlTargetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + imgSummaryResponse := zcommon.ImageSummaryResult{} + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.ImageSummary, ShouldNotBeNil) + imgSummary := imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repo) + So(imgSummary.Tag, ShouldContainSubstring, tag) + So(imgSummary.Digest, ShouldContainSubstring, image.Digest().Encoded()) + So(imgSummary.Manifests[0].Digest, ShouldContainSubstring, image.Digest().Encoded()) + t.Log(imgSummary.SignatureInfo) + So(imgSummary.SignatureInfo, ShouldNotBeNil) + So(imgSummary.IsSigned, ShouldEqual, true) + So(len(imgSummary.SignatureInfo), ShouldEqual, 1) + So(imgSummary.SignatureInfo[0].IsTrusted, ShouldEqual, false) + So(imgSummary.SignatureInfo[0].Tool, ShouldEqual, "cosign") + So(imgSummary.SignatureInfo[0].Author, ShouldEqual, "") + So(imgSummary.Manifests[0].SignatureInfo, ShouldNotBeNil) + So(len(imgSummary.Manifests[0].SignatureInfo), ShouldEqual, 1) + t.Log(imgSummary.Manifests[0].SignatureInfo) + So(imgSummary.Manifests[0].SignatureInfo[0].IsTrusted, ShouldEqual, false) + So(imgSummary.Manifests[0].SignatureInfo[0].Tool, ShouldEqual, "cosign") + So(imgSummary.Manifests[0].SignatureInfo[0].Author, ShouldEqual, "") + }) + + Convey("Verify failures when saving uploaded certificates and public keys", t, func() { + globalDir := t.TempDir() + port := test.GetFreePort() + + conf := config.New() + conf.HTTP.Port = port + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultValue + conf.Extensions.Trust.Notation = defaultValue + conf.Extensions.Trust.Cosign = defaultValue + + baseURL := test.GetBaseURL(port) + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = globalDir + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate Notation cert + err := test.GenerateNotationCerts(rootDir, "test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + // generate Cosign keys + keyDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(keyDir) + + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(keyDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + // Make sure the write to disk fails + So(os.Chmod(globalDir, 0o000), ShouldBeNil) + defer func() { + So(os.Chmod(globalDir, 0o755), ShouldBeNil) + }() + + client := resty.New() + resp, err := client.R().SetHeader("Content-type", "application/octet-stream"). + SetQueryParam("truststoreName", "test"). + SetBody(certificateContent).Post(baseURL + constants.FullNotation) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBody(publicKeyContent).Post(baseURL + constants.FullCosign) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) +} diff --git a/pkg/extensions/extension_metrics.go b/pkg/extensions/extension_metrics.go index 77a37779..818fc2fa 100644 --- a/pkg/extensions/extension_metrics.go +++ b/pkg/extensions/extension_metrics.go @@ -9,7 +9,6 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/storage" ) func EnableMetricsExtension(config *config.Config, log log.Logger, rootDir string) { @@ -26,7 +25,7 @@ func EnableMetricsExtension(config *config.Config, log log.Logger, rootDir strin } } -func SetupMetricsRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, +func SetupMetricsRoutes(config *config.Config, router *mux.Router, authFunc mux.MiddlewareFunc, log log.Logger, ) { log.Info().Msg("setting up metrics routes") diff --git a/pkg/extensions/extension_metrics_disabled.go b/pkg/extensions/extension_metrics_disabled.go index f6456330..092f75f8 100644 --- a/pkg/extensions/extension_metrics_disabled.go +++ b/pkg/extensions/extension_metrics_disabled.go @@ -8,7 +8,6 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/storage" ) // EnableMetricsExtension ... @@ -19,7 +18,7 @@ func EnableMetricsExtension(config *config.Config, log log.Logger, rootDir strin // SetupMetricsRoutes ... func SetupMetricsRoutes(conf *config.Config, router *mux.Router, - storeController storage.StoreController, authFunc mux.MiddlewareFunc, log log.Logger, + authFunc mux.MiddlewareFunc, log log.Logger, ) { log.Warn().Msg("skipping setting up metrics routes because given zot binary doesn't include this feature," + "please build a binary that does so") diff --git a/pkg/extensions/extension_mgmt.go b/pkg/extensions/extension_mgmt.go index 7d365643..a72f08ca 100644 --- a/pkg/extensions/extension_mgmt.go +++ b/pkg/extensions/extension_mgmt.go @@ -4,27 +4,15 @@ package extensions import ( - "context" "encoding/json" - "io" "net/http" - "time" "github.com/gorilla/mux" - "github.com/opencontainers/go-digest" "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" zcommon "zotregistry.io/zot/pkg/common" "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/meta/signatures" - mTypes "zotregistry.io/zot/pkg/meta/types" - "zotregistry.io/zot/pkg/scheduler" -) - -const ( - ConfigResource = "config" - SignaturesResource = "signatures" ) type HTPasswd struct { @@ -90,246 +78,51 @@ func (auth Auth) MarshalJSON() ([]byte, error) { return json.Marshal((localAuth)(auth)) } -type mgmt struct { - config *config.Config - log log.Logger -} +func SetupMgmtRoutes(conf *config.Config, router *mux.Router, log log.Logger) { + if !conf.IsMgmtEnabled() { + log.Info().Msg("skip enabling the mgmt route as the config prerequisites are not met") -func (mgmt *mgmt) handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var resource string - - if zcommon.QueryHasParams(r.URL.Query(), []string{"resource"}) { - resource = r.URL.Query().Get("resource") - } else { - resource = ConfigResource // default value of "resource" query param - } - - switch resource { - case ConfigResource: - if r.Method == http.MethodGet { - mgmt.HandleGetConfig(w, r) - } else { - w.WriteHeader(http.StatusBadRequest) - } - - return - case SignaturesResource: - if r.Method == http.MethodPost { - HandleCertificatesAndPublicKeysUploads(w, r) //nolint: contextcheck - } else { - w.WriteHeader(http.StatusBadRequest) - } - - return - default: - w.WriteHeader(http.StatusBadRequest) - - return - } - }) -} - -func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger) { - if config.Extensions.Mgmt != nil && *config.Extensions.Mgmt.Enable { - log.Info().Msg("setting up mgmt routes") - - mgmt := mgmt{config: config, log: log} - - allowedMethods := zcommon.AllowedMethods(http.MethodGet, http.MethodPost) - - mgmtRouter := router.PathPrefix(constants.ExtMgmt).Subrouter() - mgmtRouter.Use(zcommon.ACHeadersHandler(config, allowedMethods...)) - mgmtRouter.Use(zcommon.AddExtensionSecurityHeaders()) - mgmtRouter.Methods(allowedMethods...).Handler(mgmt.handler()) + return } + + log.Info().Msg("setting up mgmt routes") + + mgmt := Mgmt{Conf: conf, Log: log} + + // The endpoint for reading configuration should be available to all users + allowedMethods := zcommon.AllowedMethods(http.MethodGet) + + mgmtRouter := router.PathPrefix(constants.ExtMgmt).Subrouter() + mgmtRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + mgmtRouter.Use(zcommon.AddExtensionSecurityHeaders()) + mgmtRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + mgmtRouter.Methods(allowedMethods...).HandlerFunc(mgmt.HandleGetConfig) + + log.Info().Msg("finished setting up mgmt routes") +} + +type Mgmt struct { + Conf *config.Config + Log log.Logger } // mgmtHandler godoc // @Summary Get current server configuration // @Description Get current server configuration -// @Router /v2/_zot/ext/mgmt [get] +// @Router /v2/_zot/ext/mgmt [get] // @Accept json // @Produce json -// @Param resource query string false "specify resource" Enums(config) -// @Success 200 {object} extensions.StrippedConfig -// @Failure 500 {string} string "internal server error". -func (mgmt *mgmt) HandleGetConfig(w http.ResponseWriter, r *http.Request) { - sanitizedConfig := mgmt.config.Sanitize() +// @Param resource query string false "specify resource" Enums(config) +// @Success 200 {object} extensions.StrippedConfig +// @Failure 500 {string} string "internal server error". +func (mgmt *Mgmt) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + sanitizedConfig := mgmt.Conf.Sanitize() buf, err := zcommon.MarshalThroughStruct(sanitizedConfig, &StrippedConfig{}) if err != nil { - mgmt.log.Error().Err(err).Msg("mgmt: couldn't marshal config response") + mgmt.Log.Error().Err(err).Msg("mgmt: couldn't marshal config response") w.WriteHeader(http.StatusInternalServerError) } _, _ = w.Write(buf) } - -// mgmtHandler godoc -// @Summary Upload certificates and public keys for verifying signatures -// @Description Upload certificates and public keys for verifying signatures -// @Router /v2/_zot/ext/mgmt [post] -// @Accept octet-stream -// @Produce json -// @Param resource query string true "specify resource" Enums(signatures) -// @Param tool query string true "specify signing tool" Enums(cosign, notation) -// @Param truststoreType query string false "truststore type" -// @Param truststoreName query string false "truststore name" -// @Param requestBody body string true "Public key or Certificate content" -// @Success 200 {string} string "ok" -// @Failure 400 {string} string "bad request". -// @Failure 500 {string} string "internal server error". -func HandleCertificatesAndPublicKeysUploads(response http.ResponseWriter, request *http.Request) { - if !zcommon.QueryHasParams(request.URL.Query(), []string{"tool"}) { - response.WriteHeader(http.StatusBadRequest) - - return - } - - body, err := io.ReadAll(request.Body) - if err != nil { - response.WriteHeader(http.StatusInternalServerError) - - return - } - - tool := request.URL.Query().Get("tool") - - switch tool { - case signatures.CosignSignature: - err := signatures.UploadPublicKey(body) - if err != nil { - response.WriteHeader(http.StatusInternalServerError) - - return - } - case signatures.NotationSignature: - var truststoreType string - - if !zcommon.QueryHasParams(request.URL.Query(), []string{"truststoreName"}) { - response.WriteHeader(http.StatusBadRequest) - - return - } - - if zcommon.QueryHasParams(request.URL.Query(), []string{"truststoreType"}) { - truststoreType = request.URL.Query().Get("truststoreType") - } else { - truststoreType = "ca" // default value of "truststoreType" query param - } - - truststoreName := request.URL.Query().Get("truststoreName") - - if truststoreType == "" || truststoreName == "" { - response.WriteHeader(http.StatusBadRequest) - - return - } - - err = signatures.UploadCertificate(body, truststoreType, truststoreName) - if err != nil { - response.WriteHeader(http.StatusInternalServerError) - - return - } - default: - response.WriteHeader(http.StatusBadRequest) - - return - } - - response.WriteHeader(http.StatusOK) -} - -func EnablePeriodicSignaturesVerification(config *config.Config, taskScheduler *scheduler.Scheduler, - metaDB mTypes.MetaDB, log log.Logger, -) { - if config.Extensions.Search != nil && *config.Extensions.Search.Enable { - ctx := context.Background() - - repos, err := metaDB.GetMultipleRepoMeta(ctx, func(repoMeta mTypes.RepoMetadata) bool { - return true - }) - if err != nil { - return - } - - generator := &taskGeneratorSigValidity{ - repos: repos, - metaDB: metaDB, - repoIndex: -1, - log: log, - } - - numberOfHours := 2 - interval := time.Duration(numberOfHours) * time.Minute - taskScheduler.SubmitGenerator(generator, interval, scheduler.MediumPriority) - } -} - -type taskGeneratorSigValidity struct { - repos []mTypes.RepoMetadata - metaDB mTypes.MetaDB - repoIndex int - done bool - log log.Logger -} - -func (gen *taskGeneratorSigValidity) Next() (scheduler.Task, error) { - gen.repoIndex++ - - if gen.repoIndex >= len(gen.repos) { - gen.done = true - - return nil, nil - } - - return NewValidityTask(gen.metaDB, gen.repos[gen.repoIndex], gen.log), nil -} - -func (gen *taskGeneratorSigValidity) IsDone() bool { - return gen.done -} - -func (gen *taskGeneratorSigValidity) Reset() { - gen.done = false - gen.repoIndex = -1 - ctx := context.Background() - - repos, err := gen.metaDB.GetMultipleRepoMeta(ctx, func(repoMeta mTypes.RepoMetadata) bool { return true }) - if err != nil { - return - } - - gen.repos = repos -} - -type validityTask struct { - metaDB mTypes.MetaDB - repo mTypes.RepoMetadata - log log.Logger -} - -func NewValidityTask(metaDB mTypes.MetaDB, repo mTypes.RepoMetadata, log log.Logger) *validityTask { - return &validityTask{metaDB, repo, log} -} - -func (validityT *validityTask) DoWork() error { - validityT.log.Info().Msg("updating signatures validity") - - for signedManifest, sigs := range validityT.repo.Signatures { - if len(sigs[signatures.CosignSignature]) != 0 || len(sigs[signatures.NotationSignature]) != 0 { - err := validityT.metaDB.UpdateSignaturesValidity(validityT.repo.Name, digest.Digest(signedManifest)) - if err != nil { - validityT.log.Info().Msg("error while verifying signatures") - - return err - } - } - } - - validityT.log.Info().Msg("verifying signatures successfully completed") - - return nil -} diff --git a/pkg/extensions/extension_mgmt_disabled.go b/pkg/extensions/extension_mgmt_disabled.go index f9ea6dd9..1788f34e 100644 --- a/pkg/extensions/extension_mgmt_disabled.go +++ b/pkg/extensions/extension_mgmt_disabled.go @@ -8,8 +8,6 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" - mTypes "zotregistry.io/zot/pkg/meta/types" - "zotregistry.io/zot/pkg/scheduler" ) func IsBuiltWithMGMTExtension() bool { @@ -20,10 +18,3 @@ func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger) log.Warn().Msg("skipping setting up mgmt routes because given zot binary doesn't include this feature," + "please build a binary that does so") } - -func EnablePeriodicSignaturesVerification(config *config.Config, taskScheduler *scheduler.Scheduler, - metaDB mTypes.MetaDB, log log.Logger, -) { - log.Warn().Msg("skipping adding to the scheduler a generator for updating signatures validity because " + - "given binary doesn't include this feature, please build a binary that does so") -} diff --git a/pkg/extensions/extension_search.go b/pkg/extensions/extension_search.go index 359d648c..9ed01882 100644 --- a/pkg/extensions/extension_search.go +++ b/pkg/extensions/extension_search.go @@ -156,20 +156,27 @@ func (trivyT *trivyTask) DoWork() error { return nil } -func SetupSearchRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, +func SetupSearchRoutes(conf *config.Config, router *mux.Router, storeController storage.StoreController, metaDB mTypes.MetaDB, cveInfo CveInfo, log log.Logger, ) { + if !conf.IsSearchEnabled() { + log.Info().Msg("skip enabling the search route as the config prerequisites are not met") + + return + } + log.Info().Msg("setting up search routes") - if config.Extensions.Search != nil && *config.Extensions.Search.Enable { - resConfig := search.GetResolverConfig(log, storeController, metaDB, cveInfo) + resConfig := search.GetResolverConfig(log, storeController, metaDB, cveInfo) - allowedMethods := zcommon.AllowedMethods(http.MethodGet, http.MethodPost) + allowedMethods := zcommon.AllowedMethods(http.MethodGet, http.MethodPost) - extRouter := router.PathPrefix(constants.ExtSearch).Subrouter() - extRouter.Use(zcommon.ACHeadersHandler(config, allowedMethods...)) - extRouter.Use(zcommon.AddExtensionSecurityHeaders()) - extRouter.Methods(allowedMethods...). - Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig))) - } + extRouter := router.PathPrefix(constants.ExtSearchPrefix).Subrouter() + extRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + extRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + extRouter.Use(zcommon.AddExtensionSecurityHeaders()) + extRouter.Methods(allowedMethods...). + Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig))) + + log.Info().Msg("finished setting up search routes") } diff --git a/pkg/extensions/extension_ui.go b/pkg/extensions/extension_ui.go index 7fffe6b3..2d81f25b 100644 --- a/pkg/extensions/extension_ui.go +++ b/pkg/extensions/extension_ui.go @@ -12,8 +12,8 @@ import ( "github.com/gorilla/mux" "zotregistry.io/zot/pkg/api/config" + zcommon "zotregistry.io/zot/pkg/common" "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/storage" ) // content is our static web server content. @@ -57,19 +57,38 @@ func addUISecurityHeaders(h http.Handler) http.HandlerFunc { //nolint:varnamelen } } -func SetupUIRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, +func SetupUIRoutes(conf *config.Config, router *mux.Router, log log.Logger, ) { - if config.Extensions.UI != nil { - fsub, _ := fs.Sub(content, "build") - uih := uiHandler{log: log} + if !conf.IsUIEnabled() { + log.Info().Msg("skip enabling the ui route as the config prerequisites are not met") - router.PathPrefix("/login").Handler(addUISecurityHeaders(uih)) - router.PathPrefix("/home").Handler(addUISecurityHeaders(uih)) - router.PathPrefix("/explore").Handler(addUISecurityHeaders(uih)) - router.PathPrefix("/image").Handler(addUISecurityHeaders(uih)) - router.PathPrefix("/").Handler(addUISecurityHeaders(http.FileServer(http.FS(fsub)))) - - log.Info().Msg("setting up ui routes") + return } + + log.Info().Msg("setting up ui routes") + + fsub, _ := fs.Sub(content, "build") + uih := uiHandler{log: log} + + // See https://go-review.googlesource.com/c/go/+/482635/2/src/net/http/fs.go + // See https://github.com/golang/go/issues/59469 + // In go 1.20.4 they decided to allow any method in the FileServer handler. + // In order to be consistent with the status codes returned when the UI is disabled + // we need to be explicit about the methods we allow on UI routes. + // If we don't add this, all unmatched http methods on any urls would match the UI routes. + allowedMethods := zcommon.AllowedMethods(http.MethodGet) + + router.PathPrefix("/login").Methods(allowedMethods...). + Handler(addUISecurityHeaders(uih)) + router.PathPrefix("/home").Methods(allowedMethods...). + Handler(addUISecurityHeaders(uih)) + router.PathPrefix("/explore").Methods(allowedMethods...). + Handler(addUISecurityHeaders(uih)) + router.PathPrefix("/image").Methods(allowedMethods...). + Handler(addUISecurityHeaders(uih)) + router.PathPrefix("/").Methods(allowedMethods...). + Handler(addUISecurityHeaders(http.FileServer(http.FS(fsub)))) + + log.Info().Msg("finished setting up ui routes") } diff --git a/pkg/extensions/extension_ui_disabled.go b/pkg/extensions/extension_ui_disabled.go index e4144f45..5a142ee3 100644 --- a/pkg/extensions/extension_ui_disabled.go +++ b/pkg/extensions/extension_ui_disabled.go @@ -8,10 +8,9 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/storage" ) -func SetupUIRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, +func SetupUIRoutes(conf *config.Config, router *mux.Router, log log.Logger, ) { log.Warn().Msg("skipping setting up ui routes because given zot binary doesn't include this feature," + diff --git a/pkg/extensions/extension_userprefs.go b/pkg/extensions/extension_userprefs.go index 6ae67e47..7ef215e1 100644 --- a/pkg/extensions/extension_userprefs.go +++ b/pkg/extensions/extension_userprefs.go @@ -15,7 +15,6 @@ import ( zcommon "zotregistry.io/zot/pkg/common" "zotregistry.io/zot/pkg/log" mTypes "zotregistry.io/zot/pkg/meta/types" - "zotregistry.io/zot/pkg/storage" ) const ( @@ -27,37 +26,43 @@ func IsBuiltWithUserPrefsExtension() bool { return true } -func SetupUserPreferencesRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, - metaDB mTypes.MetaDB, cveInfo CveInfo, log log.Logger, +func SetupUserPreferencesRoutes(conf *config.Config, router *mux.Router, + metaDB mTypes.MetaDB, log log.Logger, ) { - if config.Extensions.Search != nil && *config.Extensions.Search.Enable { - log.Info().Msg("setting up user preferences routes") + if !conf.AreUserPrefsEnabled() { + log.Info().Msg("skip enabling the user preferences route as the config prerequisites are not met") - allowedMethods := zcommon.AllowedMethods(http.MethodPut) - - userprefsRouter := router.PathPrefix(constants.ExtUserPreferences).Subrouter() - userprefsRouter.Use(zcommon.ACHeadersHandler(config, allowedMethods...)) - userprefsRouter.Use(zcommon.AddExtensionSecurityHeaders()) - - userprefsRouter.HandleFunc("", HandleUserPrefs(metaDB, log)).Methods(allowedMethods...) + return } + + log.Info().Msg("setting up user preferences routes") + + allowedMethods := zcommon.AllowedMethods(http.MethodPut) + + userPrefsRouter := router.PathPrefix(constants.ExtUserPrefs).Subrouter() + userPrefsRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + userPrefsRouter.Use(zcommon.AddExtensionSecurityHeaders()) + userPrefsRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + userPrefsRouter.Methods(allowedMethods...).Handler(HandleUserPrefs(metaDB, log)) + + log.Info().Msg("finished setting up user preferences routes") } -// ListTags godoc +// Repo preferences godoc // @Summary Add bookmarks/stars info // @Description Add bookmarks/stars info -// @Router /v2/_zot/ext/userprefs [put] +// @Router /v2/_zot/ext/userprefs [put] // @Accept json // @Produce json -// @Param action query string true "specify action" Enums(toggleBookmark, toggleStar) -// @Param repo query string true "repository name" -// @Success 200 {string} string "ok" -// @Failure 404 {string} string "not found" -// @Failure 403 {string} string "forbidden" -// @Failure 500 {string} string "internal server error" -// @Failure 400 {string} string "bad request". -func HandleUserPrefs(metaDB mTypes.MetaDB, log log.Logger) func(w http.ResponseWriter, r *http.Request) { - return func(rsp http.ResponseWriter, req *http.Request) { +// @Param action query string true "specify action" Enums(toggleBookmark, toggleStar) +// @Param repo query string true "repository name" +// @Success 200 {string} string "ok" +// @Failure 404 {string} string "not found" +// @Failure 403 {string} string "forbidden" +// @Failure 500 {string} string "internal server error" +// @Failure 400 {string} string "bad request". +func HandleUserPrefs(metaDB mTypes.MetaDB, log log.Logger) http.Handler { + return http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) { if !zcommon.QueryHasParams(req.URL.Query(), []string{"action"}) { rsp.WriteHeader(http.StatusBadRequest) @@ -80,7 +85,7 @@ func HandleUserPrefs(metaDB mTypes.MetaDB, log log.Logger) func(w http.ResponseW return } - } + }) } func PutStar(rsp http.ResponseWriter, req *http.Request, metaDB mTypes.MetaDB, log log.Logger) { diff --git a/pkg/extensions/extension_userprefs_disable.go b/pkg/extensions/extension_userprefs_disable.go index ad98115c..054bb998 100644 --- a/pkg/extensions/extension_userprefs_disable.go +++ b/pkg/extensions/extension_userprefs_disable.go @@ -9,15 +9,14 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/log" mTypes "zotregistry.io/zot/pkg/meta/types" - "zotregistry.io/zot/pkg/storage" ) func IsBuiltWithUserPrefsExtension() bool { return false } -func SetupUserPreferencesRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, - metaDB mTypes.MetaDB, cveInfo CveInfo, log log.Logger, +func SetupUserPreferencesRoutes(config *config.Config, router *mux.Router, + metaDB mTypes.MetaDB, log log.Logger, ) { log.Warn().Msg("userprefs extension is disabled because given zot binary doesn't" + "include this feature please build a binary that does so") diff --git a/pkg/extensions/extension_userprefs_test.go b/pkg/extensions/extension_userprefs_test.go index b1c45570..0c218f26 100644 --- a/pkg/extensions/extension_userprefs_test.go +++ b/pkg/extensions/extension_userprefs_test.go @@ -36,11 +36,13 @@ func TestAllowedMethodsHeaderUserPrefs(t *testing.T) { conf := config.New() port := test.GetFreePort() conf.HTTP.Port = port - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - }, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal + baseURL := test.GetBaseURL(port) ctlr := api.NewController(conf) @@ -51,7 +53,7 @@ func TestAllowedMethodsHeaderUserPrefs(t *testing.T) { ctrlManager.StartAndWait(port) defer ctrlManager.StopServer() - resp, _ := resty.R().Options(baseURL + constants.FullUserPreferencesPrefix) + resp, _ := resty.R().Options(baseURL + constants.FullUserPrefs) So(resp, ShouldNotBeNil) So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "PUT,OPTIONS") So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) diff --git a/pkg/extensions/extensions_test.go b/pkg/extensions/extensions_test.go index 57075f94..93962137 100644 --- a/pkg/extensions/extensions_test.go +++ b/pkg/extensions/extensions_test.go @@ -1,5 +1,5 @@ -//go:build sync || metrics || mgmt || apikey -// +build sync metrics mgmt apikey +//go:build sync && metrics && mgmt && userprefs && search +// +build sync,metrics,mgmt,userprefs,search package extensions_test @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "path" "testing" "time" @@ -22,10 +21,6 @@ import ( "zotregistry.io/zot/pkg/extensions" extconf "zotregistry.io/zot/pkg/extensions/config" syncconf "zotregistry.io/zot/pkg/extensions/config/sync" - "zotregistry.io/zot/pkg/extensions/monitoring" - "zotregistry.io/zot/pkg/log" - "zotregistry.io/zot/pkg/storage" - "zotregistry.io/zot/pkg/storage/local" "zotregistry.io/zot/pkg/test" ) @@ -125,6 +120,7 @@ func TestMgmtExtension(t *testing.T) { if err != nil { panic(err) } + mgmtReadyTimeout := 5 * time.Second defaultValue := true @@ -142,16 +138,16 @@ func TestMgmtExtension(t *testing.T) { mockOIDCConfig := mockOIDCServer.Config() - Convey("Verify mgmt route enabled with htpasswd", t, func() { + Convey("Verify mgmt auth info route enabled with htpasswd", t, func() { htpasswdPath := test.MakeHtpasswdFile() conf.HTTP.Auth.HTPasswd.Path = htpasswdPath conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -168,19 +164,31 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + So(err, ShouldBeNil) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") - - Convey("unsupported http method call", func() { - // without credentials - resp, err := resty.R().Patch(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) - }) + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Patch(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + + // without credentials + resp, err = resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -193,7 +201,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) // with credentials - resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -206,12 +214,12 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) // with wrong credentials - resp, err = resty.R().SetBasicAuth("test", "wrong").Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().SetBasicAuth("test", "wrong").Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) }) - Convey("Verify mgmt route enabled with ldap", t, func() { + Convey("Verify mgmt auth info route enabled with ldap", t, func() { conf.HTTP.Auth.LDAP = &config.LDAPConfig{ BindDN: "binddn", BaseDN: "basedn", @@ -219,11 +227,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -240,12 +248,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -258,7 +279,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) }) - Convey("Verify mgmt route enabled with htpasswd + ldap", t, func() { + Convey("Verify mgmt auth info route enabled with htpasswd + ldap", t, func() { htpasswdPath := test.MakeHtpasswdFile() conf.HTTP.Auth.HTPasswd.Path = htpasswdPath conf.HTTP.Auth.LDAP = &config.LDAPConfig{ @@ -268,11 +289,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -289,12 +310,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -307,7 +341,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) // with credentials - resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -320,7 +354,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) }) - Convey("Verify mgmt route enabled with htpasswd + ldap + bearer", t, func() { + Convey("Verify mgmt auth info route enabled with htpasswd + ldap + bearer", t, func() { htpasswdPath := test.MakeHtpasswdFile() conf.HTTP.Auth.HTPasswd.Path = htpasswdPath conf.HTTP.Auth.LDAP = &config.LDAPConfig{ @@ -335,11 +369,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -352,12 +386,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -372,7 +419,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") // with credentials - resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -387,7 +434,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") }) - Convey("Verify mgmt route enabled with ldap + bearer", t, func() { + Convey("Verify mgmt auth info route enabled with ldap + bearer", t, func() { conf.HTTP.Auth.HTPasswd.Path = "" conf.HTTP.Auth.LDAP = &config.LDAPConfig{ BindDN: "binddn", @@ -401,11 +448,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -422,12 +469,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -442,7 +502,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") }) - Convey("Verify mgmt route enabled with bearer", t, func() { + Convey("Verify mgmt auth info route enabled with bearer", t, func() { conf.HTTP.Auth.HTPasswd.Path = "" conf.HTTP.Auth.LDAP = nil conf.HTTP.Auth.Bearer = &config.BearerConfig{ @@ -451,11 +511,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -468,12 +528,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -487,7 +560,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") }) - Convey("Verify mgmt route enabled with openID", t, func() { + Convey("Verify mgmt auth info route enabled with openID", t, func() { conf.HTTP.Auth.HTPasswd.Path = "" conf.HTTP.Auth.LDAP = nil conf.HTTP.Auth.Bearer = nil @@ -504,11 +577,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -521,12 +594,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -541,7 +627,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.OpenID.Providers, ShouldNotBeEmpty) }) - Convey("Verify mgmt route enabled with empty openID provider list", t, func() { + Convey("Verify mgmt auth info route enabled with empty openID provider list", t, func() { htpasswdPath := test.MakeHtpasswdFile() conf.HTTP.Auth.HTPasswd.Path = htpasswdPath @@ -555,11 +641,11 @@ func TestMgmtExtension(t *testing.T) { } conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -572,12 +658,25 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - data, _ := os.ReadFile(logFile.Name()) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() + So(found, ShouldBeTrue) + So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) + So(found, ShouldBeTrue) + So(err, ShouldBeNil) // without credentials - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -591,7 +690,7 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.OpenID, ShouldBeNil) }) - Convey("Verify mgmt route enabled without any auth", t, func() { + Convey("Verify mgmt auth info route enabled without any auth", t, func() { globalDir := t.TempDir() conf := config.New() port := test.GetFreePort() @@ -605,11 +704,11 @@ func TestMgmtExtension(t *testing.T) { conf.Commit = "v1.0.0" conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Log.Output = logFile.Name() defer os.Remove(logFile.Name()) // cleanup @@ -622,7 +721,7 @@ func TestMgmtExtension(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err := resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -634,158 +733,22 @@ func TestMgmtExtension(t *testing.T) { So(mgmtResp.HTTP.Auth.HTPasswd, ShouldBeNil) So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) - data, _ := os.ReadFile(logFile.Name()) - So(string(data), ShouldContainSubstring, "setting up mgmt routes") - }) - - Convey("Verify mgmt route enabled for uploading certificates and public keys", t, func() { - globalDir := t.TempDir() - conf := config.New() - port := test.GetFreePort() - conf.HTTP.Port = port - baseURL := test.GetBaseURL(port) - - logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") - So(err, ShouldBeNil) - defaultValue := true - - conf.Commit = "v1.0.0" - - imageStore := local.NewImageStore(globalDir, false, 0, false, false, - log.NewLogger("debug", logFile.Name()), monitoring.NewMetricsServer(false, - log.NewLogger("debug", logFile.Name())), nil, nil) - - storeController := storage.StoreController{ - DefaultStore: imageStore, - } - - config, layers, manifest, err := test.GetRandomImageComponents(10) //nolint:staticcheck - So(err, ShouldBeNil) - - err = test.WriteImageToFileSystem( - test.Image{ - Manifest: manifest, - Layers: layers, - Config: config, - }, "repo", "0.0.1", storeController, - ) - So(err, ShouldBeNil) - - sigConfig, sigLayers, sigManifest, err := test.GetRandomImageComponents(10) //nolint:staticcheck - So(err, ShouldBeNil) - - ref, _ := test.GetCosignSignatureTagForManifest(manifest) - err = test.WriteImageToFileSystem( - test.Image{ - Manifest: sigManifest, - Layers: sigLayers, - Config: sigConfig, - }, "repo", ref, storeController, - ) - So(err, ShouldBeNil) - - conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Search = &extconf.SearchConfig{} - conf.Extensions.Search.Enable = &defaultValue - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } - - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // cleanup - - ctlr := api.NewController(conf) - - ctlr.Config.Storage.RootDirectory = globalDir - - ctlrManager := test.NewControllerManager(ctlr) - ctlrManager.StartAndWait(port) - defer ctlrManager.StopServer() - - rootDir := t.TempDir() - - test.NotationPathLock.Lock() - defer test.NotationPathLock.Unlock() - - test.LoadNotationPath(rootDir) - - // generate a keypair - err = test.GenerateNotationCerts(rootDir, "test") - So(err, ShouldBeNil) - - certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "test.crt")) - So(err, ShouldBeNil) - So(certificateContent, ShouldNotBeNil) - - client := resty.New() - resp, err := client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", "test"). - SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation"). - SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", ""). - SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", "test"). - SetQueryParam("truststoreType", "signatureAuthority"). - SetBody([]byte("wrong content")).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - - resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "invalidTool"). - SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetQueryParam("resource", "signatures").SetQueryParam("tool", "cosign"). - SetBody([]byte("wrong content")).Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - - resp, err = client.R().SetQueryParam("resource", "signatures").SetQueryParam("tool", "cosign"). - Get(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetQueryParam("resource", "signatures").Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetQueryParam("resource", "config").Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = client.R().SetQueryParam("resource", "invalid").Post(baseURL + constants.FullMgmtPrefix) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up mgmt routes", time.Second) - So(err, ShouldBeNil) + found, err := test.ReadLogFileAndSearchString(logFile.Name(), + "setting up mgmt routes", mgmtReadyTimeout) + defer func() { + if !found { + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + t.Log(string(data)) + } + }() So(found, ShouldBeTrue) - - found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second) So(err, ShouldBeNil) - So(found, ShouldBeTrue) - found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed", - time.Second) - So(err, ShouldBeNil) + found, err = test.ReadLogFileAndSearchString(logFile.Name(), + "finished setting up mgmt routes", mgmtReadyTimeout) So(found, ShouldBeTrue) + So(err, ShouldBeNil) }) } @@ -816,11 +779,11 @@ func TestMgmtWithBearer(t *testing.T) { defaultValue := true conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Mgmt = &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{ - Enable: &defaultValue, - }, - } + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultValue + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultValue conf.Storage.RootDirectory = t.TempDir() @@ -909,7 +872,7 @@ func TestMgmtWithBearer(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) // test mgmt route - resp, err = resty.R().Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -923,7 +886,7 @@ func TestMgmtWithBearer(t *testing.T) { So(mgmtResp.HTTP.Auth.HTPasswd, ShouldBeNil) So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) - resp, err = resty.R().SetBasicAuth("", "").Get(baseURL + constants.FullMgmtPrefix) + resp, err = resty.R().SetBasicAuth("", "").Get(baseURL + constants.FullMgmt) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) @@ -946,11 +909,13 @@ func TestAllowedMethodsHeaderMgmt(t *testing.T) { conf := config.New() port := test.GetFreePort() conf.HTTP.Port = port - conf.Extensions = &extconf.ExtensionConfig{ - Mgmt: &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - }, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal + baseURL := test.GetBaseURL(port) ctlr := api.NewController(conf) @@ -961,38 +926,9 @@ func TestAllowedMethodsHeaderMgmt(t *testing.T) { ctrlManager.StartAndWait(port) defer ctrlManager.StopServer() - resp, _ := resty.R().Options(baseURL + constants.FullMgmtPrefix) + resp, _ := resty.R().Options(baseURL + constants.FullMgmt) So(resp, ShouldNotBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,POST,OPTIONS") - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - }) -} - -func TestAllowedMethodsHeaderAPIKey(t *testing.T) { - defaultVal := true - - Convey("Test http options response", t, func() { - conf := config.New() - port := test.GetFreePort() - conf.HTTP.Port = port - conf.Extensions = &extconf.ExtensionConfig{ - APIKey: &extconf.APIKeyConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - }, - } - baseURL := test.GetBaseURL(port) - - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = t.TempDir() - - ctrlManager := test.NewControllerManager(ctlr) - - ctrlManager.StartAndWait(port) - defer ctrlManager.StopServer() - - resp, _ := resty.R().Options(baseURL + constants.FullAPIKeyPrefix) - So(resp, ShouldNotBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "POST,DELETE,OPTIONS") + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) }) } diff --git a/pkg/extensions/get_extensions.go b/pkg/extensions/get_extensions.go index d5f67b23..74a414ee 100644 --- a/pkg/extensions/get_extensions.go +++ b/pkg/extensions/get_extensions.go @@ -5,6 +5,9 @@ import ( "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" + "zotregistry.io/zot/pkg/log" + mTypes "zotregistry.io/zot/pkg/meta/types" + "zotregistry.io/zot/pkg/scheduler" ) func GetExtensions(config *config.Config) distext.ExtensionList { @@ -13,18 +16,24 @@ func GetExtensions(config *config.Config) distext.ExtensionList { endpoints := []string{} extensions := []distext.Extension{} - if config.Extensions != nil && config.Extensions.Search != nil { - if IsBuiltWithSearchExtension() { - endpoints = append(endpoints, constants.FullSearchPrefix) - } - - if IsBuiltWithUserPrefsExtension() { - endpoints = append(endpoints, constants.FullUserPreferencesPrefix) - } + if config.IsNotationEnabled() && IsBuiltWithImageTrustExtension() { + endpoints = append(endpoints, constants.FullNotation) } - if IsBuiltWithMGMTExtension() && config.Extensions != nil && config.Extensions.Mgmt != nil { - endpoints = append(endpoints, constants.FullMgmtPrefix) + if config.IsCosignEnabled() && IsBuiltWithImageTrustExtension() { + endpoints = append(endpoints, constants.FullCosign) + } + + if config.IsSearchEnabled() && IsBuiltWithSearchExtension() { + endpoints = append(endpoints, constants.FullSearchPrefix) + } + + if config.AreUserPrefsEnabled() && IsBuiltWithUserPrefsExtension() { + endpoints = append(endpoints, constants.FullUserPrefs) + } + + if config.IsMgmtEnabled() && IsBuiltWithMGMTExtension() { + endpoints = append(endpoints, constants.FullMgmt) } if len(endpoints) > 0 { @@ -40,3 +49,9 @@ func GetExtensions(config *config.Config) distext.ExtensionList { return extensionList } + +func EnableScheduledTasks(conf *config.Config, taskScheduler *scheduler.Scheduler, + metaDB mTypes.MetaDB, log log.Logger, +) { + EnableImageTrustVerification(conf, taskScheduler, metaDB, log) +} diff --git a/pkg/extensions/get_extensions_disabled_test.go b/pkg/extensions/get_extensions_disabled_test.go index 7975d01c..71e60a53 100644 --- a/pkg/extensions/get_extensions_disabled_test.go +++ b/pkg/extensions/get_extensions_disabled_test.go @@ -28,18 +28,12 @@ func TestGetExensionsDisabled(t *testing.T) { defaultVal := true - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - mgmtConfg := &extconf.MgmtConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - Mgmt: mgmtConfg, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) diff --git a/pkg/extensions/search/userprefs_test.go b/pkg/extensions/search/userprefs_test.go index b0d6701b..a7760076 100644 --- a/pkg/extensions/search/userprefs_test.go +++ b/pkg/extensions/search/userprefs_test.go @@ -83,9 +83,12 @@ func TestUserData(t *testing.T) { Actions: []string{"read", "create", "update"}, }, } - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal ctlr := api.NewController(conf) @@ -134,7 +137,7 @@ func TestUserData(t *testing.T) { } }` - userprefsBaseURL := baseURL + constants.FullUserPreferencesPrefix + userprefsBaseURL := baseURL + constants.FullUserPrefs Convey("Flip starred repo authorized", func(c C) { clientHTTP := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) @@ -499,9 +502,12 @@ func TestChangingRepoState(t *testing.T) { }, }, } - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal gqlStarredRepos := ` { @@ -563,7 +569,7 @@ func TestChangingRepoState(t *testing.T) { simpleUserClient := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) anonynousClient := resty.R() - userprefsBaseURL := baseURL + constants.FullUserPreferencesPrefix + userprefsBaseURL := baseURL + constants.FullUserPrefs Convey("PutStars", t, func() { resp, err := simpleUserClient.Put(userprefsBaseURL + PutRepoStarURL(accesibleRepo)) @@ -647,9 +653,12 @@ func TestGlobalSearchWithUserPrefFiltering(t *testing.T) { } defaultVal := true - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal ctlr := api.NewController(conf) @@ -657,7 +666,7 @@ func TestGlobalSearchWithUserPrefFiltering(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - preferencesBaseURL := baseURL + constants.FullUserPreferencesPrefix + preferencesBaseURL := baseURL + constants.FullUserPrefs simpleUserClient := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) // ------ Add simple repo @@ -840,9 +849,12 @@ func TestExpandedRepoInfoWithUserPrefs(t *testing.T) { } defaultVal := true - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, - } + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal ctlr := api.NewController(conf) @@ -850,7 +862,7 @@ func TestExpandedRepoInfoWithUserPrefs(t *testing.T) { ctlrManager.StartAndWait(port) defer ctlrManager.StopServer() - preferencesBaseURL := baseURL + constants.FullUserPreferencesPrefix + preferencesBaseURL := baseURL + constants.FullUserPrefs simpleUserClient := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) // ------ Add sbrepo and star/bookmark it diff --git a/pkg/meta/signatures/cosign.go b/pkg/meta/signatures/cosign.go index 822738b2..85560158 100644 --- a/pkg/meta/signatures/cosign.go +++ b/pkg/meta/signatures/cosign.go @@ -5,6 +5,7 @@ import ( "context" "crypto" "encoding/base64" + "fmt" "io" "os" "path" @@ -136,7 +137,7 @@ func UploadPublicKey(publicKeyContent []byte) error { func validatePublicKey(publicKeyContent []byte) (bool, error) { _, err := cryptoutils.UnmarshalPEMToPublicKey(publicKeyContent) if err != nil { - return false, err + return false, fmt.Errorf("%w: %w", zerr.ErrInvalidPublicKeyContent, err) } return true, nil diff --git a/pkg/meta/signatures/notation.go b/pkg/meta/signatures/notation.go index 5b2475b5..6f195cdf 100644 --- a/pkg/meta/signatures/notation.go +++ b/pkg/meta/signatures/notation.go @@ -302,7 +302,7 @@ func validateCertificate(certificateContent []byte) (bool, error) { // data may be in DER format derCerts, err := x509.ParseCertificates(certificateContent) if err != nil { - return false, err + return false, fmt.Errorf("%w: %w", zerr.ErrInvalidCertificateContent, err) } certs = append(certs, derCerts...) @@ -311,7 +311,7 @@ func validateCertificate(certificateContent []byte) (bool, error) { for block != nil { cert, err := x509.ParseCertificate(block.Bytes) if err != nil { - return false, err + return false, fmt.Errorf("%w: %w", zerr.ErrInvalidCertificateContent, err) } certs = append(certs, cert) block, rest = pem.Decode(rest) @@ -319,7 +319,8 @@ func validateCertificate(certificateContent []byte) (bool, error) { } if len(certs) == 0 { - return false, zerr.ErrInvalidCertificateContent + return false, fmt.Errorf("%w: no valid certificates found in payload", + zerr.ErrInvalidCertificateContent) } return true, nil diff --git a/pkg/meta/signatures/signatures.go b/pkg/meta/signatures/signatures.go index a3c2b636..99f1eae1 100644 --- a/pkg/meta/signatures/signatures.go +++ b/pkg/meta/signatures/signatures.go @@ -1,6 +1,7 @@ package signatures import ( + "context" "encoding/json" "time" @@ -8,6 +9,9 @@ import ( ispec "github.com/opencontainers/image-spec/specs-go/v1" zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/log" + mTypes "zotregistry.io/zot/pkg/meta/types" + "zotregistry.io/zot/pkg/scheduler" ) const ( @@ -58,3 +62,84 @@ func VerifySignature( return "", time.Time{}, false, zerr.ErrInvalidSignatureType } } + +func NewTaskGenerator(metaDB mTypes.MetaDB, log log.Logger) scheduler.TaskGenerator { + return &sigValidityTaskGenerator{ + repos: []mTypes.RepoMetadata{}, + metaDB: metaDB, + repoIndex: -1, + log: log, + } +} + +type sigValidityTaskGenerator struct { + repos []mTypes.RepoMetadata + metaDB mTypes.MetaDB + repoIndex int + done bool + log log.Logger +} + +func (gen *sigValidityTaskGenerator) Next() (scheduler.Task, error) { + if len(gen.repos) == 0 { + ctx := context.Background() + + repos, err := gen.metaDB.GetMultipleRepoMeta(ctx, func(repoMeta mTypes.RepoMetadata) bool { + return true + }) + if err != nil { + return nil, err + } + + gen.repos = repos + } + + gen.repoIndex++ + + if gen.repoIndex >= len(gen.repos) { + gen.done = true + + return nil, nil + } + + return NewValidityTask(gen.metaDB, gen.repos[gen.repoIndex], gen.log), nil +} + +func (gen *sigValidityTaskGenerator) IsDone() bool { + return gen.done +} + +func (gen *sigValidityTaskGenerator) Reset() { + gen.done = false + gen.repoIndex = -1 + gen.repos = []mTypes.RepoMetadata{} +} + +type validityTask struct { + metaDB mTypes.MetaDB + repo mTypes.RepoMetadata + log log.Logger +} + +func NewValidityTask(metaDB mTypes.MetaDB, repo mTypes.RepoMetadata, log log.Logger) *validityTask { + return &validityTask{metaDB, repo, log} +} + +func (validityT *validityTask) DoWork() error { + validityT.log.Info().Msg("updating signatures validity") + + for signedManifest, sigs := range validityT.repo.Signatures { + if len(sigs[CosignSignature]) != 0 || len(sigs[NotationSignature]) != 0 { + err := validityT.metaDB.UpdateSignaturesValidity(validityT.repo.Name, godigest.Digest(signedManifest)) + if err != nil { + validityT.log.Info().Msg("error while verifying signatures") + + return err + } + } + } + + validityT.log.Info().Msg("verifying signatures successfully completed") + + return nil +} diff --git a/pkg/test/common.go b/pkg/test/common.go index 8bd56a05..1e3fcbf8 100644 --- a/pkg/test/common.go +++ b/pkg/test/common.go @@ -43,6 +43,7 @@ import ( "github.com/sigstore/cosign/v2/cmd/cosign/cli/generate" "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" + "golang.org/x/crypto/bcrypt" "gopkg.in/resty.v1" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" @@ -126,6 +127,17 @@ func MakeHtpasswdFile() string { return MakeHtpasswdFileFromString(content) } +func GetCredString(username, password string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + panic(err) + } + + usernameAndHash := fmt.Sprintf("%s:%s", username, string(hash)) + + return usernameAndHash +} + func MakeHtpasswdFileFromString(fileContent string) string { htpasswdFile, err := os.CreateTemp("", "htpasswd-") if err != nil { diff --git a/pkg/test/common_test.go b/pkg/test/common_test.go index fbbe2962..6209e260 100644 --- a/pkg/test/common_test.go +++ b/pkg/test/common_test.go @@ -19,7 +19,6 @@ import ( "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" . "github.com/smartystreets/goconvey/convey" - "golang.org/x/crypto/bcrypt" "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" @@ -610,7 +609,7 @@ func TestUploadImage(t *testing.T) { user1 := "test" password1 := "test" - testString1 := getCredString(user1, password1) + testString1 := test.GetCredString(user1, password1) htpasswdPath := test.MakeHtpasswdFileFromString(testString1) defer os.Remove(htpasswdPath) conf.HTTP.Auth = &config.AuthConfig{ @@ -768,17 +767,6 @@ func TestUploadImage(t *testing.T) { }) } -func getCredString(username, password string) string { - hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) - if err != nil { - panic(err) - } - - usernameAndHash := fmt.Sprintf("%s:%s", username, string(hash)) - - return usernameAndHash -} - func TestInjectUploadImage(t *testing.T) { Convey("Inject failures for unreachable lines", t, func() { port := test.GetFreePort() @@ -909,7 +897,7 @@ func TestInjectUploadImageWithBasicAuth(t *testing.T) { user := "user" password := "password" - testString := getCredString(user, password) + testString := test.GetCredString(user, password) htpasswdPath := test.MakeHtpasswdFileFromString(testString) defer os.Remove(htpasswdPath) conf.HTTP.Auth = &config.AuthConfig{ diff --git a/swagger/docs.go b/swagger/docs.go index a335f883..ff8e2949 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -20,6 +20,126 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/apikey": { + "post": { + "description": "Can create an api key for a logged in user, based on the provided label and scopes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Create an API key for the current user", + "parameters": [ + { + "description": "api token id (UUID)", + "name": "id", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.APIKeyPayload" + } + } + ], + "responses": { + "201": { + "description": "created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Revokes one current user API key based on given key ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Revokes one current user API key", + "parameters": [ + { + "type": "string", + "description": "api token id (UUID)", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout by removing current session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Logout by removing current session", + "responses": { + "200": { + "description": "ok\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } + } + }, "/oras/artifacts/v1/{name}/manifests/{digest}/referrers": { "get": { "description": "Get references for an image given a digest and artifact type", @@ -141,6 +261,49 @@ const docTemplate = `{ } } }, + "/v2/_zot/ext/cosign": { + "post": { + "description": "Upload cosign public keys for verifying signatures", + "consumes": [ + "application/octet-stream" + ], + "produces": [ + "application/json" + ], + "summary": "Upload cosign public keys for verifying signatures", + "parameters": [ + { + "description": "Public key content", + "name": "requestBody", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } + } + }, "/v2/_zot/ext/mgmt": { "get": { "description": "Get current server configuration", @@ -176,38 +339,19 @@ const docTemplate = `{ } } } - }, + } + }, + "/v2/_zot/ext/notation": { "post": { - "description": "Upload certificates and public keys for verifying signatures", + "description": "Upload notation certificates for verifying signatures", "consumes": [ "application/octet-stream" ], "produces": [ "application/json" ], - "summary": "Upload certificates and public keys for verifying signatures", + "summary": "Upload notation certificates for verifying signatures", "parameters": [ - { - "enum": [ - "signatures" - ], - "type": "string", - "description": "specify resource", - "name": "resource", - "in": "query", - "required": true - }, - { - "enum": [ - "cosign", - "notation" - ], - "type": "string", - "description": "specify signing tool", - "name": "tool", - "in": "query", - "required": true - }, { "type": "string", "description": "truststore type", @@ -221,7 +365,7 @@ const docTemplate = `{ "in": "query" }, { - "description": "Public key or Certificate content", + "description": "Certificate content", "name": "requestBody", "in": "body", "required": true, @@ -992,6 +1136,20 @@ const docTemplate = `{ } }, "definitions": { + "api.APIKeyPayload": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "api.ExtensionList": { "type": "object", "properties": { @@ -1013,6 +1171,10 @@ const docTemplate = `{ "type": "string" } }, + "artifactType": { + "description": "ArtifactType specifies the IANA media type of artifact when the manifest is used for an artifact.", + "type": "string" + }, "manifests": { "description": "Manifests references platform specific manifests.", "type": "array", @@ -1027,6 +1189,14 @@ const docTemplate = `{ "schemaVersion": { "description": "SchemaVersion is the image manifest schema that this image follows", "type": "integer" + }, + "subject": { + "description": "Subject is an optional link from the image manifest to another manifest forming an association between the image manifest and the other manifest.", + "allOf": [ + { + "$ref": "#/definitions/github_com_opencontainers_image-spec_specs-go_v1.Descriptor" + } + ] } } }, @@ -1118,6 +1288,9 @@ const docTemplate = `{ "type": "string" } } + }, + "openid": { + "$ref": "#/definitions/extensions.OpenIDConfig" } } }, @@ -1160,6 +1333,20 @@ const docTemplate = `{ } } }, + "extensions.OpenIDConfig": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/extensions.OpenIDProviderConfig" + } + } + } + }, + "extensions.OpenIDProviderConfig": { + "type": "object" + }, "extensions.StrippedConfig": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 7ef9a0f4..48993635 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -11,6 +11,126 @@ "version": "v1.1.0-dev" }, "paths": { + "/auth/apikey": { + "post": { + "description": "Can create an api key for a logged in user, based on the provided label and scopes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Create an API key for the current user", + "parameters": [ + { + "description": "api token id (UUID)", + "name": "id", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.APIKeyPayload" + } + } + ], + "responses": { + "201": { + "description": "created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Revokes one current user API key based on given key ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Revokes one current user API key", + "parameters": [ + { + "type": "string", + "description": "api token id (UUID)", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout by removing current session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Logout by removing current session", + "responses": { + "200": { + "description": "ok\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } + } + }, "/oras/artifacts/v1/{name}/manifests/{digest}/referrers": { "get": { "description": "Get references for an image given a digest and artifact type", @@ -132,6 +252,49 @@ } } }, + "/v2/_zot/ext/cosign": { + "post": { + "description": "Upload cosign public keys for verifying signatures", + "consumes": [ + "application/octet-stream" + ], + "produces": [ + "application/json" + ], + "summary": "Upload cosign public keys for verifying signatures", + "parameters": [ + { + "description": "Public key content", + "name": "requestBody", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } + } + }, "/v2/_zot/ext/mgmt": { "get": { "description": "Get current server configuration", @@ -167,38 +330,19 @@ } } } - }, + } + }, + "/v2/_zot/ext/notation": { "post": { - "description": "Upload certificates and public keys for verifying signatures", + "description": "Upload notation certificates for verifying signatures", "consumes": [ "application/octet-stream" ], "produces": [ "application/json" ], - "summary": "Upload certificates and public keys for verifying signatures", + "summary": "Upload notation certificates for verifying signatures", "parameters": [ - { - "enum": [ - "signatures" - ], - "type": "string", - "description": "specify resource", - "name": "resource", - "in": "query", - "required": true - }, - { - "enum": [ - "cosign", - "notation" - ], - "type": "string", - "description": "specify signing tool", - "name": "tool", - "in": "query", - "required": true - }, { "type": "string", "description": "truststore type", @@ -212,7 +356,7 @@ "in": "query" }, { - "description": "Public key or Certificate content", + "description": "Certificate content", "name": "requestBody", "in": "body", "required": true, @@ -983,6 +1127,20 @@ } }, "definitions": { + "api.APIKeyPayload": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "api.ExtensionList": { "type": "object", "properties": { @@ -1004,6 +1162,10 @@ "type": "string" } }, + "artifactType": { + "description": "ArtifactType specifies the IANA media type of artifact when the manifest is used for an artifact.", + "type": "string" + }, "manifests": { "description": "Manifests references platform specific manifests.", "type": "array", @@ -1018,6 +1180,14 @@ "schemaVersion": { "description": "SchemaVersion is the image manifest schema that this image follows", "type": "integer" + }, + "subject": { + "description": "Subject is an optional link from the image manifest to another manifest forming an association between the image manifest and the other manifest.", + "allOf": [ + { + "$ref": "#/definitions/github_com_opencontainers_image-spec_specs-go_v1.Descriptor" + } + ] } } }, @@ -1109,6 +1279,9 @@ "type": "string" } } + }, + "openid": { + "$ref": "#/definitions/extensions.OpenIDConfig" } } }, @@ -1151,6 +1324,20 @@ } } }, + "extensions.OpenIDConfig": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/extensions.OpenIDProviderConfig" + } + } + } + }, + "extensions.OpenIDProviderConfig": { + "type": "object" + }, "extensions.StrippedConfig": { "type": "object", "properties": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 0d4bfe6e..cb0f2dfa 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1,4 +1,13 @@ definitions: + api.APIKeyPayload: + properties: + label: + type: string + scopes: + items: + type: string + type: array + type: object api.ExtensionList: properties: extensions: @@ -13,6 +22,10 @@ definitions: type: string description: Annotations contains arbitrary metadata for the image index. type: object + artifactType: + description: ArtifactType specifies the IANA media type of artifact when the + manifest is used for an artifact. + type: string manifests: description: Manifests references platform specific manifests. items: @@ -25,6 +38,12 @@ definitions: schemaVersion: description: SchemaVersion is the image manifest schema that this image follows type: integer + subject: + allOf: + - $ref: '#/definitions/github_com_opencontainers_image-spec_specs-go_v1.Descriptor' + description: Subject is an optional link from the image manifest to another + manifest forming an association between the image manifest and the other + manifest. type: object api.ImageManifest: properties: @@ -89,6 +108,8 @@ definitions: address: type: string type: object + openid: + $ref: '#/definitions/extensions.OpenIDConfig' type: object extensions.BearerConfig: properties: @@ -115,6 +136,15 @@ definitions: path: type: string type: object + extensions.OpenIDConfig: + properties: + providers: + additionalProperties: + $ref: '#/definitions/extensions.OpenIDProviderConfig' + type: object + type: object + extensions.OpenIDProviderConfig: + type: object extensions.StrippedConfig: properties: binaryType: @@ -206,6 +236,86 @@ info: title: Open Container Initiative Distribution Specification version: v1.1.0-dev paths: + /auth/apikey: + delete: + consumes: + - application/json + description: Revokes one current user API key based on given key ID + parameters: + - description: api token id (UUID) + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: ok + schema: + type: string + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "500": + description: internal server error + schema: + type: string + summary: Revokes one current user API key + post: + consumes: + - application/json + description: Can create an api key for a logged in user, based on the provided + label and scopes. + parameters: + - description: api token id (UUID) + in: body + name: id + required: true + schema: + $ref: '#/definitions/api.APIKeyPayload' + produces: + - application/json + responses: + "201": + description: created + schema: + type: string + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "500": + description: internal server error + schema: + type: string + summary: Create an API key for the current user + /auth/logout: + post: + consumes: + - application/json + description: Logout by removing current session + produces: + - application/json + responses: + "200": + description: ok". + schema: + type: string + "500": + description: internal server error". + schema: + type: string + summary: Logout by removing current session /oras/artifacts/v1/{name}/manifests/{digest}/referrers: get: consumes: @@ -286,6 +396,34 @@ paths: schema: $ref: '#/definitions/api.ExtensionList' summary: List Registry level extensions + /v2/_zot/ext/cosign: + post: + consumes: + - application/octet-stream + description: Upload cosign public keys for verifying signatures + parameters: + - description: Public key content + in: body + name: requestBody + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: ok + schema: + type: string + "400": + description: bad request". + schema: + type: string + "500": + description: internal server error". + schema: + type: string + summary: Upload cosign public keys for verifying signatures /v2/_zot/ext/mgmt: get: consumes: @@ -310,26 +448,12 @@ paths: schema: type: string summary: Get current server configuration + /v2/_zot/ext/notation: post: consumes: - application/octet-stream - description: Upload certificates and public keys for verifying signatures + description: Upload notation certificates for verifying signatures parameters: - - description: specify resource - enum: - - signatures - in: query - name: resource - required: true - type: string - - description: specify signing tool - enum: - - cosign - - notation - in: query - name: tool - required: true - type: string - description: truststore type in: query name: truststoreType @@ -338,7 +462,7 @@ paths: in: query name: truststoreName type: string - - description: Public key or Certificate content + - description: Certificate content in: body name: requestBody required: true @@ -359,7 +483,7 @@ paths: description: internal server error". schema: type: string - summary: Upload certificates and public keys for verifying signatures + summary: Upload notation certificates for verifying signatures /v2/_zot/ext/userprefs: put: consumes: diff --git a/test/blackbox/metadata.bats b/test/blackbox/metadata.bats index b18690d8..8e162625 100644 --- a/test/blackbox/metadata.bats +++ b/test/blackbox/metadata.bats @@ -25,6 +25,9 @@ function setup_file() { "extensions": { "search": { "enable": true + }, + "ui": { + "enable": true } }, "http": {