Skip to content

Commit 609d85d

Browse files
Add identity-based access control, closes #51
Add a cli subcommand to verify config files validity
1 parent 26926ad commit 609d85d

14 files changed

Lines changed: 913 additions & 49 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ https://anuvu.github.io/zot/
2727
* TLS mutual authentication
2828
* HTTP *Basic* (local _htpasswd_ and LDAP)
2929
* HTTP *Bearer* token
30+
* [Authorization](#Authorization) Supports Identity-Based Access Control: [Configuration](./examples/config-policy.json)
31+
* Supports live modifications on the config file while zot is running (Authorization config only)
3032
* Doesn't require _root_ privileges
3133
* Storage optimizations:
3234
* Automatic garbage collection of orphaned blobs
@@ -222,6 +224,15 @@ c3/openjdk-dev commit-2674e8a-squashfs b545b8ba 321MB
222224
c3/openjdk-dev commit-d5024ec-squashfs cd45f8cf 321MB
223225
```
224226

227+
## Authorization
228+
229+
zot follows the next logic for deciding who has rights on what resource:
230+
1) First it checks the requested resource in "repositories" as specified in configuration
231+
2) Searches in all policies for the user who requested the resource
232+
3) If found, applies the policy, otherwise it falls back to the default policy for that repository
233+
4) If not found, it falls back to the adminPolicy
234+
235+
225236
# Ecosystem
226237

227238

docs/docs.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,13 +729,13 @@ type swaggerInfo struct {
729729
}
730730

731731
// SwaggerInfo holds exported Swagger Info so clients can modify it
732-
var SwaggerInfo = swaggerInfo{ Schemes: []string{}}
732+
var SwaggerInfo = swaggerInfo{Schemes: []string{}}
733733

734734
type s struct{}
735735

736736
func (s *s) ReadDoc() string {
737737
t, err := template.New("swagger_info").Funcs(template.FuncMap{
738-
"marshal": func(v interface {}) string {
738+
"marshal": func(v interface{}) string {
739739
a, _ := json.Marshal(v)
740740
return string(a)
741741
},

examples/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
You can check a configuration file validity by running the verify command:
2+
3+
```console
4+
$ zot verify /path/to/config
5+
```

examples/config-minimal.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"ReadOnly": false
1010
},
1111
"log": {
12-
"level": "debug"
12+
"level": "debug",
13+
"output": "/tmp/zot.log"
1314
}
1415
}

examples/config-policy.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"version": "0.1.0-dev",
3+
"storage": {
4+
"rootDirectory": "/tmp/zot"
5+
},
6+
"http": {
7+
"address": "127.0.0.1",
8+
"port": "8080",
9+
"realm": "zot",
10+
"auth": {
11+
"htpasswd": {
12+
"path": "test/data/htpasswd"
13+
},
14+
"failDelay": 1
15+
},
16+
"accessControl": {
17+
"repos1/repo": {
18+
"policies": [
19+
{
20+
"users": ["alice", "bob"],
21+
"actions": ["create", "read", "update", "delete"]
22+
},
23+
{
24+
"users": ["mallory"],
25+
"actions": ["create", "read"]
26+
}
27+
],
28+
"defaultPolicy": ["read"]
29+
},
30+
"repos2/repo": {
31+
"policies": [
32+
{
33+
"users": ["bob"],
34+
"actions": ["read", "create"]
35+
},
36+
{
37+
"users": ["mallory"],
38+
"actions": ["create", "read"]
39+
}
40+
],
41+
"defaultPolicy": ["read"]
42+
},
43+
"adminPolicy": {
44+
"users": ["admin"],
45+
"actions": ["read", "create", "update", "delete"]
46+
}
47+
}
48+
},
49+
"log": {
50+
"level": "debug",
51+
"output": "/tmp/zot.log"
52+
}
53+
}
54+

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/briandowns/spinner v1.12.0
1212
github.com/chartmuseum/auth v0.4.5
1313
github.com/dustin/go-humanize v1.0.0
14+
github.com/fsnotify/fsnotify v1.4.9
1415
github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a
1516
github.com/go-ldap/ldap/v3 v3.3.0
1617
github.com/gofrs/uuid v4.0.0+incompatible
@@ -40,7 +41,7 @@ require (
4041
github.com/swaggo/swag v1.7.0
4142
github.com/vektah/gqlparser/v2 v2.2.0
4243
go.etcd.io/bbolt v1.3.5
43-
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
44+
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
4445
gopkg.in/resty.v1 v1.12.0
4546
gopkg.in/yaml.v2 v2.4.0
4647
)

go.sum

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,9 +1148,8 @@ golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPh
11481148
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
11491149
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
11501150
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
1151+
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
11511152
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
1152-
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
1153-
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
11541153
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
11551154
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
11561155
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=

pkg/api/auth.go renamed to pkg/api/authn.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,7 @@ const (
2323
)
2424

2525
func AuthHandler(c *Controller) mux.MiddlewareFunc {
26-
if c.Config.HTTP.Auth != nil &&
27-
c.Config.HTTP.Auth.Bearer != nil &&
28-
c.Config.HTTP.Auth.Bearer.Cert != "" &&
29-
c.Config.HTTP.Auth.Bearer.Realm != "" &&
30-
c.Config.HTTP.Auth.Bearer.Service != "" {
26+
if isBearerAuthEnabled(c.Config) {
3127
return bearerAuthHandler(c)
3228
}
3329

pkg/api/authz.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"net/http"
7+
"strings"
8+
"time"
9+
10+
"github.com/anuvu/zot/pkg/log"
11+
"github.com/gorilla/mux"
12+
)
13+
14+
type contextKey int
15+
16+
const (
17+
// actions
18+
CREATE = "create"
19+
READ = "read"
20+
UPDATE = "update"
21+
DELETE = "delete"
22+
23+
// request-local context key
24+
authzCtxKey contextKey = 0
25+
)
26+
27+
type AccessControlConfig struct {
28+
Repositories Repositories
29+
AdminPolicy Policy
30+
}
31+
32+
type Repositories map[string]PolicyGroup
33+
34+
type PolicyGroup struct {
35+
Policies []Policy
36+
DefaultPolicy []string
37+
}
38+
39+
type Policy struct {
40+
Users []string
41+
Actions []string
42+
}
43+
44+
// AccessController authorizes users to act on resources.
45+
type AccessController struct {
46+
Config *AccessControlConfig
47+
Log log.Logger
48+
}
49+
50+
// AccessControlContext context passed down to http.Handlers.
51+
type AccessControlContext struct {
52+
userAllowedRepos []string
53+
isAdmin bool
54+
}
55+
56+
func NewAccessController(config *Config) *AccessController {
57+
return &AccessController{
58+
Config: config.AccessControl,
59+
Log: log.NewLogger(config.Log.Level, config.Log.Output),
60+
}
61+
}
62+
63+
// getReadRepos get repositories from config file that the user has READ perms.
64+
func (ac *AccessController) getReadRepos(username string) []string {
65+
var repos []string
66+
67+
for r, pg := range ac.Config.Repositories {
68+
for _, p := range pg.Policies {
69+
if (contains(p.Users, username) && contains(p.Actions, READ)) ||
70+
contains(pg.DefaultPolicy, READ) {
71+
repos = append(repos, r)
72+
}
73+
}
74+
}
75+
76+
return repos
77+
}
78+
79+
// can verifies if a user can do action on repository.
80+
func (ac *AccessController) can(username, action, repository string) bool {
81+
can := false
82+
// check repo based policy
83+
pg, ok := ac.Config.Repositories[repository]
84+
if ok {
85+
can = isPermitted(username, action, pg)
86+
}
87+
88+
//check admins based policy
89+
if !can {
90+
if ac.isAdmin(username) && contains(ac.Config.AdminPolicy.Actions, action) {
91+
can = true
92+
}
93+
}
94+
95+
return can
96+
}
97+
98+
// isAdmin .
99+
func (ac *AccessController) isAdmin(username string) bool {
100+
return contains(ac.Config.AdminPolicy.Users, username)
101+
}
102+
103+
// getContext builds ac context(allowed to read repos and if user is admin) and returns it.
104+
func (ac *AccessController) getContext(username string, r *http.Request) context.Context {
105+
userAllowedRepos := ac.getReadRepos(username)
106+
acCtx := AccessControlContext{userAllowedRepos: userAllowedRepos}
107+
108+
if ac.isAdmin(username) {
109+
acCtx.isAdmin = true
110+
} else {
111+
acCtx.isAdmin = false
112+
}
113+
114+
ctx := context.WithValue(r.Context(), authzCtxKey, acCtx)
115+
116+
return ctx
117+
}
118+
119+
// isPermitted returns true if username can do action on a repository policy.
120+
func isPermitted(username, action string, pg PolicyGroup) bool {
121+
var result bool
122+
// check repo/system based policies
123+
for _, p := range pg.Policies {
124+
if contains(p.Users, username) && contains(p.Actions, action) {
125+
result = true
126+
break
127+
}
128+
}
129+
130+
// check defaultPolicy
131+
if !result {
132+
if contains(pg.DefaultPolicy, action) {
133+
result = true
134+
}
135+
}
136+
137+
return result
138+
}
139+
140+
func contains(slice []string, item string) bool {
141+
for _, v := range slice {
142+
if item == v {
143+
return true
144+
}
145+
}
146+
147+
return false
148+
}
149+
150+
func containsRepo(slice []string, item string) bool {
151+
for _, v := range slice {
152+
if strings.HasPrefix(item, v) {
153+
return true
154+
}
155+
}
156+
157+
return false
158+
}
159+
160+
func AuthzHandler(c *Controller) mux.MiddlewareFunc {
161+
return func(next http.Handler) http.Handler {
162+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
163+
vars := mux.Vars(r)
164+
resource := vars["name"]
165+
reference, ok := vars["reference"]
166+
167+
ac := NewAccessController(c.Config)
168+
username := getUsername(r)
169+
ctx := ac.getContext(username, r)
170+
171+
if r.RequestURI == "/v2/_catalog" || r.RequestURI == "/v2/" {
172+
next.ServeHTTP(w, r.WithContext(ctx))
173+
return
174+
}
175+
176+
var action string
177+
if r.Method == http.MethodGet || r.Method == http.MethodHead {
178+
action = READ
179+
}
180+
181+
if r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodPost {
182+
// assume user wants to create
183+
action = CREATE
184+
// if we get a reference (tag)
185+
if ok {
186+
is := c.StoreController.GetImageStore(resource)
187+
tags, err := is.GetImageTags(resource)
188+
// if repo exists and request's tag doesn't exist yet then action is UPDATE
189+
if err == nil && contains(tags, reference) && reference != "latest" {
190+
action = UPDATE
191+
}
192+
}
193+
}
194+
195+
if r.Method == http.MethodDelete {
196+
action = DELETE
197+
}
198+
199+
can := ac.can(username, action, resource)
200+
if !can {
201+
authzFail(w, c.Config.HTTP.Realm, c.Config.HTTP.Auth.FailDelay)
202+
} else {
203+
next.ServeHTTP(w, r.WithContext(ctx))
204+
}
205+
})
206+
}
207+
}
208+
209+
func getUsername(r *http.Request) string {
210+
// this should work because it worked in auth middleware
211+
basicAuth := r.Header.Get("Authorization")
212+
s := strings.SplitN(basicAuth, " ", 2)
213+
b, _ := base64.StdEncoding.DecodeString(s[1])
214+
pair := strings.SplitN(string(b), ":", 2)
215+
216+
return pair[0]
217+
}
218+
219+
func isBearerAuthEnabled(config *Config) bool {
220+
if config.HTTP.Auth != nil &&
221+
config.HTTP.Auth.Bearer != nil &&
222+
config.HTTP.Auth.Bearer.Cert != "" &&
223+
config.HTTP.Auth.Bearer.Realm != "" &&
224+
config.HTTP.Auth.Bearer.Service != "" {
225+
return true
226+
}
227+
228+
return false
229+
}
230+
231+
func authzFail(w http.ResponseWriter, realm string, delay int) {
232+
time.Sleep(time.Duration(delay) * time.Second)
233+
w.Header().Set("WWW-Authenticate", realm)
234+
w.Header().Set("Content-Type", "application/json")
235+
WriteJSON(w, http.StatusForbidden, NewErrorList(NewError(DENIED)))
236+
}

0 commit comments

Comments
 (0)