mirror of
https://github.com/project-zot/zot.git
synced 2025-02-03 23:09:41 -05:00
[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:
parent
3177f87403
commit
4f825a5e2f
8 changed files with 332 additions and 101 deletions
|
@ -186,9 +186,35 @@ identities. An additional per-repository default policy can be specified for
|
||||||
identities not in the whitelist. Furthermore, a global admin policy can also be
|
identities not in the whitelist. Furthermore, a global admin policy can also be
|
||||||
specified which can override per-repository policies.
|
specified which can override per-repository policies.
|
||||||
|
|
||||||
|
Glob patterns can also be used as repository paths.
|
||||||
|
|
||||||
|
Authorization is granted based on the longest path matched.
|
||||||
|
For example repos2/repo repository will match both "**" and "repos2/repo" keys,
|
||||||
|
in such case repos2/repo policy will be used because it's longer.
|
||||||
|
|
||||||
|
Because we use longest path matching we need a way to specify a global policy to override all the other policies.
|
||||||
|
For example, we can specify a global policy with "**" (will match all repos), but any other policy will overwrite it,
|
||||||
|
because it will be longer. So that's why we have the option to specify an adminPolicy.
|
||||||
|
|
||||||
|
Basically '**' means repositories not matched by any other per-repository policy.
|
||||||
|
|
||||||
|
create/update/delete can not be used without 'read' action, make sure read is always included in policies!
|
||||||
|
|
||||||
```
|
```
|
||||||
"accessControl": {
|
"accessControl": {
|
||||||
"repos1/repo": {
|
"**": { # matches all repos (which are not matched by any other per-repository policy)
|
||||||
|
"policies": [ # user based policies
|
||||||
|
{
|
||||||
|
"users": ["charlie"],
|
||||||
|
"actions": ["read", "create", "update"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultPolicy": ["read", "create"] # default policy which is applied for all users => so all users can read/create repositories
|
||||||
|
},
|
||||||
|
"tmp/**": { # matches all repos under tmp/ recursively
|
||||||
|
"defaultPolicy": ["read", "create", "update"] # so all users have read/create/update on all repos under tmp/ eg: tmp/infra/repo
|
||||||
|
},
|
||||||
|
"infra/*": { # matches all repos directly under infra/ (not recursively)
|
||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"users": ["alice", "bob"],
|
"users": ["alice", "bob"],
|
||||||
|
@ -201,7 +227,7 @@ specified which can override per-repository policies.
|
||||||
],
|
],
|
||||||
"defaultPolicy": ["read"]
|
"defaultPolicy": ["read"]
|
||||||
},
|
},
|
||||||
"repos2/repo": {
|
"repos2/repo": { # matches only repos2/repo repository
|
||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"users": ["bob"],
|
"users": ["bob"],
|
||||||
|
@ -214,13 +240,15 @@ specified which can override per-repository policies.
|
||||||
],
|
],
|
||||||
"defaultPolicy": ["read"]
|
"defaultPolicy": ["read"]
|
||||||
},
|
},
|
||||||
"adminPolicy": {
|
"adminPolicy": { # global admin policy (overrides per-repo policy)
|
||||||
"users": ["admin"],
|
"users": ["admin"],
|
||||||
"actions": ["read", "create", "update", "delete"]
|
"actions": ["read", "create", "update", "delete"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Enable and configure logging with:
|
Enable and configure logging with:
|
||||||
|
|
|
@ -14,7 +14,19 @@
|
||||||
"failDelay": 1
|
"failDelay": 1
|
||||||
},
|
},
|
||||||
"accessControl": {
|
"accessControl": {
|
||||||
"repos1/repo": {
|
"**": {
|
||||||
|
"policies": [
|
||||||
|
{
|
||||||
|
"users": ["charlie"],
|
||||||
|
"actions": ["read", "create", "update"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultPolicy": ["read", "create"]
|
||||||
|
},
|
||||||
|
"tmp/**": {
|
||||||
|
"defaultPolicy": ["read", "create", "update"]
|
||||||
|
},
|
||||||
|
"infra/**": {
|
||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"users": ["alice", "bob"],
|
"users": ["alice", "bob"],
|
||||||
|
@ -30,7 +42,7 @@
|
||||||
"repos2/repo": {
|
"repos2/repo": {
|
||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"users": ["bob"],
|
"users": ["charlie"],
|
||||||
"actions": ["read", "create"]
|
"actions": ["read", "create"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
glob "github.com/bmatcuk/doublestar/v4"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"zotregistry.io/zot/pkg/api/config"
|
"zotregistry.io/zot/pkg/api/config"
|
||||||
"zotregistry.io/zot/pkg/log"
|
"zotregistry.io/zot/pkg/log"
|
||||||
|
@ -33,7 +34,7 @@ type AccessController struct {
|
||||||
|
|
||||||
// AccessControlContext context passed down to http.Handlers.
|
// AccessControlContext context passed down to http.Handlers.
|
||||||
type AccessControlContext struct {
|
type AccessControlContext struct {
|
||||||
userAllowedRepos []string
|
globPatterns map[string]bool
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,27 +45,49 @@ func NewAccessController(config *config.Config) *AccessController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getReadRepos get repositories from config file that the user has READ perms.
|
// getReadRepos get glob patterns from config file that the user has or doesn't have READ perms.
|
||||||
func (ac *AccessController) getReadRepos(username string) []string {
|
// used to filter /v2/_catalog repositories based on user rights.
|
||||||
var repos []string
|
func (ac *AccessController) getReadGlobPatterns(username string) map[string]bool {
|
||||||
|
globPatterns := make(map[string]bool)
|
||||||
|
|
||||||
for r, pg := range ac.Config.Repositories {
|
for pattern, policyGroup := range ac.Config.Repositories {
|
||||||
for _, p := range pg.Policies {
|
// check default policy
|
||||||
if (contains(p.Users, username) && contains(p.Actions, READ)) ||
|
if contains(policyGroup.DefaultPolicy, READ) {
|
||||||
contains(pg.DefaultPolicy, READ) {
|
globPatterns[pattern] = true
|
||||||
repos = append(repos, r)
|
|
||||||
}
|
}
|
||||||
|
// check user based policy
|
||||||
|
for _, p := range policyGroup.Policies {
|
||||||
|
if contains(p.Users, username) && contains(p.Actions, READ) {
|
||||||
|
globPatterns[pattern] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return repos
|
// if not allowed then mark it
|
||||||
|
if _, ok := globPatterns[pattern]; !ok {
|
||||||
|
globPatterns[pattern] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return globPatterns
|
||||||
}
|
}
|
||||||
|
|
||||||
// can verifies if a user can do action on repository.
|
// can verifies if a user can do action on repository.
|
||||||
func (ac *AccessController) can(username, action, repository string) bool {
|
func (ac *AccessController) can(username, action, repository string) bool {
|
||||||
can := false
|
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 {
|
if ok {
|
||||||
can = isPermitted(username, action, pg)
|
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.
|
// 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 {
|
func (ac *AccessController) getContext(username string, request *http.Request) context.Context {
|
||||||
userAllowedRepos := ac.getReadRepos(username)
|
readGlobPatterns := ac.getReadGlobPatterns(username)
|
||||||
acCtx := AccessControlContext{userAllowedRepos: userAllowedRepos}
|
acCtx := AccessControlContext{globPatterns: readGlobPatterns}
|
||||||
|
|
||||||
if ac.isAdmin(username) {
|
if ac.isAdmin(username) {
|
||||||
acCtx.isAdmin = true
|
acCtx.isAdmin = true
|
||||||
|
@ -132,14 +155,23 @@ func contains(slice []string, item string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsRepo(slice []string, item string) bool {
|
// returns either a user has or not rights on 'repository'.
|
||||||
for _, v := range slice {
|
func matchesRepo(globPatterns map[string]bool, repository string) bool {
|
||||||
if strings.HasPrefix(item, v) {
|
var longestMatchedPattern string
|
||||||
return true
|
|
||||||
|
// 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 {
|
func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
|
||||||
|
@ -149,11 +181,19 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
|
||||||
resource := vars["name"]
|
resource := vars["name"]
|
||||||
reference, ok := vars["reference"]
|
reference, ok := vars["reference"]
|
||||||
|
|
||||||
|
// bypass authz for /v2/ route
|
||||||
|
if request.RequestURI == "/v2/" {
|
||||||
|
next.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
acCtrlr := NewAccessController(ctlr.Config)
|
acCtrlr := NewAccessController(ctlr.Config)
|
||||||
username := getUsername(request)
|
username := getUsername(request)
|
||||||
ctx := acCtrlr.getContext(username, 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))
|
next.ServeHTTP(response, request.WithContext(ctx))
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -193,7 +233,7 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUsername(r *http.Request) string {
|
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")
|
basicAuth := r.Header.Get("Authorization")
|
||||||
s := strings.SplitN(basicAuth, " ", 2) //nolint:gomnd
|
s := strings.SplitN(basicAuth, " ", 2) //nolint:gomnd
|
||||||
b, _ := base64.StdEncoding.DecodeString(s[1])
|
b, _ := base64.StdEncoding.DecodeString(s[1])
|
||||||
|
|
|
@ -162,7 +162,7 @@ func (c *Config) Validate(log log.Logger) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadAccessControlConfig populates config.AccessControl struct with values from config.
|
// 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 {
|
if c.HTTP.RawAccessControl == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -176,19 +176,19 @@ func (c *Config) LoadAccessControlConfig() error {
|
||||||
var policyGroup PolicyGroup
|
var policyGroup PolicyGroup
|
||||||
|
|
||||||
if policy == "adminpolicy" {
|
if policy == "adminpolicy" {
|
||||||
adminPolicy := viper.GetStringMapStringSlice("http.accessControl.adminPolicy")
|
adminPolicy := viperInstance.GetStringMapStringSlice("http::accessControl::adminPolicy")
|
||||||
c.AccessControl.AdminPolicy.Actions = adminPolicy["actions"]
|
c.AccessControl.AdminPolicy.Actions = adminPolicy["actions"]
|
||||||
c.AccessControl.AdminPolicy.Users = adminPolicy["users"]
|
c.AccessControl.AdminPolicy.Users = adminPolicy["users"]
|
||||||
|
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
return err
|
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.Policies = policies
|
||||||
policyGroup.DefaultPolicy = defaultPolicy
|
policyGroup.DefaultPolicy = defaultPolicy
|
||||||
c.AccessControl.Repositories[policy] = policyGroup
|
c.AccessControl.Repositories[policy] = policyGroup
|
||||||
|
|
|
@ -56,6 +56,7 @@ const (
|
||||||
UnauthorizedNamespace = "fortknox/notallowed"
|
UnauthorizedNamespace = "fortknox/notallowed"
|
||||||
ALICE = "alice"
|
ALICE = "alice"
|
||||||
AuthorizationNamespace = "authz/image"
|
AuthorizationNamespace = "authz/image"
|
||||||
|
AuthorizationAllRepos = "**"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -1752,7 +1753,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
}
|
}
|
||||||
conf.AccessControl = &config.AccessControlConfig{
|
conf.AccessControl = &config.AccessControlConfig{
|
||||||
Repositories: config.Repositories{
|
Repositories: config.Repositories{
|
||||||
AuthorizationNamespace: config.PolicyGroup{
|
AuthorizationAllRepos: config.PolicyGroup{
|
||||||
Policies: []config.Policy{
|
Policies: []config.Policy{
|
||||||
{
|
{
|
||||||
Users: []string{},
|
Users: []string{},
|
||||||
|
@ -1787,8 +1788,14 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
blob := []byte("hello, blob!")
|
blob := []byte("hello, blob!")
|
||||||
digest := godigest.FromBytes(blob).String()
|
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/
|
// everybody should have access to /v2/
|
||||||
resp, err := resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Get(baseURL + "/v2/")
|
Get(baseURL + "/v2/")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
|
@ -1804,7 +1811,6 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
err = json.Unmarshal(resp.Body(), &e)
|
err = json.Unmarshal(resp.Body(), &e)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
// first let's use only repositories based policies
|
|
||||||
// should get 403 without create
|
// should get 403 without create
|
||||||
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
|
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
|
||||||
|
@ -1812,11 +1818,13 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
||||||
|
|
||||||
// add test user to repo's policy with create perm
|
// first let's use global based policies
|
||||||
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users =
|
// add test user to global policy with create perm
|
||||||
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test")
|
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users =
|
||||||
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
|
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test")
|
||||||
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create")
|
|
||||||
|
conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions =
|
||||||
|
append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create")
|
||||||
|
|
||||||
// now it should get 202
|
// now it should get 202
|
||||||
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
|
@ -1837,18 +1845,104 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
|
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).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
|
Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
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).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
|
Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(resp, ShouldNotBeNil)
|
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)
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
||||||
|
|
||||||
// get tags without read access should get 403
|
// 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
|
// get tags with read access should get 200
|
||||||
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
|
conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
|
||||||
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read")
|
append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read")
|
||||||
|
|
||||||
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
|
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
@ -1899,6 +1994,12 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
|
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
|
// get manifest should get 403, we don't have perm at all on this repo
|
||||||
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Get(baseURL + "/v2/zot-test/manifests/0.0.1")
|
Get(baseURL + "/v2/zot-test/manifests/0.0.1")
|
||||||
|
@ -1965,7 +2066,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
|
|
||||||
// now use default repo policy
|
// now use default repo policy
|
||||||
conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{}
|
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"}
|
repoPolicy.DefaultPolicy = []string{"update"}
|
||||||
conf.AccessControl.Repositories["zot-test"] = repoPolicy
|
conf.AccessControl.Repositories["zot-test"] = repoPolicy
|
||||||
|
|
||||||
|
@ -2006,17 +2107,17 @@ func TestAuthorizationWithBasicAuth(t *testing.T) {
|
||||||
repoPolicy.DefaultPolicy = []string{}
|
repoPolicy.DefaultPolicy = []string{}
|
||||||
conf.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy
|
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).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
|
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(resp, ShouldNotBeNil)
|
So(resp, ShouldNotBeNil)
|
||||||
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
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
|
// whithout any perm should get 403
|
||||||
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
resp, err = resty.R().SetBasicAuth(username, passphrase).
|
||||||
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
|
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
|
||||||
|
|
|
@ -1234,7 +1234,7 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range combineRepoList {
|
for _, r := range combineRepoList {
|
||||||
if containsRepo(acCtx.userAllowedRepos, r) || acCtx.isAdmin {
|
if acCtx.isAdmin || matchesRepo(acCtx.globPatterns, r) {
|
||||||
repos = append(repos, r)
|
repos = append(repos, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,25 +186,7 @@ func NewRootCmd() *cobra.Command {
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfiguration(config *config.Config, configPath string) {
|
func validateConfiguration(config *config.Config) {
|
||||||
viper.SetConfigFile(configPath)
|
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
|
||||||
log.Error().Err(err).Msg("error while reading configuration")
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metaData := &mapstructure.Metadata{}
|
|
||||||
if err := viper.Unmarshal(&config, metadataConfig(metaData)); err != nil {
|
|
||||||
log.Error().Err(err).Msg("error while unmarshalling new config")
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metaData.Keys) == 0 || len(metaData.Unused) > 0 {
|
|
||||||
log.Error().Err(errors.ErrBadConfig).Msg("bad configuration, retry writing it")
|
|
||||||
panic(errors.ErrBadConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check authorization config, it should have basic auth enabled or ldap
|
// check authorization config, it should have basic auth enabled or ldap
|
||||||
if config.HTTP.RawAccessControl != nil {
|
if config.HTTP.RawAccessControl != nil {
|
||||||
if config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil) {
|
if config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil) {
|
||||||
|
@ -228,14 +210,14 @@ func LoadConfiguration(config *config.Config, configPath string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check glob patterns in sync are compilable
|
// check glob patterns in sync config are compilable
|
||||||
if config.Extensions != nil && config.Extensions.Sync != nil {
|
if config.Extensions != nil && config.Extensions.Sync != nil {
|
||||||
for _, regCfg := range config.Extensions.Sync.Registries {
|
for _, regCfg := range config.Extensions.Sync.Registries {
|
||||||
if regCfg.Content != nil {
|
if regCfg.Content != nil {
|
||||||
for _, content := range regCfg.Content {
|
for _, content := range regCfg.Content {
|
||||||
ok := glob.ValidatePattern(content.Prefix)
|
ok := glob.ValidatePattern(content.Prefix)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error().Err(glob.ErrBadPattern).Str("pattern", content.Prefix).Msg("pattern could not be compiled")
|
log.Error().Err(glob.ErrBadPattern).Str("pattern", content.Prefix).Msg("sync pattern could not be compiled")
|
||||||
panic(errors.ErrBadConfig)
|
panic(errors.ErrBadConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -260,19 +242,57 @@ func LoadConfiguration(config *config.Config, configPath string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := config.LoadAccessControlConfig()
|
// check glob patterns in authz config are compilable
|
||||||
if err != nil {
|
if config.AccessControl != nil {
|
||||||
log.Error().Err(errors.ErrBadConfig).Msg("unable to unmarshal http.accessControl.key.policies")
|
for pattern := range config.AccessControl.Repositories {
|
||||||
|
ok := glob.ValidatePattern(pattern)
|
||||||
|
if !ok {
|
||||||
|
log.Error().Err(glob.ErrBadPattern).Str("pattern", pattern).Msg("authorization pattern could not be compiled")
|
||||||
|
panic(errors.ErrBadConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfiguration(config *config.Config, configPath string) {
|
||||||
|
// Default is dot (.) but because we allow glob patterns in authz
|
||||||
|
// we need another key delimiter.
|
||||||
|
viperInstance := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||||
|
|
||||||
|
viperInstance.SetConfigFile(configPath)
|
||||||
|
|
||||||
|
if err := viperInstance.ReadInConfig(); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while reading configuration")
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metaData := &mapstructure.Metadata{}
|
||||||
|
if err := viperInstance.Unmarshal(&config, metadataConfig(metaData)); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while unmarshalling new config")
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metaData.Keys) == 0 || len(metaData.Unused) > 0 {
|
||||||
|
log.Error().Err(errors.ErrBadConfig).Msg("bad configuration, retry writing it")
|
||||||
|
panic(errors.ErrBadConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.LoadAccessControlConfig(viperInstance)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("unable to unmarshal config's accessControl")
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// various config checks
|
||||||
|
validateConfiguration(config)
|
||||||
|
|
||||||
// defaults
|
// defaults
|
||||||
defualtTLSVerify := true
|
defaultTLSVerify := true
|
||||||
|
|
||||||
if config.Extensions != nil && config.Extensions.Sync != nil {
|
if config.Extensions != nil && config.Extensions.Sync != nil {
|
||||||
for id, regCfg := range config.Extensions.Sync.Registries {
|
for id, regCfg := range config.Extensions.Sync.Registries {
|
||||||
if regCfg.TLSVerify == nil {
|
if regCfg.TLSVerify == nil {
|
||||||
config.Extensions.Sync.Registries[id].TLSVerify = &defualtTLSVerify
|
config.Extensions.Sync.Registries[id].TLSVerify = &defaultTLSVerify
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
"github.com/spf13/viper"
|
|
||||||
"gopkg.in/resty.v1"
|
"gopkg.in/resty.v1"
|
||||||
"zotregistry.io/zot/pkg/api"
|
"zotregistry.io/zot/pkg/api"
|
||||||
"zotregistry.io/zot/pkg/api/config"
|
"zotregistry.io/zot/pkg/api/config"
|
||||||
|
@ -188,6 +187,40 @@ func TestVerify(t *testing.T) {
|
||||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Convey("Test verify with bad authorization repo patterns", t, func(c C) {
|
||||||
|
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"},
|
||||||
|
"http":{"address":"127.0.0.1","port":"8080","realm":"zot",
|
||||||
|
"auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1},
|
||||||
|
"accessControl":{"\|":{"policies":[],"defaultPolicy":[]}}}}`)
|
||||||
|
_, err = tmpfile.Write(content)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
err = tmpfile.Close()
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||||
|
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("Test verify sync config default tls value", t, func(c C) {
|
||||||
|
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"},
|
||||||
|
"http":{"address":"127.0.0.1","port":"8080","realm":"zot",
|
||||||
|
"auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}},
|
||||||
|
"extensions":{"sync": {"registries": [{"url":"localhost:9999",
|
||||||
|
"content": [{"prefix":"repo**"}]}]}}}`)
|
||||||
|
_, err = tmpfile.Write(content)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
err = tmpfile.Close()
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||||
|
err = cli.NewRootCmd().Execute()
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
})
|
||||||
|
|
||||||
Convey("Test verify good config", t, func(c C) {
|
Convey("Test verify good config", t, func(c C) {
|
||||||
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
|
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
@ -209,9 +242,6 @@ func TestLoadConfig(t *testing.T) {
|
||||||
Convey("Test viper load config", t, func(c C) {
|
Convey("Test viper load config", t, func(c C) {
|
||||||
config := config.New()
|
config := config.New()
|
||||||
So(func() { cli.LoadConfiguration(config, "../../examples/config-policy.json") }, ShouldNotPanic)
|
So(func() { cli.LoadConfiguration(config, "../../examples/config-policy.json") }, ShouldNotPanic)
|
||||||
adminPolicy := viper.GetStringMapStringSlice("http.accessControl.adminPolicy")
|
|
||||||
So(config.AccessControl.AdminPolicy.Actions, ShouldResemble, adminPolicy["actions"])
|
|
||||||
So(config.AccessControl.AdminPolicy.Users, ShouldResemble, adminPolicy["users"])
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue