mirror of
https://github.com/project-zot/zot.git
synced 2026-06-16 12:28:01 +08: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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user