0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2025-03-25 02:32:57 -05:00

Add identity-based access control, closes #51

Add a cli subcommand to verify config files validity
This commit is contained in:
Petu Eusebiu 2021-05-13 21:59:12 +03:00 committed by Ramkumar Chinchani
parent 26926ad4c2
commit 609d85d875
14 changed files with 915 additions and 51 deletions

View file

@ -27,6 +27,8 @@ https://anuvu.github.io/zot/
* TLS mutual authentication
* HTTP *Basic* (local _htpasswd_ and LDAP)
* HTTP *Bearer* token
* [Authorization](#Authorization) Supports Identity-Based Access Control: [Configuration](./examples/config-policy.json)
* Supports live modifications on the config file while zot is running (Authorization config only)
* Doesn't require _root_ privileges
* Storage optimizations:
* Automatic garbage collection of orphaned blobs
@ -222,6 +224,15 @@ c3/openjdk-dev commit-2674e8a-squashfs b545b8ba 321MB
c3/openjdk-dev commit-d5024ec-squashfs cd45f8cf 321MB
```
## Authorization
zot follows the next logic for deciding who has rights on what resource:
1) First it checks the requested resource in "repositories" as specified in configuration
2) Searches in all policies for the user who requested the resource
3) If found, applies the policy, otherwise it falls back to the default policy for that repository
4) If not found, it falls back to the adminPolicy
# Ecosystem

View file

@ -729,13 +729,13 @@ type swaggerInfo struct {
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{ Schemes: []string{}}
var SwaggerInfo = swaggerInfo{Schemes: []string{}}
type s struct{}
func (s *s) ReadDoc() string {
t, err := template.New("swagger_info").Funcs(template.FuncMap{
"marshal": func(v interface {}) string {
"marshal": func(v interface{}) string {
a, _ := json.Marshal(v)
return string(a)
},

5
examples/README.md Normal file
View file

@ -0,0 +1,5 @@
You can check a configuration file validity by running the verify command:
```console
$ zot verify /path/to/config
```

View file

@ -9,6 +9,7 @@
"ReadOnly": false
},
"log": {
"level": "debug"
"level": "debug",
"output": "/tmp/zot.log"
}
}

54
examples/config-policy.json Executable file
View file

@ -0,0 +1,54 @@
{
"version": "0.1.0-dev",
"storage": {
"rootDirectory": "/tmp/zot"
},
"http": {
"address": "127.0.0.1",
"port": "8080",
"realm": "zot",
"auth": {
"htpasswd": {
"path": "test/data/htpasswd"
},
"failDelay": 1
},
"accessControl": {
"repos1/repo": {
"policies": [
{
"users": ["alice", "bob"],
"actions": ["create", "read", "update", "delete"]
},
{
"users": ["mallory"],
"actions": ["create", "read"]
}
],
"defaultPolicy": ["read"]
},
"repos2/repo": {
"policies": [
{
"users": ["bob"],
"actions": ["read", "create"]
},
{
"users": ["mallory"],
"actions": ["create", "read"]
}
],
"defaultPolicy": ["read"]
},
"adminPolicy": {
"users": ["admin"],
"actions": ["read", "create", "update", "delete"]
}
}
},
"log": {
"level": "debug",
"output": "/tmp/zot.log"
}
}

3
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/briandowns/spinner v1.12.0
github.com/chartmuseum/auth v0.4.5
github.com/dustin/go-humanize v1.0.0
github.com/fsnotify/fsnotify v1.4.9
github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a
github.com/go-ldap/ldap/v3 v3.3.0
github.com/gofrs/uuid v4.0.0+incompatible
@ -40,7 +41,7 @@ require (
github.com/swaggo/swag v1.7.0
github.com/vektah/gqlparser/v2 v2.2.0
go.etcd.io/bbolt v1.3.5
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
gopkg.in/resty.v1 v1.12.0
gopkg.in/yaml.v2 v2.4.0
)

3
go.sum
View file

@ -1148,9 +1148,8 @@ golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=

View file

@ -23,11 +23,7 @@ const (
)
func AuthHandler(c *Controller) mux.MiddlewareFunc {
if c.Config.HTTP.Auth != nil &&
c.Config.HTTP.Auth.Bearer != nil &&
c.Config.HTTP.Auth.Bearer.Cert != "" &&
c.Config.HTTP.Auth.Bearer.Realm != "" &&
c.Config.HTTP.Auth.Bearer.Service != "" {
if isBearerAuthEnabled(c.Config) {
return bearerAuthHandler(c)
}

236
pkg/api/authz.go Executable file
View file

@ -0,0 +1,236 @@
package api
import (
"context"
"encoding/base64"
"net/http"
"strings"
"time"
"github.com/anuvu/zot/pkg/log"
"github.com/gorilla/mux"
)
type contextKey int
const (
// actions
CREATE = "create"
READ = "read"
UPDATE = "update"
DELETE = "delete"
// request-local context key
authzCtxKey contextKey = 0
)
type AccessControlConfig struct {
Repositories Repositories
AdminPolicy Policy
}
type Repositories map[string]PolicyGroup
type PolicyGroup struct {
Policies []Policy
DefaultPolicy []string
}
type Policy struct {
Users []string
Actions []string
}
// AccessController authorizes users to act on resources.
type AccessController struct {
Config *AccessControlConfig
Log log.Logger
}
// AccessControlContext context passed down to http.Handlers.
type AccessControlContext struct {
userAllowedRepos []string
isAdmin bool
}
func NewAccessController(config *Config) *AccessController {
return &AccessController{
Config: config.AccessControl,
Log: log.NewLogger(config.Log.Level, config.Log.Output),
}
}
// getReadRepos get repositories from config file that the user has READ perms.
func (ac *AccessController) getReadRepos(username string) []string {
var repos []string
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)
}
}
}
return repos
}
// 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]
if ok {
can = isPermitted(username, action, pg)
}
//check admins based policy
if !can {
if ac.isAdmin(username) && contains(ac.Config.AdminPolicy.Actions, action) {
can = true
}
}
return can
}
// isAdmin .
func (ac *AccessController) isAdmin(username string) bool {
return contains(ac.Config.AdminPolicy.Users, username)
}
// getContext builds ac context(allowed to read repos and if user is admin) and returns it.
func (ac *AccessController) getContext(username string, r *http.Request) context.Context {
userAllowedRepos := ac.getReadRepos(username)
acCtx := AccessControlContext{userAllowedRepos: userAllowedRepos}
if ac.isAdmin(username) {
acCtx.isAdmin = true
} else {
acCtx.isAdmin = false
}
ctx := context.WithValue(r.Context(), authzCtxKey, acCtx)
return ctx
}
// isPermitted returns true if username can do action on a repository policy.
func isPermitted(username, action string, pg PolicyGroup) bool {
var result bool
// check repo/system based policies
for _, p := range pg.Policies {
if contains(p.Users, username) && contains(p.Actions, action) {
result = true
break
}
}
// check defaultPolicy
if !result {
if contains(pg.DefaultPolicy, action) {
result = true
}
}
return result
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if item == v {
return true
}
}
return false
}
func containsRepo(slice []string, item string) bool {
for _, v := range slice {
if strings.HasPrefix(item, v) {
return true
}
}
return false
}
func AuthzHandler(c *Controller) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
resource := vars["name"]
reference, ok := vars["reference"]
ac := NewAccessController(c.Config)
username := getUsername(r)
ctx := ac.getContext(username, r)
if r.RequestURI == "/v2/_catalog" || r.RequestURI == "/v2/" {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
var action string
if r.Method == http.MethodGet || r.Method == http.MethodHead {
action = READ
}
if r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodPost {
// assume user wants to create
action = CREATE
// if we get a reference (tag)
if ok {
is := c.StoreController.GetImageStore(resource)
tags, err := is.GetImageTags(resource)
// if repo exists and request's tag doesn't exist yet then action is UPDATE
if err == nil && contains(tags, reference) && reference != "latest" {
action = UPDATE
}
}
}
if r.Method == http.MethodDelete {
action = DELETE
}
can := ac.can(username, action, resource)
if !can {
authzFail(w, c.Config.HTTP.Realm, c.Config.HTTP.Auth.FailDelay)
} else {
next.ServeHTTP(w, r.WithContext(ctx))
}
})
}
}
func getUsername(r *http.Request) string {
// this should work because it worked in auth middleware
basicAuth := r.Header.Get("Authorization")
s := strings.SplitN(basicAuth, " ", 2)
b, _ := base64.StdEncoding.DecodeString(s[1])
pair := strings.SplitN(string(b), ":", 2)
return pair[0]
}
func isBearerAuthEnabled(config *Config) bool {
if config.HTTP.Auth != nil &&
config.HTTP.Auth.Bearer != nil &&
config.HTTP.Auth.Bearer.Cert != "" &&
config.HTTP.Auth.Bearer.Realm != "" &&
config.HTTP.Auth.Bearer.Service != "" {
return true
}
return false
}
func authzFail(w http.ResponseWriter, realm string, delay int) {
time.Sleep(time.Duration(delay) * time.Second)
w.Header().Set("WWW-Authenticate", realm)
w.Header().Set("Content-Type", "application/json")
WriteJSON(w, http.StatusForbidden, NewErrorList(NewError(DENIED)))
}

View file

@ -1,11 +1,14 @@
package api
import (
"fmt"
"github.com/anuvu/zot/errors"
ext "github.com/anuvu/zot/pkg/extensions"
"github.com/anuvu/zot/pkg/log"
"github.com/getlantern/deepcopy"
distspec "github.com/opencontainers/distribution-spec/specs-go"
"github.com/spf13/viper"
)
var (
@ -43,13 +46,14 @@ type BearerConfig struct {
}
type HTTPConfig struct {
Address string
Port string
TLS *TLSConfig
Auth *AuthConfig
Realm string
AllowReadAccess bool `mapstructure:",omitempty"`
ReadOnly bool `mapstructure:",omitempty"`
Address string
Port string
TLS *TLSConfig
Auth *AuthConfig
RawAccessControl map[string]interface{} `mapstructure:"accessControl,omitempty"`
Realm string
AllowReadAccess bool `mapstructure:",omitempty"`
ReadOnly bool `mapstructure:",omitempty"`
}
type LDAPConfig struct {
@ -80,13 +84,14 @@ type GlobalStorageConfig struct {
}
type Config struct {
Version string
Commit string
BinaryType string
Storage GlobalStorageConfig
HTTP HTTPConfig
Log *LogConfig
Extensions *ext.ExtensionConfig
Version string
Commit string
BinaryType string
AccessControl *AccessControlConfig
Storage GlobalStorageConfig
HTTP HTTPConfig
Log *LogConfig
Extensions *ext.ExtensionConfig
}
func NewConfig() *Config {
@ -134,3 +139,39 @@ func (c *Config) Validate(log log.Logger) error {
return nil
}
// LoadAccessControlConfig populates config.AccessControl struct with values from config.
func (c *Config) LoadAccessControlConfig() error {
if c.HTTP.RawAccessControl == nil {
return nil
}
c.AccessControl = &AccessControlConfig{}
c.AccessControl.Repositories = make(map[string]PolicyGroup)
for k := range c.HTTP.RawAccessControl {
var policies []Policy
var policyGroup PolicyGroup
if k == "adminpolicy" {
adminPolicy := viper.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", k), &policies)
if err != nil {
return err
}
defaultPolicy := viper.GetStringSlice(fmt.Sprintf("http.accessControl.%s.defaultPolicy", k))
policyGroup.Policies = policies
policyGroup.DefaultPolicy = defaultPolicy
c.AccessControl.Repositories[k] = policyGroup
}
return nil
}

View file

@ -39,16 +39,17 @@ import (
)
const (
BaseURL = "http://127.0.0.1:%s"
BaseSecureURL = "https://127.0.0.1:%s"
username = "test"
passphrase = "test"
ServerCert = "../../test/data/server.cert"
ServerKey = "../../test/data/server.key"
CACert = "../../test/data/ca.crt"
AuthorizedNamespace = "everyone/isallowed"
UnauthorizedNamespace = "fortknox/notallowed"
ALICE = "alice"
BaseURL = "http://127.0.0.1:%s"
BaseSecureURL = "https://127.0.0.1:%s"
username = "test"
passphrase = "test"
ServerCert = "../../test/data/server.cert"
ServerKey = "../../test/data/server.key"
CACert = "../../test/data/ca.crt"
AuthorizedNamespace = "everyone/isallowed"
UnauthorizedNamespace = "fortknox/notallowed"
ALICE = "alice"
AuthorizationNamespace = "authz/image"
)
type (
@ -1639,6 +1640,395 @@ func parseBearerAuthHeader(authHeaderRaw string) *authHeader {
return &h
}
func TestAuthorizationWithBasicAuth(t *testing.T) {
Convey("Make a new controller", t, func() {
port := getFreePort()
baseURL := getBaseURL(port, false)
config := api.NewConfig()
config.HTTP.Port = port
htpasswdPath := makeHtpasswdFile()
defer os.Remove(htpasswdPath)
config.HTTP.Auth = &api.AuthConfig{
HTPasswd: api.AuthHTPasswd{
Path: htpasswdPath,
},
}
config.AccessControl = &api.AccessControlConfig{
Repositories: api.Repositories{
AuthorizationNamespace: api.PolicyGroup{
Policies: []api.Policy{
{
Users: []string{},
Actions: []string{},
},
},
DefaultPolicy: []string{},
},
},
AdminPolicy: api.Policy{
Users: []string{},
Actions: []string{},
},
}
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
err = copyFiles("../../test/data", dir)
if err != nil {
panic(err)
}
c.Config.Storage.RootDirectory = dir
go func() {
// this blocks
if err := c.Run(); err != nil {
return
}
}()
// wait till ready
for {
_, err := resty.R().Get(baseURL)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
defer func() {
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
}()
blob := []byte("hello, blob!")
digest := godigest.FromBytes(blob).String()
// everybody should have access to /v2/
resp, err := resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
// everybody should have access to /v2/_catalog
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
var e api.Error
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/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add test user to repo's policy with create perm
config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users =
append(config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test")
config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(config.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, 202)
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, 201)
// head blob should get 403 with 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, 403)
// get blob should get 403 without read perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// 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, 403)
// get tags with read access should get 200
config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(config.AccessControl.Repositories[AuthorizationNamespace].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, 200)
// 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, 200)
// 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, 200)
// 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, 403)
// add delete perm on repo
config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions =
append(config.AccessControl.Repositories[AuthorizationNamespace].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, 202)
// 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")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add read perm on repo
config.AccessControl.Repositories["zot-test"] = api.PolicyGroup{Policies: []api.Policy{
{
[]string{"test"},
[]string{"read"},
},
}, DefaultPolicy: []string{}}
// get manifest should get 200 now
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/zot-test/manifests/0.0.1")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
manifestBlob := resp.Body()
// put manifest should get 403 without create perm
resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add create perm on repo
config.AccessControl.Repositories["zot-test"].Policies[0].Actions =
append(config.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create")
// should get 201 with create perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
// update manifest should get 403 without update perm
resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add update perm on repo
config.AccessControl.Repositories["zot-test"].Policies[0].Actions =
append(config.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update")
// update manifest should get 201 with update perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
// now use default repo policy
config.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{}
repoPolicy := config.AccessControl.Repositories["zot-test"]
repoPolicy.DefaultPolicy = []string{"update"}
config.AccessControl.Repositories["zot-test"] = repoPolicy
// update manifest should get 201 with update perm on repo's default policy
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
// with default read on repo should still get 200
config.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{}
repoPolicy = config.AccessControl.Repositories[AuthorizationNamespace]
repoPolicy.DefaultPolicy = []string{"read"}
config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
// upload blob without user create but with default create should get 200
repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "create")
config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 202)
//remove per repo policy
repoPolicy = config.AccessControl.Repositories[AuthorizationNamespace]
repoPolicy.Policies = []api.Policy{}
repoPolicy.DefaultPolicy = []string{}
config.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// let's use admin policy
// remove all repo based policy
delete(config.AccessControl.Repositories, AuthorizationNamespace)
delete(config.AccessControl.Repositories, "zot-test")
// whithout any perm 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, 403)
// add read perm
config.AccessControl.AdminPolicy.Users = append(config.AccessControl.AdminPolicy.Users, "test")
config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "read")
// with read perm should get 200
resp, err = resty.R().SetBasicAuth(username, passphrase).
Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
// without create perm should 403
resp, err = resty.R().SetBasicAuth(username, passphrase).
Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add create perm
config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "create")
// with create perm 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, 202)
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, 201)
// without delete perm should 403
resp, err = resty.R().SetBasicAuth(username, passphrase).
Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add delete perm
config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "delete")
// with delete perm 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, 202)
// without update perm should 403
resp, err = resty.R().SetBasicAuth(username, passphrase).SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
// add update perm
config.AccessControl.AdminPolicy.Actions = append(config.AccessControl.AdminPolicy.Actions, "update")
// update manifest should get 201 with update perm
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 201)
config.AccessControl = &api.AccessControlConfig{}
resp, err = resty.R().SetBasicAuth(username, passphrase).
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/zot-test/manifests/0.0.2")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 403)
})
}
func TestInvalidCases(t *testing.T) {
Convey("Invalid repo dir", t, func() {
port := getFreePort()

View file

@ -54,6 +54,11 @@ func NewRouteHandler(c *Controller) *RouteHandler {
func (rh *RouteHandler) SetupRoutes() {
rh.c.Router.Use(AuthHandler(rh.c))
if !isBearerAuthEnabled(rh.c.Config) && rh.c.Config.AccessControl != nil {
rh.c.Router.Use(AuthzHandler(rh.c))
}
g := rh.c.Router.PathPrefix(RoutePrefix).Subrouter()
{
g.HandleFunc(fmt.Sprintf("/{name:%s}/tags/list", NameRegexp.String()),
@ -1137,7 +1142,20 @@ func (rh *RouteHandler) ListRepositories(w http.ResponseWriter, r *http.Request)
combineRepoList = append(combineRepoList, repos...)
}
is := RepositoryList{Repositories: combineRepoList}
var repos []string
// get passed context from authzHandler and filter out repos based on permissions
if authCtx := r.Context().Value(authzCtxKey); authCtx != nil {
acCtx := authCtx.(AccessControlContext)
for _, r := range combineRepoList {
if containsRepo(acCtx.userAllowedRepos, r) || acCtx.isAdmin {
repos = append(repos, r)
}
}
} else {
repos = combineRepoList
}
is := RepositoryList{Repositories: repos}
WriteJSON(w, http.StatusOK, is)
}

View file

@ -4,6 +4,7 @@ import (
"github.com/anuvu/zot/errors"
"github.com/anuvu/zot/pkg/api"
"github.com/anuvu/zot/pkg/storage"
"github.com/fsnotify/fsnotify"
"github.com/mitchellh/mapstructure"
distspec "github.com/opencontainers/distribution-spec/specs-go"
"github.com/rs/zerolog/log"
@ -31,29 +32,66 @@ func NewRootCmd() *cobra.Command {
Long: "`serve` stores and distributes OCI images",
Run: func(cmd *cobra.Command, args []string) {
if len(args) > 0 {
viper.SetConfigFile(args[0])
if err := viper.ReadInConfig(); err != nil {
panic(err)
}
md := &mapstructure.Metadata{}
if err := viper.Unmarshal(&config, metadataConfig(md)); err != nil {
panic(err)
}
// if haven't found a single key or there were unused keys, report it as
// a error
if len(md.Keys) == 0 || len(md.Unused) > 0 {
panic(errors.ErrBadConfig)
}
LoadConfiguration(config, args[0])
}
c := api.NewController(config)
// creates a new file watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
defer watcher.Close()
done := make(chan bool)
// run watcher
go func() {
go func() {
for {
select {
// watch for events
case event := <-watcher.Events:
if event.Op == fsnotify.Write {
log.Info().Msg("Config file changed, trying to reload accessControl config")
newConfig := api.NewConfig()
LoadConfiguration(newConfig, args[0])
c.Config.AccessControl = newConfig.AccessControl
}
// watch for errors
case err := <-watcher.Errors:
log.Error().Err(err).Msgf("FsNotify error while watching config %s", args[0])
panic(err)
}
}
}()
if err := watcher.Add(args[0]); err != nil {
log.Error().Err(err).Msgf("Error adding config file %s to FsNotify watcher", args[0])
panic(err)
}
<-done
}()
if err := c.Run(); err != nil {
panic(err)
}
},
}
verifyCmd := &cobra.Command{
Use: "verify <config>",
Aliases: []string{"verify"},
Short: "`verify` validates a zot config file",
Long: "`verify` validates a zot config file",
Run: func(cmd *cobra.Command, args []string) {
if len(args) > 0 {
config := api.NewConfig()
LoadConfiguration(config, args[0])
log.Info().Msgf("Config file %s is valid", args[0])
}
},
}
// "garbage-collect"
gcDelUntagged := false
gcDryRun := false
@ -98,6 +136,7 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(gcCmd)
rootCmd.AddCommand(verifyCmd)
enableCli(rootCmd)
@ -105,3 +144,29 @@ func NewRootCmd() *cobra.Command {
return rootCmd
}
func LoadConfiguration(config *api.Config, configPath string) {
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
log.Error().Err(err).Msg("Error while reading configuration")
panic(err)
}
md := &mapstructure.Metadata{}
if err := viper.Unmarshal(&config, metadataConfig(md)); err != nil {
log.Error().Err(err).Msg("Error while unmarshalling new config")
panic(err)
}
if len(md.Keys) == 0 || len(md.Unused) > 0 {
log.Error().Err(errors.ErrBadConfig).Msg("Bad configuration, retry writing it")
panic(errors.ErrBadConfig)
}
err := config.LoadAccessControlConfig()
if err != nil {
log.Error().Err(errors.ErrBadConfig).Msg("Unable to unmarshal http.accessControl.key.policies")
panic(err)
}
}

View file

@ -6,8 +6,10 @@ import (
"path"
"testing"
"github.com/anuvu/zot/pkg/api"
"github.com/anuvu/zot/pkg/cli"
. "github.com/smartystreets/goconvey/convey"
"github.com/spf13/viper"
)
func TestUsage(t *testing.T) {
@ -65,6 +67,51 @@ func TestServe(t *testing.T) {
})
}
func TestVerify(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
Convey("Test verify bad config", t, func(c C) {
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpfile.Name()) // clean up
content := []byte(`{"log":{}}`)
_, 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 good config", t, func(c C) {
tmpfile, err := ioutil.TempFile("", "zot-test*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpfile.Name()) // clean up
content := []byte(`{"version": "0.1.0-dev", "storage": {"rootDirectory": "/tmp/zot"},
"http": {"address": "127.0.0.1", "port": "8080", "ReadOnly": false},
"log": {"level": "debug"}}`)
_, 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)
})
}
func TestLoadConfig(t *testing.T) {
Convey("Test viper load config", t, func(c C) {
config := api.NewConfig()
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"])
})
}
func TestGC(t *testing.T) {
oldArgs := os.Args