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
+18 -17
View File
@@ -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
+158 -2
View File
@@ -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()
+5 -2
View File
@@ -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)
}
}