Skip to content

Commit

Permalink
Merge pull request cesanta#44 from kwk/acl-backend-mongo
Browse files Browse the repository at this point in the history
Add MongoDB backend for ACLs
  • Loading branch information
rojer committed Nov 12, 2015
2 parents c5ebd6f + 84329da commit 3a1aa21
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 13 deletions.
6 changes: 4 additions & 2 deletions auth_server/authz/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ type aclAuthorizer struct {
acl ACL
}

func NewACLAuthorizer(acl ACL) Authorizer {
return &aclAuthorizer{acl: acl}
// NewACLAuthorizer Creates a new static authorizer with ACL that have been read from the config file
func NewACLAuthorizer(acl ACL) (Authorizer, error) {
glog.V(1).Infof("Created ACL Authorizer with %d entries", len(acl))
return &aclAuthorizer{acl: acl}, nil
}

func (aa *aclAuthorizer) Authorize(ai *AuthRequestInfo) ([]string, error) {
Expand Down
166 changes: 166 additions & 0 deletions auth_server/authz/acl_mongo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package authz

import (
"errors"
"fmt"
"io/ioutil"
"strings"
"sync"
"time"

"github.com/golang/glog"
"gopkg.in/mgo.v2"
"gopkg.in/mgo.v2/bson"
)

// ACLMongoConfig stores how to connect to the MongoDB server and how long
// an ACL remains valid until new ones will be fetched.
type ACLMongoConfig struct {
DialInfo ACLMongoDialConfig `yaml:"dial_info,omitempty"`
Collection string `yaml:"collection,omitempty"`
CacheTTL time.Duration `yaml:"cache_ttl,omitempty"`
}

// ACLMongoDialConfig stores how we connect to the MongoDB server
type ACLMongoDialConfig struct {
mgo.DialInfo `yaml:",inline"`
PasswordFile string `yaml:"password_file,omitempty"`
}

// Validate ensures the most common fields inside the mgo.DialInfo portion of
// an ACLMongoDialInfo are set correctly as well as other fields inside the
// ACLMongoConfig itself.
func (c *ACLMongoConfig) Validate() error {
if len(c.DialInfo.DialInfo.Addrs) == 0 {
return errors.New("At least one element in acl_mongo.dial_info.addrs is required")
}
if c.DialInfo.DialInfo.Timeout == 0 {
c.DialInfo.DialInfo.Timeout = 10 * time.Second
}
if c.DialInfo.DialInfo.Database == "" {
return errors.New("acl_mongo.dial_info.database is required")
}
if c.Collection == "" {
return errors.New("acl_mongo.collection is required")
}
if c.CacheTTL < 0 {
return errors.New(`acl_mongo.cache_ttl is required (e.g. "1m" for 1 minute)`)
}
return nil
}

type aclMongoAuthorizer struct {
lastCacheUpdate time.Time
lock sync.RWMutex
config ACLMongoConfig
staticAuthorizer Authorizer
session *mgo.Session
updateTicker *time.Ticker
}

// NewACLMongoAuthorizer creates a new ACL Mongo authorizer
func NewACLMongoAuthorizer(c ACLMongoConfig) (Authorizer, error) {
// Attempt to create a MongoDB session which we can re-use when handling
// multiple auth requests.

// Read in the password (if any)
if c.DialInfo.PasswordFile != "" {
passBuf, err := ioutil.ReadFile(c.DialInfo.PasswordFile)
if err != nil {
return nil, fmt.Errorf(`Failed to read password file "%s": %s`, c.DialInfo.PasswordFile, err)
}
c.DialInfo.DialInfo.Password = strings.TrimSpace(string(passBuf))
}

glog.V(2).Infof("Creating MongoDB session (operation timeout %s)", c.DialInfo.DialInfo.Timeout)
session, err := mgo.DialWithInfo(&c.DialInfo.DialInfo)
if err != nil {
return nil, err
}

authorizer := &aclMongoAuthorizer{
config: c,
session: session,
updateTicker: time.NewTicker(c.CacheTTL),
}

// Initially fetch the ACL from MongoDB
if err := authorizer.updateACLCache(); err != nil {
return nil, err
}

go authorizer.continuouslyUpdateACLCache()

return authorizer, nil
}

func (ma *aclMongoAuthorizer) Authorize(ai *AuthRequestInfo) ([]string, error) {
ma.lock.RLock()
defer ma.lock.RUnlock()

// Test if authorizer has been initialized
if ma.staticAuthorizer == nil {
return nil, fmt.Errorf("MongoDB authorizer is not ready")
}

return ma.staticAuthorizer.Authorize(ai)
}

func (ma *aclMongoAuthorizer) Stop() {
// This causes the background go routine which updates the ACL to stop
ma.updateTicker.Stop()

// Close connection to MongoDB database (if any)
if ma.session != nil {
ma.session.Close()
}
}

func (ma *aclMongoAuthorizer) Name() string {
return "MongoDB ACL"
}

// continuouslyUpdateACLCache checks if the ACL cache has expired and depending
// on the the result it updates the cache with the ACL from the MongoDB server.
// The ACL will be stored inside the static authorizer instance which we use
// to minimize duplication of code and maximize reuse of existing code.
func (ma *aclMongoAuthorizer) continuouslyUpdateACLCache() {
var tick time.Time
for ; true; tick = <-ma.updateTicker.C {
aclAge := time.Now().Sub(ma.lastCacheUpdate)
glog.V(2).Infof("Updating ACL at %s (ACL age: %s. CacheTTL: %s)", tick, aclAge, ma.config.CacheTTL)

err := ma.updateACLCache()
if err == nil {
continue
}

glog.Errorf("Failed to update ACL. ERROR: %s", err)
glog.Warningf("Using stale ACL (Age: %s, TTL: %s)", aclAge, ma.config.CacheTTL)
}
}

func (ma *aclMongoAuthorizer) updateACLCache() error {
// Get ACL from MongoDB
var newACL ACL
collection := ma.session.DB(ma.config.DialInfo.DialInfo.Database).C(ma.config.Collection)
err := collection.Find(bson.M{}).All(&newACL)
if err != nil {
return err
}
glog.V(2).Infof("Number of new ACL entries from MongoDB: %d", len(newACL))

newStaticAuthorizer, err := NewACLAuthorizer(newACL)
if err != nil {
return err
}

ma.lock.Lock()
ma.lastCacheUpdate = time.Now()
ma.staticAuthorizer = newStaticAuthorizer
ma.lock.Unlock()

glog.V(2).Infof("Got new ACL from MongoDB: %s", newACL)
glog.V(1).Infof("Installed new ACL from MongoDB (%d entries)", len(newACL))
return nil
}
4 changes: 2 additions & 2 deletions auth_server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type RestartableServer struct {
}

func ServeOnce(c *server.Config, cf string, hd *httpdown.HTTP) (*server.AuthServer, httpdown.Server) {
glog.Infof("Config from %s (%d users, %d ACL entries)", cf, len(c.Users), len(c.ACL))
glog.Infof("Config from %s (%d users, %d ACL static entries)", cf, len(c.Users), len(c.ACL))
as, err := server.NewAuthServer(c)
if err != nil {
glog.Exitf("Failed to create auth server: %s", err)
Expand Down Expand Up @@ -101,6 +101,7 @@ func (rs *RestartableServer) WatchConfig() {
if err != nil {
glog.Fatalf("Failed to create watcher: %s", err)
}
defer w.Close()

stopSignals := make(chan os.Signal, 1)
signal.Notify(stopSignals, syscall.SIGTERM, syscall.SIGINT)
Expand Down Expand Up @@ -137,7 +138,6 @@ func (rs *RestartableServer) WatchConfig() {
glog.Exitf("Exiting")
}
}
w.Close()
}

func (rs *RestartableServer) MaybeRestart() {
Expand Down
22 changes: 14 additions & 8 deletions auth_server/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ import (
)

type Config struct {
Server ServerConfig `yaml:"server"`
Token TokenConfig `yaml:"token"`
Users map[string]*authn.Requirements `yaml:"users,omitempty"`
GoogleAuth *authn.GoogleAuthConfig `yaml:"google_auth,omitempty"`
LDAPAuth *authn.LDAPAuthConfig `yaml:"ldap_auth,omitempty"`
ACL authz.ACL `yaml:"acl"`
Server ServerConfig `yaml:"server"`
Token TokenConfig `yaml:"token"`
Users map[string]*authn.Requirements `yaml:"users,omitempty"`
GoogleAuth *authn.GoogleAuthConfig `yaml:"google_auth,omitempty"`
LDAPAuth *authn.LDAPAuthConfig `yaml:"ldap_auth,omitempty"`
ACL authz.ACL `yaml:"acl"`
ACLMongoConf *authz.ACLMongoConfig `yaml:"acl_mongo"`
}

type ServerConfig struct {
Expand Down Expand Up @@ -87,8 +88,13 @@ func validate(c *Config) error {
gac.HTTPTimeout = 10
}
}
if c.ACL == nil {
return errors.New("ACL is empty, this is probably a mistake. Use an empty list if you really want to deny all actions.")
if c.ACL == nil && c.ACLMongoConf == nil {
return errors.New("ACL is empty, this is probably a mistake. Use an empty list if you really want to deny all actions")
}
if c.ACLMongoConf != nil {
if err := c.ACLMongoConf.Validate(); err != nil {
return err
}
}
return nil
}
Expand Down
16 changes: 15 additions & 1 deletion auth_server/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,21 @@ type AuthServer struct {
func NewAuthServer(c *Config) (*AuthServer, error) {
as := &AuthServer{
config: c,
authorizers: []authz.Authorizer{authz.NewACLAuthorizer(c.ACL)},
authorizers: []authz.Authorizer{},
}
if c.ACL != nil {
staticAuthorizer, err := authz.NewACLAuthorizer(c.ACL)
if err != nil {
return nil, err
}
as.authorizers = append(as.authorizers, staticAuthorizer)
}
if c.ACLMongoConf != nil {
mongoAuthorizer, err := authz.NewACLMongoAuthorizer(*c.ACLMongoConf)
if err != nil {
return nil, err
}
as.authorizers = append(as.authorizers, mongoAuthorizer)
}
if c.Users != nil {
as.authenticators = append(as.authenticators, authn.NewStaticUserAuth(c.Users))
Expand Down
73 changes: 73 additions & 0 deletions docs/ACL_Backend_MongoDB.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ACL backend in MongoDB

Maybe you want to manage your ACL from an external application and therefore
need them to be stored outside of your auth_server's configuration file.

For this purpose, there's a [MongoDB](https://www.mongodb.org/) ACL backend
which can query an ACL from a MongoDB database.

A typical ACL entry from the static YAML configuration file looks something like
this:

```
- match: {account: "/.+/", name: "${account}/*"}
actions: ["push", "pull"]
comment: "All logged in users can push all images that are in a namespace beginning with their name"
```

Notice the use of a regular expression (`/.+/`), a placeholder (`${account}`),
and in particular the `actions` array.

The ACL entry as is it is stored inside the static YAML file can be mapped to
MongoDB quite easily. Below you can find a list of ACL entries that are ready to
be imported into MongoDB. Those ACL entries reflect what's specified in the
`example/reference.yml` file under the `acl` section (aka static ACL).

**reference_acl.json**

```json
{"match" : {"account" : "admin"}, "actions" : ["*"], "comment" : "Admin has full access to everything."}
{"match" : {"account" : "test", "name" : "test-*"}, "actions" : ["*"], "comment" : "User \"test\" has full access to test-* images but nothing else. (1)"}
{"match" : {"account" : "test"}, "actions" : [], "comment" : "User \"test\" has full access to test-* images but nothing else. (2)"}
{"match" : {"account" : "/.+/"}, "actions" : ["pull"], "comment" : "All logged in users can pull all images."}
{"match" : {"account" : "/.+/", "name" : "${account}/*"}, "actions" : ["*"], "comment" : "All logged in users can push all images that are in a namespace beginning with their name"}
{"match" : {"account" : "", "name" : "hello-world"}, "actions" : ["pull"], "comment" : "Anonymous users can pull \"hello-world\"."}
```

**Note** that each document entry must span exactly one line or otherwise the
`mongoimport` tool (see below) will not accept it.

## Import reference ACLs into MongoDB

To import the above specified ACL entries from the reference file, simply
execute the following commands.

### Ensure MongoDB is running

If you don't have a MongoDB server running, consider to start it within it's own
docker container:

`docker run --name mongo-acl -d mongo`

Then wait until the MongoDB server is ready to accept connections. You can find
this out by running `docker logs -f mongo-acl`. Once you see the message
`waiting for connections on port 27017`, you can proceed with the instructions
below.

### Get mongoimport tool

On Ubuntu this is a matter of `sudo apt-get install mongodb-clients`.

### Import ACLs

```bash
MONGO_IP=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' mongo-acl)
mongoimport --host $MONGO_IP --db docker_auth --collection acl < reference_acl.json
```

This should print a message like this if everything was successful:

```
connected to: 172.17.0.4
Wed Nov 4 13:34:15.816 imported 6 objects
```
27 changes: 27 additions & 0 deletions examples/reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ ldap_auth:
base: "o=example.com"
filter: "(&(uid=${account})(objectClass=person))"

# Authorization methods. All are tried, any one returning success is sufficient.
# At least one must be configured.

# ACL specifies who can do what. If the match section of an entry matches the
# request, the set of allowed actions will be applied to the token request
# and a ticket will be issued only for those of the requested actions that are
Expand Down Expand Up @@ -115,3 +118,27 @@ acl:
actions: ["pull"]
comment: "Anonymous users can pull \"hello-world\"."
# Access is denied by default.

# (optional) Define to query ACL from a MongoDB server.
acl_mongo:
# Essentially all options are described here: https://godoc.org/gopkg.in/mgo.v2#DialInfo
dial_info:
# The MongoDB hostnames or IPs to connect to.
addrs: ["localhost"]
# The time to wait for a server to respond when first connecting and on
# follow up operations in the session. If timeout is zero, the call may
# block forever waiting for a connection to be established.
# (See https://golang.org/pkg/time/#ParseDuration for a format description.)
timeout: "10s"
# Database name that will be used on the MongoDB server.
database: "docker_auth"
# The username with which to connect to the MongoDB server.
user: ""
# Path to the text file with the password in it.
password_file: ""
# Name of the collection in which ACLs will be stored in MongoDB.
collection: "acl"
# Specify how long an ACL remains valid before they will be fetched again from
# the MongoDB server.
# (See https://golang.org/pkg/time/#ParseDuration for a format description.)
cache_ttl: "1m"

0 comments on commit 3a1aa21

Please sign in to comment.