[Identity-based Authorization] Add an option to specify a global policy for all repositories

using regex.

Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
This commit is contained in:
Petu Eusebiu
2021-09-10 18:23:26 +03:00
committed by Ramkumar Chinchani
parent 3177f87403
commit 4f825a5e2f
8 changed files with 332 additions and 101 deletions
+62 -22
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
glob "github.com/bmatcuk/doublestar/v4"
"github.com/gorilla/mux"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/log"
@@ -33,8 +34,8 @@ type AccessController struct {
// AccessControlContext context passed down to http.Handlers.
type AccessControlContext struct {
userAllowedRepos []string
isAdmin bool
globPatterns map[string]bool
isAdmin bool
}
func NewAccessController(config *config.Config) *AccessController {
@@ -44,27 +45,49 @@ func NewAccessController(config *config.Config) *AccessController {
}
}
// getReadRepos get repositories from config file that the user has READ perms.
func (ac *AccessController) getReadRepos(username string) []string {
var repos []string
// getReadRepos get glob patterns from config file that the user has or doesn't have READ perms.
// used to filter /v2/_catalog repositories based on user rights.
func (ac *AccessController) getReadGlobPatterns(username string) map[string]bool {
globPatterns := make(map[string]bool)
for r, pg := range ac.Config.Repositories {
for _, p := range pg.Policies {
if (contains(p.Users, username) && contains(p.Actions, READ)) ||
contains(pg.DefaultPolicy, READ) {
repos = append(repos, r)
for pattern, policyGroup := range ac.Config.Repositories {
// check default policy
if contains(policyGroup.DefaultPolicy, READ) {
globPatterns[pattern] = true
}
// check user based policy
for _, p := range policyGroup.Policies {
if contains(p.Users, username) && contains(p.Actions, READ) {
globPatterns[pattern] = true
}
}
// if not allowed then mark it
if _, ok := globPatterns[pattern]; !ok {
globPatterns[pattern] = false
}
}
return repos
return globPatterns
}
// can verifies if a user can do action on repository.
func (ac *AccessController) can(username, action, repository string) bool {
can := false
// check repo based policy
pg, ok := ac.Config.Repositories[repository]
var longestMatchedPattern string
for pattern := range ac.Config.Repositories {
matched, err := glob.Match(pattern, repository)
if err == nil {
if matched && len(pattern) > len(longestMatchedPattern) {
longestMatchedPattern = pattern
}
}
}
// check matched repo based policy
pg, ok := ac.Config.Repositories[longestMatchedPattern]
if ok {
can = isPermitted(username, action, pg)
}
@@ -86,8 +109,8 @@ 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 {
userAllowedRepos := ac.getReadRepos(username)
acCtx := AccessControlContext{userAllowedRepos: userAllowedRepos}
readGlobPatterns := ac.getReadGlobPatterns(username)
acCtx := AccessControlContext{globPatterns: readGlobPatterns}
if ac.isAdmin(username) {
acCtx.isAdmin = true
@@ -132,14 +155,23 @@ func contains(slice []string, item string) bool {
return false
}
func containsRepo(slice []string, item string) bool {
for _, v := range slice {
if strings.HasPrefix(item, v) {
return true
// 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
}
}
}
return false
allowed := globPatterns[longestMatchedPattern]
return allowed
}
func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
@@ -149,11 +181,19 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
resource := vars["name"]
reference, ok := vars["reference"]
// bypass authz for /v2/ route
if request.RequestURI == "/v2/" {
next.ServeHTTP(response, request)
return
}
acCtrlr := NewAccessController(ctlr.Config)
username := getUsername(request)
ctx := acCtrlr.getContext(username, request)
if request.RequestURI == "/v2/_catalog" || request.RequestURI == "/v2/" {
// will return only repos on which client is authorized to read
if request.RequestURI == "/v2/_catalog" {
next.ServeHTTP(response, request.WithContext(ctx))
return
@@ -193,7 +233,7 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
}
func getUsername(r *http.Request) string {
// this should work because it worked in auth middleware
// this should work because it was already parsed in authn middleware
basicAuth := r.Header.Get("Authorization")
s := strings.SplitN(basicAuth, " ", 2) //nolint:gomnd
b, _ := base64.StdEncoding.DecodeString(s[1])
+4 -4
View File
@@ -162,7 +162,7 @@ func (c *Config) Validate(log log.Logger) error {
}
// LoadAccessControlConfig populates config.AccessControl struct with values from config.
func (c *Config) LoadAccessControlConfig() error {
func (c *Config) LoadAccessControlConfig(viperInstance *viper.Viper) error {
if c.HTTP.RawAccessControl == nil {
return nil
}
@@ -176,19 +176,19 @@ func (c *Config) LoadAccessControlConfig() error {
var policyGroup PolicyGroup
if policy == "adminpolicy" {
adminPolicy := viper.GetStringMapStringSlice("http.accessControl.adminPolicy")
adminPolicy := viperInstance.GetStringMapStringSlice("http::accessControl::adminPolicy")
c.AccessControl.AdminPolicy.Actions = adminPolicy["actions"]
c.AccessControl.AdminPolicy.Users = adminPolicy["users"]
continue
}
err := viper.UnmarshalKey(fmt.Sprintf("http.accessControl.%s.policies", policy), &policies)
err := viperInstance.UnmarshalKey(fmt.Sprintf("http::accessControl::%s::policies", policy), &policies)
if err != nil {
return err
}
defaultPolicy := viper.GetStringSlice(fmt.Sprintf("http.accessControl.%s.defaultPolicy", policy))
defaultPolicy := viperInstance.GetStringSlice(fmt.Sprintf("http::accessControl::%s::defaultPolicy", policy))
policyGroup.Policies = policies
policyGroup.DefaultPolicy = defaultPolicy
c.AccessControl.Repositories[policy] = policyGroup
+117 -16
View File
@@ -56,6 +56,7 @@ const (
UnauthorizedNamespace = "fortknox/notallowed"
ALICE = "alice"
AuthorizationNamespace = "authz/image"
AuthorizationAllRepos = "**"
)
type (
@@ -1752,7 +1753,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
}
conf.AccessControl = &config.AccessControlConfig{
Repositories: config.Repositories{
AuthorizationNamespace: config.PolicyGroup{
AuthorizationAllRepos: config.PolicyGroup{
Policies: []config.Policy{
{
Users: []string{},
@@ -1787,8 +1788,14 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
blob := []byte("hello, blob!")
digest := godigest.FromBytes(blob).String()
// unauthenticated clients should not have access to /v2/
resp, err := resty.R().Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 401)
// everybody should have access to /v2/
resp, err := resty.R().SetBasicAuth(username, passphrase).
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
@@ -1804,7 +1811,6 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
err = json.Unmarshal(resp.Body(), &e)
So(err, ShouldBeNil)
// first let's use only repositories based policies
// should get 403 without create
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
@@ -1812,11 +1818,13 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// add test user to repo's policy with create perm
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users =
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test")
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create")
// first let's use global based policies
// add test user to global policy with create perm
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users =
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test")
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create")
// now it should get 202
resp, err = resty.R().SetBasicAuth(username, passphrase).
@@ -1837,18 +1845,104 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
// head blob should get 403 with read perm
// head blob should get 403 without read perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// get blob should get 403 without read perm
// get tags without read access should get 403
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// get tags with read access should get 200
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "read")
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// head blob should get 200 now
resp, err = resty.R().SetBasicAuth(username, passphrase).
Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// get blob should get 200 now
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// delete blob should get 403 without delete perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// add delete perm on repo
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "delete")
// delete blob should get 202
resp, err = resty.R().SetBasicAuth(username, passphrase).
Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
// now let's use only repository based policies
// add test user to repo's policy with create perm
// longest path matching should match the repo and not **/*
conf.AccessControl.Repositories[AuthorizationNamespace] = config.PolicyGroup{
Policies: []config.Policy{
{
Users: []string{},
Actions: []string{},
},
},
DefaultPolicy: []string{},
}
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users =
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test")
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create")
// now it should get 202
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
loc = resp.Header().Get("Location")
// uploading blob should get 201
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))).
SetHeader("Content-Type", "application/octet-stream").
SetQueryParam("digest", digest).
SetBody(blob).
Put(baseURL + loc)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
// head blob should get 403 without read perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// get tags without read access should get 403
@@ -1861,6 +1955,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
// get tags with read access should get 200
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read")
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
So(err, ShouldBeNil)
@@ -1899,6 +1994,12 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
// remove permissions on **/* so it will not interfere with zot-test namespace
repoPolicy := conf.AccessControl.Repositories[AuthorizationAllRepos]
repoPolicy.Policies = []config.Policy{}
repoPolicy.DefaultPolicy = []string{}
conf.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy
// get manifest should get 403, we don't have perm at all on this repo
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/zot-test/manifests/0.0.1")
@@ -1965,7 +2066,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
// now use default repo policy
conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{}
repoPolicy := conf.AccessControl.Repositories["zot-test"]
repoPolicy = conf.AccessControl.Repositories["zot-test"]
repoPolicy.DefaultPolicy = []string{"update"}
conf.AccessControl.Repositories["zot-test"] = repoPolicy
@@ -2006,17 +2107,17 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
repoPolicy.DefaultPolicy = []string{}
conf.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy
repoPolicy = conf.AccessControl.Repositories["zot-test"]
repoPolicy.Policies = []config.Policy{}
repoPolicy.DefaultPolicy = []string{}
conf.AccessControl.Repositories["zot-test"] = repoPolicy
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
// let's use admin policy
// remove all repo based policy
delete(conf.AccessControl.Repositories, AuthorizationNamespace)
delete(conf.AccessControl.Repositories, "zot-test")
// whithout any perm should get 403
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
+1 -1
View File
@@ -1234,7 +1234,7 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
}
for _, r := range combineRepoList {
if containsRepo(acCtx.userAllowedRepos, r) || acCtx.isAdmin {
if acCtx.isAdmin || matchesRepo(acCtx.globPatterns, r) {
repos = append(repos, r)
}
}