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:
Alex Stan
2022-08-16 11:57:09 +03:00
committed by Andrei Aaron
parent 5450139ba1
commit 49e8167dbe
15 changed files with 763 additions and 165 deletions
+2 -2
View File
@@ -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)))
}
}
+3 -137
View File
@@ -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()
+51 -2
View File
@@ -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
}
+28
View File
@@ -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"
+8 -1
View File
@@ -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,