mirror of
https://github.com/project-zot/zot.git
synced 2026-06-16 04:17:55 +08:00
refactor(extensions)!: refactor the extensions URLs and errors (#1636)
BREAKING CHANGE: The functionality provided by the mgmt endpoint has beed redesigned - see details below BREAKING CHANGE: The API keys endpoint has been moved - see details below BREAKING CHANGE: The mgmt extension config has been removed - endpoint is now enabled by having both the search and the ui extensions enabled BREAKING CHANGE: The API keys configuration has been moved from extensions to http>auth>apikey mgmt and imagetrust extensions: - separate the _zot/ext/mgmt into 3 separate endpoints: _zot/ext/auth, _zot/ext/notation, _zot/ext/cosign - signature verification logic is in a separate `imagetrust` extension - better hanling or errors in case of signature uploads: logging and error codes (more 400 and less 500 errors) - add authz on signature uploads (and add a new middleware in common for this purpose) - remove the mgmt extension configuration - it is now enabled if the UI and the search extensions are enabled userprefs estension: - userprefs are enabled if both search and ui extensions are enabled (as opposed to just search) apikey extension is removed and logic moved into the api folder - Move apikeys code out of pkg/extensions and into pkg/api - Remove apikey configuration options from the extensions configuration and move it inside the http auth section - remove the build label apikeys other changes: - move most of the logic adding handlers to the extensions endpoints out of routes.go and into the extensions files. - add warnings in case the users are still using configurations with the obsolete settings for mgmt and api keys - add a new function in the extension package which could be a single point of starting backgroud tasks for all extensions - more clear methods for verifying specific extensions are enabled - fix http methods paired with the UI handlers - rebuild swagger docs Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
This commit is contained in:
+25
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
//go:build mgmt
|
||||
// +build mgmt
|
||||
|
||||
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"
|
||||
|
||||
"zotregistry.io/zot/pkg/api"
|
||||
"zotregistry.io/zot/pkg/api/config"
|
||||
"zotregistry.io/zot/pkg/api/constants"
|
||||
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
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
)
|
||||
|
||||
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() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
|
||||
htpasswdPath := test.MakeHtpasswdFile()
|
||||
defer os.Remove(htpasswdPath)
|
||||
|
||||
mockOIDCServer, err := test.MockOIDCRun()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := mockOIDCServer.Shutdown()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
mockOIDCConfig := mockOIDCServer.Config()
|
||||
defaultVal := true
|
||||
|
||||
conf.HTTP.Auth = &config.AuthConfig{
|
||||
HTPasswd: config.AuthHTPasswd{
|
||||
Path: htpasswdPath,
|
||||
},
|
||||
OpenID: &config.OpenIDConfig{
|
||||
Providers: map[string]config.OpenIDProviderConfig{
|
||||
"dex": {
|
||||
ClientID: mockOIDCConfig.ClientID,
|
||||
ClientSecret: mockOIDCConfig.ClientSecret,
|
||||
KeyPath: "",
|
||||
Issuer: mockOIDCConfig.Issuer,
|
||||
Scopes: []string{"openid", "email", "groups"},
|
||||
},
|
||||
},
|
||||
},
|
||||
APIKey: defaultVal,
|
||||
}
|
||||
|
||||
conf.HTTP.AccessControl = &config.AccessControlConfig{}
|
||||
|
||||
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()
|
||||
|
||||
ctlr.Config.Storage.RootDirectory = dir
|
||||
|
||||
cm := test.NewControllerManager(ctlr)
|
||||
|
||||
cm.StartServer()
|
||||
defer cm.StopServer()
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
payload := api.APIKeyPayload{
|
||||
Label: "test",
|
||||
Scopes: []string{"test"},
|
||||
}
|
||||
reqBody, err := json.Marshal(payload)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("API key retrieved with basic auth", func() {
|
||||
// call endpoint with session ( added to client after previous request)
|
||||
resp, err := resty.R().
|
||||
SetBody(reqBody).
|
||||
SetBasicAuth("test", "test").
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
user := mockoidc.DefaultUser()
|
||||
|
||||
// get API key and email from apikey route response
|
||||
var apiKeyResponse apiKeyResponse
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
email := user.Email
|
||||
So(email, ShouldNotBeEmpty)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetBasicAuth("test", apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// add another one
|
||||
resp, err = resty.R().
|
||||
SetBody(reqBody).
|
||||
SetBasicAuth("test", "test").
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = resty.R().
|
||||
SetBasicAuth("test", apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("API key retrieved with openID", func() {
|
||||
client := resty.New()
|
||||
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
|
||||
|
||||
// first login user
|
||||
resp, err := client.R().
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
SetQueryParam("provider", "dex").
|
||||
Get(baseURL + constants.LoginPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
|
||||
cookies := resp.Cookies()
|
||||
|
||||
// call endpoint without session
|
||||
resp, err = client.R().
|
||||
SetBody(reqBody).
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
client.SetCookies(cookies)
|
||||
|
||||
// call endpoint with session ( added to client after previous request)
|
||||
resp, err = client.R().
|
||||
SetBody(reqBody).
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
user := mockoidc.DefaultUser()
|
||||
|
||||
// get API key and email from apikey route response
|
||||
var apiKeyResponse apiKeyResponse
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
email := user.Email
|
||||
So(email, ShouldNotBeEmpty)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// trigger errors
|
||||
ctlr.MetaDB = mocks.MetaDBMock{
|
||||
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
|
||||
return "", ErrUnexpectedError
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
|
||||
|
||||
ctlr.MetaDB = mocks.MetaDBMock{
|
||||
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
|
||||
return user.Email, nil
|
||||
},
|
||||
GetUserGroupsFn: func(ctx context.Context) ([]string, error) {
|
||||
return []string{}, ErrUnexpectedError
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
|
||||
|
||||
ctlr.MetaDB = mocks.MetaDBMock{
|
||||
GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) {
|
||||
return user.Email, nil
|
||||
},
|
||||
UpdateUserAPIKeyLastUsedFn: func(ctx context.Context, hashedKey string) error {
|
||||
return ErrUnexpectedError
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
|
||||
|
||||
client = resty.New()
|
||||
|
||||
// call endpoint without session
|
||||
resp, err = client.R().
|
||||
SetBody(reqBody).
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
Convey("Login with openid and create API key", func() {
|
||||
client := resty.New()
|
||||
|
||||
// mgmt should work both unauthenticated and authenticated
|
||||
resp, err := client.R().Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
|
||||
// first login user
|
||||
resp, err = client.R().
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
SetQueryParam("provider", "dex").
|
||||
Get(baseURL + constants.LoginPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
client.SetCookies(resp.Cookies())
|
||||
|
||||
// call endpoint with session ( added to client after previous request)
|
||||
resp, err = client.R().
|
||||
SetBody(reqBody).
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
var apiKeyResponse apiKeyResponse
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
user := mockoidc.DefaultUser()
|
||||
email := user.Email
|
||||
So(email, ShouldNotBeEmpty)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// auth with API key
|
||||
// we need new client without session cookie set
|
||||
client = resty.New()
|
||||
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// invalid api keys
|
||||
resp, err = client.R().
|
||||
SetBasicAuth("invalidEmail", apiKeyResponse.APIKey).
|
||||
Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, "noprefixAPIKey").
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, "zak_notworkingAPIKey").
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
authzCtxKey := localCtx.GetContextKey()
|
||||
|
||||
acCtx := localCtx.AccessControlContext{
|
||||
Username: email,
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), authzCtxKey, acCtx)
|
||||
|
||||
err = ctlr.MetaDB.DeleteUserData(ctx)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
|
||||
|
||||
client = resty.New()
|
||||
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
|
||||
|
||||
// without creds should work
|
||||
resp, err = client.R().Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// login again
|
||||
resp, err = client.R().
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
SetQueryParam("provider", "dex").
|
||||
Get(baseURL + constants.LoginPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
client.SetCookies(resp.Cookies())
|
||||
|
||||
resp, err = client.R().
|
||||
SetBody(reqBody).
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Post(baseURL + constants.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
||||
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// should work with session
|
||||
resp, err = client.R().
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
// should work with api key
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + constants.FullMgmt)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
||||
|
||||
err = json.Unmarshal(resp.Body(), &apiKeyResponse)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// delete api key
|
||||
resp, err = client.R().
|
||||
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
|
||||
SetQueryParam("id", apiKeyResponse.UUID).
|
||||
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.APIKeyPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth(email, apiKeyResponse.APIKey).
|
||||
Get(baseURL + "/v2/_catalog")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
resp, err = client.R().
|
||||
SetBasicAuth("test", "test").
|
||||
SetQueryParam("id", apiKeyResponse.UUID).
|
||||
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.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIKeysOpenDBError(t *testing.T) {
|
||||
Convey("Test API keys - unable to create database", t, func() {
|
||||
conf := config.New()
|
||||
htpasswdPath := test.MakeHtpasswdFile()
|
||||
defer os.Remove(htpasswdPath)
|
||||
|
||||
mockOIDCServer, err := test.MockOIDCRun()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := mockOIDCServer.Shutdown()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
mockOIDCConfig := mockOIDCServer.Config()
|
||||
defaultVal := true
|
||||
|
||||
conf.HTTP.Auth = &config.AuthConfig{
|
||||
HTPasswd: config.AuthHTPasswd{
|
||||
Path: htpasswdPath,
|
||||
},
|
||||
|
||||
OpenID: &config.OpenIDConfig{
|
||||
Providers: map[string]config.OpenIDProviderConfig{
|
||||
"dex": {
|
||||
ClientID: mockOIDCConfig.ClientID,
|
||||
ClientSecret: mockOIDCConfig.ClientSecret,
|
||||
KeyPath: "",
|
||||
Issuer: mockOIDCConfig.Issuer,
|
||||
Scopes: []string{"openid", "email"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
APIKey: defaultVal,
|
||||
}
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
dir := t.TempDir()
|
||||
|
||||
err = os.Chmod(dir, 0o000)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
ctlr.Config.Storage.RootDirectory = dir
|
||||
cm := test.NewControllerManager(ctlr)
|
||||
|
||||
So(func() {
|
||||
cm.StartServer()
|
||||
}, 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+88
-39
@@ -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) {
|
||||
|
||||
+150
-37
@@ -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/<name>/blobs/uploads/<session-id>.
|
||||
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
|
||||
}
|
||||
|
||||
+17
-25
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user