mirror of
https://github.com/project-zot/zot.git
synced 2024-12-16 21:56:37 -05:00
graphql: Apply authorization on /_search endpoint
- AccessControlContext now resides in a separate package from where it can be imported, along with the contextKey that will be used to set and retrieve this context value. - AccessControlContext has a new field called Username, that will be of use for future implementations in graphQL resolvers. - GlobalSearch resolver now uses this context to filter repos available to the logged user. - moved logic for uploading images in tests so that it can be used in every package - tests were added for multiple request scenarios, when zot-server requires authz on specific repos - added tests with injected errors for extended coverage - added tests for status code error injection utilities Closes https://github.com/project-zot/zot/issues/615 Signed-off-by: Alex Stan <alexandrustan96@yahoo.ro>
This commit is contained in:
parent
5450139ba1
commit
49e8167dbe
15 changed files with 763 additions and 165 deletions
|
@ -8,8 +8,8 @@
|
|||
"port": "8080",
|
||||
"realm": "zot",
|
||||
"tls": {
|
||||
"cert": "test/data/server.cert",
|
||||
"key": "test/data/server.key"
|
||||
"cert": "../../test/data/server.cert",
|
||||
"key": "../../test/data/server.key"
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
glob "github.com/bmatcuk/doublestar/v4"
|
||||
|
@ -12,19 +13,15 @@ import (
|
|||
"zotregistry.io/zot/pkg/api/constants"
|
||||
"zotregistry.io/zot/pkg/common"
|
||||
"zotregistry.io/zot/pkg/log"
|
||||
localCtx "zotregistry.io/zot/pkg/requestcontext"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
// actions.
|
||||
CREATE = "create"
|
||||
READ = "read"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
|
||||
// request-local context key.
|
||||
authzCtxKey contextKey = 0
|
||||
)
|
||||
|
||||
// AccessController authorizes users to act on resources.
|
||||
|
@ -33,12 +30,6 @@ type AccessController struct {
|
|||
Log log.Logger
|
||||
}
|
||||
|
||||
// AccessControlContext context passed down to http.Handlers.
|
||||
type AccessControlContext struct {
|
||||
globPatterns map[string]bool
|
||||
isAdmin bool
|
||||
}
|
||||
|
||||
func NewAccessController(config *config.Config) *AccessController {
|
||||
return &AccessController{
|
||||
Config: config.AccessControl,
|
||||
|
@ -111,14 +102,18 @@ func (ac *AccessController) isAdmin(username string) bool {
|
|||
// getContext builds ac context(allowed to read repos and if user is admin) and returns it.
|
||||
func (ac *AccessController) getContext(username string, request *http.Request) context.Context {
|
||||
readGlobPatterns := ac.getReadGlobPatterns(username)
|
||||
acCtx := AccessControlContext{globPatterns: readGlobPatterns}
|
||||
|
||||
if ac.isAdmin(username) {
|
||||
acCtx.isAdmin = true
|
||||
} else {
|
||||
acCtx.isAdmin = false
|
||||
acCtx := localCtx.AccessControlContext{
|
||||
GlobPatterns: readGlobPatterns,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
if ac.isAdmin(username) {
|
||||
acCtx.IsAdmin = true
|
||||
} else {
|
||||
acCtx.IsAdmin = false
|
||||
}
|
||||
|
||||
authzCtxKey := localCtx.GetContextKey()
|
||||
ctx := context.WithValue(request.Context(), authzCtxKey, acCtx)
|
||||
|
||||
return ctx
|
||||
|
@ -227,6 +222,12 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if strings.Contains(request.RequestURI, constants.ExtSearchPrefix) {
|
||||
next.ServeHTTP(response, request.WithContext(ctx))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var action string
|
||||
if request.Method == http.MethodGet || request.Method == http.MethodHead {
|
||||
action = READ
|
||||
|
|
|
@ -317,7 +317,6 @@ func TestHtpasswdTwoCreds(t *testing.T) {
|
|||
}
|
||||
ctlr := api.NewController(conf)
|
||||
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
||||
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
@ -5701,7 +5700,7 @@ func TestInjectTooManyOpenFiles(t *testing.T) {
|
|||
|
||||
func TestPeriodicGC(t *testing.T) {
|
||||
Convey("Periodic gc enabled for default store", t, func() {
|
||||
repoName := "test"
|
||||
repoName := "testRepo"
|
||||
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
@ -5858,6 +5857,163 @@ func TestPeriodicTasks(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestSearchRoutes(t *testing.T) {
|
||||
Convey("Upload image for test", t, func(c C) {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
ctlr.Config.Storage.RootDirectory = tempDir
|
||||
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
repoName := "testrepo"
|
||||
inaccessibleRepo := "inaccessible"
|
||||
cfg, layers, manifest, err := test.GetImageComponents(10000)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
err = test.UploadImage(
|
||||
test.Image{
|
||||
Config: cfg,
|
||||
Layers: layers,
|
||||
Manifest: manifest,
|
||||
Tag: "latest",
|
||||
}, baseURL, repoName)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// data for the inaccessible repo
|
||||
cfg, layers, manifest, err = test.GetImageComponents(10000)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
err = test.UploadImage(
|
||||
test.Image{
|
||||
Config: cfg,
|
||||
Layers: layers,
|
||||
Manifest: manifest,
|
||||
Tag: "latest",
|
||||
}, baseURL, inaccessibleRepo)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("GlobalSearch with authz enabled", func(c C) {
|
||||
conf := config.New()
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
user1 := "test"
|
||||
password1 := "test"
|
||||
testString1 := getCredString(user1, password1)
|
||||
htpasswdPath := test.MakeHtpasswdFileFromString(testString1)
|
||||
defer os.Remove(htpasswdPath)
|
||||
conf.HTTP.Auth = &config.AuthConfig{
|
||||
HTPasswd: config.AuthHTPasswd{
|
||||
Path: htpasswdPath,
|
||||
},
|
||||
}
|
||||
|
||||
conf.HTTP.Port = port
|
||||
|
||||
defaultVal := true
|
||||
|
||||
searchConfig := &extconf.SearchConfig{
|
||||
Enable: &defaultVal,
|
||||
}
|
||||
|
||||
conf.Extensions = &extconf.ExtensionConfig{
|
||||
Search: searchConfig,
|
||||
}
|
||||
|
||||
conf.AccessControl = &config.AccessControlConfig{
|
||||
Repositories: config.Repositories{
|
||||
repoName: config.PolicyGroup{
|
||||
Policies: []config.Policy{
|
||||
{
|
||||
Users: []string{user1},
|
||||
Actions: []string{"read"},
|
||||
},
|
||||
},
|
||||
DefaultPolicy: []string{},
|
||||
},
|
||||
inaccessibleRepo: config.PolicyGroup{
|
||||
Policies: []config.Policy{
|
||||
{
|
||||
Users: []string{},
|
||||
Actions: []string{},
|
||||
},
|
||||
},
|
||||
DefaultPolicy: []string{},
|
||||
},
|
||||
},
|
||||
AdminPolicy: config.Policy{
|
||||
Users: []string{},
|
||||
Actions: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
|
||||
ctlr.Config.Storage.RootDirectory = tempDir
|
||||
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
query := `
|
||||
{
|
||||
GlobalSearch(query:""){
|
||||
Repos {
|
||||
Name
|
||||
Score
|
||||
NewestImage {
|
||||
RepoName
|
||||
Tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.ExtSearchPrefix +
|
||||
"?query=" + url.QueryEscape(query))
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, 200)
|
||||
|
||||
So(string(resp.Body()), ShouldContainSubstring, repoName)
|
||||
So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo)
|
||||
|
||||
resp, err = resty.R().Get(baseURL + constants.ExtSearchPrefix + "?query=" + url.QueryEscape(query))
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
|
||||
// credentials for user unauthorized to access repo
|
||||
user2 := "notWorking"
|
||||
password2 := "notWorking"
|
||||
testString2 := getCredString(user2, password2)
|
||||
htpasswdPath2 := test.MakeHtpasswdFileFromString(testString2)
|
||||
defer os.Remove(htpasswdPath2)
|
||||
|
||||
ctlr.Config.HTTP.Auth = &config.AuthConfig{
|
||||
HTPasswd: config.AuthHTPasswd{
|
||||
Path: htpasswdPath2,
|
||||
},
|
||||
}
|
||||
// authenticated, but no access to resource
|
||||
resp, err = resty.R().SetBasicAuth(user2, password2).Get(baseURL + constants.ExtSearchPrefix +
|
||||
"?query=" + url.QueryEscape(query))
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDistSpecExtensions(t *testing.T) {
|
||||
Convey("start zot server with search extension", t, func(c C) {
|
||||
conf := config.New()
|
||||
|
|
|
@ -34,6 +34,7 @@ import (
|
|||
"zotregistry.io/zot/pkg/api/constants"
|
||||
ext "zotregistry.io/zot/pkg/extensions"
|
||||
"zotregistry.io/zot/pkg/log"
|
||||
localCtx "zotregistry.io/zot/pkg/requestcontext"
|
||||
"zotregistry.io/zot/pkg/storage"
|
||||
"zotregistry.io/zot/pkg/test" // nolint:goimports
|
||||
// as required by swaggo.
|
||||
|
@ -1240,9 +1241,11 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
|
|||
}
|
||||
|
||||
var repos []string
|
||||
authzCtxKey := localCtx.GetContextKey()
|
||||
|
||||
// get passed context from authzHandler and filter out repos based on permissions
|
||||
if authCtx := request.Context().Value(authzCtxKey); authCtx != nil {
|
||||
acCtx, ok := authCtx.(AccessControlContext)
|
||||
acCtx, ok := authCtx.(localCtx.AccessControlContext)
|
||||
if !ok {
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
|
@ -1250,7 +1253,7 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
|
|||
}
|
||||
|
||||
for _, r := range combineRepoList {
|
||||
if acCtx.isAdmin || matchesRepo(acCtx.globPatterns, r) {
|
||||
if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) {
|
||||
repos = append(repos, r)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,8 +71,8 @@ func SetupSearchRoutes(config *config.Config, router *mux.Router, storeControlle
|
|||
resConfig = search.GetResolverConfig(log, storeController, false)
|
||||
}
|
||||
|
||||
router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST").
|
||||
Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig)))
|
||||
graphqlPrefix := router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST")
|
||||
graphqlPrefix.Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
@ -19,7 +18,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/opencontainers/image-spec/specs-go"
|
||||
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/sigstore/cosign/cmd/cosign/cli/generate"
|
||||
"github.com/sigstore/cosign/cmd/cosign/cli/options"
|
||||
|
@ -44,8 +42,6 @@ const (
|
|||
|
||||
var (
|
||||
ErrTestError = errors.New("test error")
|
||||
ErrPutBlob = errors.New("can't put blob")
|
||||
ErrPostBlob = errors.New("can't post blob")
|
||||
ErrPutManifest = errors.New("can't put manifest")
|
||||
)
|
||||
|
||||
|
@ -1043,7 +1039,7 @@ func TestSearchSize(t *testing.T) {
|
|||
WaitTillServerReady(baseURL)
|
||||
|
||||
repoName := "testrepo"
|
||||
config, layers, manifest, err := getImageComponents(10000)
|
||||
config, layers, manifest, err := GetImageComponents(10000)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
configBlob, err := json.Marshal(config)
|
||||
|
@ -1060,7 +1056,7 @@ func TestSearchSize(t *testing.T) {
|
|||
manifestSize := len(manifestBlob)
|
||||
|
||||
err = UploadImage(
|
||||
uploadImage{
|
||||
Image{
|
||||
Manifest: manifest,
|
||||
Config: config,
|
||||
Layers: layers,
|
||||
|
@ -1107,7 +1103,7 @@ func TestSearchSize(t *testing.T) {
|
|||
|
||||
// add the same image with different tag
|
||||
err = UploadImage(
|
||||
uploadImage{
|
||||
Image{
|
||||
Manifest: manifest,
|
||||
Config: config,
|
||||
Layers: layers,
|
||||
|
@ -1135,136 +1131,6 @@ func TestSearchSize(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func getImageComponents(layerSize int) (ispec.Image, [][]byte, ispec.Manifest, error) {
|
||||
config := ispec.Image{
|
||||
Architecture: "amd64",
|
||||
OS: "linux",
|
||||
RootFS: ispec.RootFS{
|
||||
Type: "layers",
|
||||
DiffIDs: []digest.Digest{},
|
||||
},
|
||||
Author: "ZotUser",
|
||||
}
|
||||
|
||||
configBlob, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return ispec.Image{}, [][]byte{}, ispec.Manifest{}, err
|
||||
}
|
||||
|
||||
configDigest := digest.FromBytes(configBlob)
|
||||
|
||||
layers := [][]byte{
|
||||
make([]byte, layerSize),
|
||||
}
|
||||
|
||||
manifest := ispec.Manifest{
|
||||
Versioned: specs.Versioned{
|
||||
SchemaVersion: 2,
|
||||
},
|
||||
Config: ispec.Descriptor{
|
||||
MediaType: "application/vnd.oci.image.config.v1+json",
|
||||
Digest: configDigest,
|
||||
Size: int64(len(configBlob)),
|
||||
},
|
||||
Layers: []ispec.Descriptor{
|
||||
{
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Digest: digest.FromBytes(layers[0]),
|
||||
Size: int64(len(layers[0])),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return config, layers, manifest, nil
|
||||
}
|
||||
|
||||
type uploadImage struct {
|
||||
Manifest ispec.Manifest
|
||||
Config ispec.Image
|
||||
Layers [][]byte
|
||||
Tag string
|
||||
}
|
||||
|
||||
func UploadImage(img uploadImage, baseURL, repo string) error {
|
||||
for _, blob := range img.Layers {
|
||||
resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusAccepted {
|
||||
return ErrPostBlob
|
||||
}
|
||||
|
||||
loc := resp.Header().Get("Location")
|
||||
|
||||
digest := digest.FromBytes(blob).String()
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetQueryParam("digest", digest).
|
||||
SetBody(blob).
|
||||
Put(baseURL + loc)
|
||||
|
||||
if resp.StatusCode() != http.StatusCreated {
|
||||
return ErrPutBlob
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// upload config
|
||||
cblob, err := json.Marshal(img.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cdigest := digest.FromBytes(cblob)
|
||||
|
||||
resp, err := resty.R().
|
||||
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusAccepted {
|
||||
return ErrPostBlob
|
||||
}
|
||||
|
||||
loc := Location(baseURL, resp)
|
||||
|
||||
// uploading blob should get 201
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetQueryParam("digest", cdigest.String()).
|
||||
SetBody(cblob).
|
||||
Put(loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusCreated {
|
||||
return ErrPutBlob
|
||||
}
|
||||
|
||||
// put manifest
|
||||
manifestBlob, err := json.Marshal(img.Manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = resty.R().
|
||||
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
|
||||
SetBody(manifestBlob).
|
||||
Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func startServer(c *api.Controller) {
|
||||
// this blocks
|
||||
ctx := context.Background()
|
||||
|
|
|
@ -5,19 +5,22 @@ package search
|
|||
// It serves as dependency injection for your app, add any dependencies you require here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
glob "github.com/bmatcuk/doublestar/v4"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
godigest "github.com/opencontainers/go-digest"
|
||||
"zotregistry.io/zot/pkg/log" // nolint: gci
|
||||
|
||||
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"zotregistry.io/zot/pkg/extensions/search/common"
|
||||
cveinfo "zotregistry.io/zot/pkg/extensions/search/cve"
|
||||
digestinfo "zotregistry.io/zot/pkg/extensions/search/digest"
|
||||
"zotregistry.io/zot/pkg/extensions/search/gql_generated"
|
||||
"zotregistry.io/zot/pkg/log" // nolint: gci
|
||||
localCtx "zotregistry.io/zot/pkg/requestcontext"
|
||||
"zotregistry.io/zot/pkg/storage"
|
||||
) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
|
||||
|
||||
|
@ -36,6 +39,8 @@ type cveDetail struct {
|
|||
PackageList []*gql_generated.PackageInfo
|
||||
}
|
||||
|
||||
var ErrBadCtxFormat = errors.New("type assertion failed")
|
||||
|
||||
// GetResolverConfig ...
|
||||
func GetResolverConfig(log log.Logger, storeController storage.StoreController, enableCVE bool) gql_generated.Config {
|
||||
var cveInfo *cveinfo.CveInfo
|
||||
|
@ -469,3 +474,47 @@ func buildImageInfo(repo string, tag string, tagDigest godigest.Digest,
|
|||
|
||||
return imageInfo
|
||||
}
|
||||
|
||||
// returns either a user has or not rights on 'repository'.
|
||||
func matchesRepo(globPatterns map[string]bool, repository string) bool {
|
||||
var longestMatchedPattern string
|
||||
|
||||
// because of the longest path matching rule, we need to check all patterns from config
|
||||
for pattern := range globPatterns {
|
||||
matched, err := glob.Match(pattern, repository)
|
||||
if err == nil {
|
||||
if matched && len(pattern) > len(longestMatchedPattern) {
|
||||
longestMatchedPattern = pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allowed := globPatterns[longestMatchedPattern]
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// get passed context from authzHandler and filter out repos based on permissions.
|
||||
func userAvailableRepos(ctx context.Context, repoList []string) ([]string, error) {
|
||||
var availableRepos []string
|
||||
|
||||
authzCtxKey := localCtx.GetContextKey()
|
||||
if authCtx := ctx.Value(authzCtxKey); authCtx != nil {
|
||||
acCtx, ok := authCtx.(localCtx.AccessControlContext)
|
||||
if !ok {
|
||||
err := ErrBadCtxFormat
|
||||
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
for _, r := range repoList {
|
||||
if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) {
|
||||
availableRepos = append(availableRepos, r)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
availableRepos = repoList
|
||||
}
|
||||
|
||||
return availableRepos, nil
|
||||
}
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
package search //nolint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
godigest "github.com/opencontainers/go-digest"
|
||||
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/rs/zerolog"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"zotregistry.io/zot/pkg/extensions/monitoring"
|
||||
"zotregistry.io/zot/pkg/extensions/search/common"
|
||||
"zotregistry.io/zot/pkg/log"
|
||||
localCtx "zotregistry.io/zot/pkg/requestcontext"
|
||||
"zotregistry.io/zot/pkg/storage"
|
||||
"zotregistry.io/zot/pkg/test/mocks"
|
||||
)
|
||||
|
||||
|
@ -172,6 +178,28 @@ func TestGlobalSearch(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestUserAvailableRepos(t *testing.T) {
|
||||
Convey("Type assertion fails", t, func() {
|
||||
var invalid struct{}
|
||||
|
||||
log := log.Logger{Logger: zerolog.New(os.Stdout)}
|
||||
dir := t.TempDir()
|
||||
metrics := monitoring.NewMetricsServer(false, log)
|
||||
defaultStore := storage.NewImageStore(dir, false, 0, false, false, log, metrics, nil)
|
||||
|
||||
repoList, err := defaultStore.GetRepositories()
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
ctx := context.TODO()
|
||||
key := localCtx.GetContextKey()
|
||||
ctx = context.WithValue(ctx, key, invalid)
|
||||
|
||||
repos, err := userAvailableRepos(ctx, repoList)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(repos, ShouldBeEmpty)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatching(t *testing.T) {
|
||||
pine := "pine"
|
||||
|
||||
|
|
|
@ -454,7 +454,14 @@ func (r *queryResolver) GlobalSearch(ctx context.Context, query string) (*gql_ge
|
|||
return &gql_generated.GlobalSearchResult{}, err
|
||||
}
|
||||
|
||||
repos, images, layers := globalSearch(repoList, name, tag, olu, r.log)
|
||||
availableRepos, err := userAvailableRepos(ctx, repoList)
|
||||
if err != nil {
|
||||
r.log.Error().Err(err).Msg("unable to filter user available repositories")
|
||||
|
||||
return &gql_generated.GlobalSearchResult{}, err
|
||||
}
|
||||
|
||||
repos, images, layers := globalSearch(availableRepos, name, tag, olu, r.log)
|
||||
|
||||
return &gql_generated.GlobalSearchResult{
|
||||
Images: images,
|
||||
|
|
18
pkg/requestcontext/context.go
Normal file
18
pkg/requestcontext/context.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package requestcontext
|
||||
|
||||
type Key int
|
||||
|
||||
// request-local context key.
|
||||
var authzCtxKey = Key(0) // nolint: gochecknoglobals
|
||||
|
||||
// pointer needed for use in context.WithValue.
|
||||
func GetContextKey() *Key {
|
||||
return &authzCtxKey
|
||||
}
|
||||
|
||||
// AccessControlContext context passed down to http.Handlers.
|
||||
type AccessControlContext struct {
|
||||
GlobPatterns map[string]bool
|
||||
IsAdmin bool
|
||||
Username string
|
||||
}
|
|
@ -4,17 +4,20 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
godigest "github.com/opencontainers/go-digest"
|
||||
"github.com/opencontainers/image-spec/specs-go"
|
||||
imagespec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/opencontainers/umoci"
|
||||
"github.com/phayes/freeport"
|
||||
|
@ -27,6 +30,18 @@ const (
|
|||
SleepTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPostBlob = errors.New("can't post blob")
|
||||
ErrPutBlob = errors.New("can't put blob")
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
Manifest imagespec.Manifest
|
||||
Config imagespec.Image
|
||||
Layers [][]byte
|
||||
Tag string
|
||||
}
|
||||
|
||||
func GetFreePort() string {
|
||||
port, err := freeport.GetFreePort()
|
||||
if err != nil {
|
||||
|
@ -264,3 +279,127 @@ func GetOciLayoutDigests(imagePath string) (godigest.Digest, godigest.Digest, go
|
|||
|
||||
return manifestDigest, configDigest, layerDigest
|
||||
}
|
||||
|
||||
func GetImageComponents(layerSize int) (imagespec.Image, [][]byte, imagespec.Manifest, error) {
|
||||
config := imagespec.Image{
|
||||
Architecture: "amd64",
|
||||
OS: "linux",
|
||||
RootFS: imagespec.RootFS{
|
||||
Type: "layers",
|
||||
DiffIDs: []godigest.Digest{},
|
||||
},
|
||||
Author: "ZotUser",
|
||||
}
|
||||
|
||||
configBlob, err := json.Marshal(config)
|
||||
if err = Error(err); err != nil {
|
||||
return imagespec.Image{}, [][]byte{}, imagespec.Manifest{}, err
|
||||
}
|
||||
|
||||
configDigest := godigest.FromBytes(configBlob)
|
||||
|
||||
layers := [][]byte{
|
||||
make([]byte, layerSize),
|
||||
}
|
||||
|
||||
schemaVersion := 2
|
||||
|
||||
manifest := imagespec.Manifest{
|
||||
Versioned: specs.Versioned{
|
||||
SchemaVersion: schemaVersion,
|
||||
},
|
||||
Config: imagespec.Descriptor{
|
||||
MediaType: "application/vnd.oci.image.config.v1+json",
|
||||
Digest: configDigest,
|
||||
Size: int64(len(configBlob)),
|
||||
},
|
||||
Layers: []imagespec.Descriptor{
|
||||
{
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Digest: godigest.FromBytes(layers[0]),
|
||||
Size: int64(len(layers[0])),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return config, layers, manifest, nil
|
||||
}
|
||||
|
||||
func UploadImage(img Image, baseURL, repo string) error {
|
||||
for _, blob := range img.Layers {
|
||||
resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusAccepted {
|
||||
return ErrPostBlob
|
||||
}
|
||||
|
||||
loc := resp.Header().Get("Location")
|
||||
|
||||
digest := godigest.FromBytes(blob).String()
|
||||
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetQueryParam("digest", digest).
|
||||
SetBody(blob).
|
||||
Put(baseURL + loc)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusCreated {
|
||||
return ErrPutBlob
|
||||
}
|
||||
}
|
||||
// upload config
|
||||
cblob, err := json.Marshal(img.Config)
|
||||
if err = Error(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cdigest := godigest.FromBytes(cblob)
|
||||
|
||||
resp, err := resty.R().
|
||||
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
|
||||
if err = Error(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ErrStatusCode(resp.StatusCode()) != http.StatusAccepted && ErrStatusCode(resp.StatusCode()) == -1 {
|
||||
return ErrPostBlob
|
||||
}
|
||||
|
||||
loc := Location(baseURL, resp)
|
||||
|
||||
// uploading blob should get 201
|
||||
resp, err = resty.R().
|
||||
SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))).
|
||||
SetHeader("Content-Type", "application/octet-stream").
|
||||
SetQueryParam("digest", cdigest.String()).
|
||||
SetBody(cblob).
|
||||
Put(loc)
|
||||
if err = Error(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ErrStatusCode(resp.StatusCode()) != http.StatusCreated && ErrStatusCode(resp.StatusCode()) == -1 {
|
||||
return ErrPostBlob
|
||||
}
|
||||
|
||||
// put manifest
|
||||
manifestBlob, err := json.Marshal(img.Manifest)
|
||||
if err = Error(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = resty.R().
|
||||
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
|
||||
SetBody(manifestBlob).
|
||||
Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -4,14 +4,18 @@
|
|||
package test_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"zotregistry.io/zot/pkg/api"
|
||||
"zotregistry.io/zot/pkg/api/config"
|
||||
"zotregistry.io/zot/pkg/test"
|
||||
)
|
||||
|
||||
|
@ -122,3 +126,283 @@ func TestGetOciLayoutDigests(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetImageComponents(t *testing.T) {
|
||||
Convey("Inject failures for unreachable lines", t, func() {
|
||||
injected := test.InjectFailure(0)
|
||||
if injected {
|
||||
_, _, _, err := test.GetImageComponents(100)
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
Convey("finishes successfully", t, func() {
|
||||
_, _, _, err := test.GetImageComponents(100)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUploadImage(t *testing.T) {
|
||||
Convey("Post request results in an error", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = t.TempDir()
|
||||
|
||||
img := test.Image{
|
||||
Layers: make([][]byte, 10),
|
||||
}
|
||||
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("Post request status differs from accepted", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = tempDir
|
||||
|
||||
err := os.Chmod(tempDir, 0o400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
img := test.Image{
|
||||
Layers: make([][]byte, 10),
|
||||
}
|
||||
|
||||
err = test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("Put request results in an error", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = t.TempDir()
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
img := test.Image{
|
||||
Layers: make([][]byte, 10), // invalid format that will result in an error
|
||||
Config: ispec.Image{},
|
||||
}
|
||||
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("Image uploaded successfully", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = t.TempDir()
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
layerBlob := []byte("test")
|
||||
|
||||
img := test.Image{
|
||||
Layers: [][]byte{
|
||||
layerBlob,
|
||||
}, // invalid format that will result in an error
|
||||
Config: ispec.Image{},
|
||||
}
|
||||
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Blob upload wrong response status code", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = tempDir
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
layerBlob := []byte("test")
|
||||
layerBlobDigest := digest.FromBytes(layerBlob)
|
||||
layerPath := path.Join(tempDir, "test", "blobs", "sha256")
|
||||
|
||||
if _, err := os.Stat(layerPath); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(layerPath, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.Create(path.Join(layerPath, layerBlobDigest.Encoded()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Chmod(layerPath, 0o000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
err = os.Chmod(layerPath, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.RemoveAll(file.Name())
|
||||
}()
|
||||
}
|
||||
|
||||
img := test.Image{
|
||||
Layers: [][]byte{
|
||||
layerBlob,
|
||||
}, // invalid format that will result in an error
|
||||
Config: ispec.Image{},
|
||||
}
|
||||
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("CreateBlobUpload wrong response status code", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = tempDir
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
layerBlob := []byte("test")
|
||||
|
||||
img := test.Image{
|
||||
Layers: [][]byte{
|
||||
layerBlob,
|
||||
}, // invalid format that will result in an error
|
||||
Config: ispec.Image{},
|
||||
}
|
||||
|
||||
Convey("CreateBlobUpload", func() {
|
||||
injected := test.InjectFailure(2)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
Convey("UpdateBlobUpload", func() {
|
||||
injected := test.InjectFailure(4)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestInjectUploadImage(t *testing.T) {
|
||||
Convey("Inject failures for unreachable lines", t, func() {
|
||||
port := test.GetFreePort()
|
||||
baseURL := test.GetBaseURL(port)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
conf := config.New()
|
||||
conf.HTTP.Port = port
|
||||
conf.Storage.RootDirectory = tempDir
|
||||
|
||||
ctlr := api.NewController(conf)
|
||||
go startServer(ctlr)
|
||||
defer stopServer(ctlr)
|
||||
|
||||
test.WaitTillServerReady(baseURL)
|
||||
|
||||
layerBlob := []byte("test")
|
||||
layerPath := path.Join(tempDir, "test", ".uploads")
|
||||
|
||||
if _, err := os.Stat(layerPath); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(layerPath, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
img := test.Image{
|
||||
Layers: [][]byte{
|
||||
layerBlob,
|
||||
}, // invalid format that will result in an error
|
||||
Config: ispec.Image{},
|
||||
}
|
||||
|
||||
Convey("first marshal", func() {
|
||||
injected := test.InjectFailure(0)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
Convey("CreateBlobUpload POST call", func() {
|
||||
injected := test.InjectFailure(1)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
Convey("UpdateBlobUpload PUT call", func() {
|
||||
injected := test.InjectFailure(3)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
Convey("second marshal", func() {
|
||||
injected := test.InjectFailure(5)
|
||||
if injected {
|
||||
err := test.UploadImage(img, baseURL, "test")
|
||||
So(err, ShouldNotBeNil)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func startServer(c *api.Controller) {
|
||||
// this blocks
|
||||
ctx := context.Background()
|
||||
if err := c.Run(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func stopServer(c *api.Controller) {
|
||||
ctx := context.Background()
|
||||
_ = c.Server.Shutdown(ctx)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
zerr "zotregistry.io/zot/errors"
|
||||
|
@ -36,6 +37,20 @@ func Error(err error) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Used to inject error status codes for coverage purposes.
|
||||
// -1 will be returned in case of successful failure injection.
|
||||
func ErrStatusCode(status int) int {
|
||||
if !injectedFailure() {
|
||||
if status == http.StatusAccepted || status == http.StatusCreated {
|
||||
return status
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Failure injection infrastructure to cover hard-to-reach code paths.
|
||||
|
|
|
@ -59,6 +59,18 @@ func bar() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func baz() error {
|
||||
if test.ErrStatusCode(0) != 0 {
|
||||
return errCall1
|
||||
}
|
||||
|
||||
if test.ErrStatusCode(0) != 0 {
|
||||
return errCall2
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func alwaysErr() error {
|
||||
return errNotZero
|
||||
}
|
||||
|
@ -108,6 +120,22 @@ func TestInject(t *testing.T) {
|
|||
So(errors.Is(err, errCall2), ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Check ErrStatusCode", func() {
|
||||
Convey("Without skipping", func() {
|
||||
test.InjectFailure(0) // inject a failure
|
||||
err := baz() // should be a failure
|
||||
So(err, ShouldNotBeNil) // should be a failure
|
||||
So(errors.Is(err, errCall1), ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("With skipping", func() {
|
||||
test.InjectFailure(1) // inject a failure but skip first one
|
||||
err := baz() // should be a failure
|
||||
So(errors.Is(err, errCall1), ShouldBeFalse)
|
||||
So(errors.Is(err, errCall2), ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Without injected failure", t, func(c C) {
|
||||
|
|
|
@ -11,6 +11,10 @@ func Ok(ok bool) bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
func ErrStatusCode(statusCode int) int {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Failure injection infrastructure to cover hard-to-reach code paths (nop in production).
|
||||
|
|
Loading…
Reference in a new issue