Authorization for Private Docker Registry

Thilina Manamgoda
6 min readJun 11, 2018

--

I have recently deployed a Private Docker registry and ran into the problem “How do I govern this ????” because Docker registry itself cannot handle this problem. Well there is a solution for the problem !. Docker Auth is an authentication server which is written for the Token Authentication Specification published by Docker. This service offers serveral methods for Authentication and Authorization.

Supported authentication methods:

  • Static list of users
  • Google Sign-In
  • LDAP bind
  • MongoDB user collection
  • Extension point

Supported authorization methods:

  • Static ACL
  • MongoDB-backed ACL
  • Extension point

Following flow chart displays the flow of execution,

Flow of the Authorization
  1. Attempt to begin a push/pull operation with the registry.
  2. If the registry requires authorization it will return a 401 Unauthorized HTTP response with information on how to authenticate.
  3. The Docker daemon makes a request to the authorization service for a Bearer token.
  4. The authorization service returns an opaque Bearer token representing the client’s authorized access.
  5. The client retries the original request with the Bearer token embedded in the request’s Authorization header.
  6. The Registry authorizes the client by validating the Bearer token and the claim set embedded within it and begins the push/pull session as usual.

Most of the time you would be able to handle authorization with the above-listed methods. But if you cannot make it work with the existing methods, extension point method is the way to go.

But the main barrier in extension point path is it only allows you to execute authentication & authorization logic as a shell script. Basically a Linux executable. But it not easy to write a complex logic in shell scripting. For example handling HTTP.

We can overcome this barrier by implementing our logic using GO and creating an executable. We have to keep it in mind that Auth server is deployed as a Busybox based container and the executable should be built targeting this architecture. Let’s setup the development environment as follow,

  1. Install Go, Docker & Docker compose
  2. Let’s build the Docker Auth module
  • Export Go path export GOPATH=~/go
  • Export Go bin export PATH=$PATH:$GOPATH/bin
  • Make directory “cesanta” in go src folder mkdir $GOPATH/src/cesanta
  • Git clone the project
cd $GOPATH/src/cesanta && \
git clone https://github.com/cesanta/docker_auth.git && \
cd docker_auth/auth_server
  • Build the project make deps

3. Clone the tutorial repository

git clone https://github.com/ThilinaManamgoda/docker_registry_auth.git

4. Go to cloned repository cd docker_registry_auth

5. Create self signed certificates for Docker registry & Docker Auth server in conf/ssldirectory

Create a private key and a CSR,


openssl req \
-newkey rsa:2048 -nodes -keyout domain.key \
-out domain.csr \
-subj "/C=US/ST=New York/L=Brooklyn/O=Example Brooklyn Company/CN=example.docker.com"

Create a self-signed cert,

openssl x509 \
-signkey domain.key \
-in domain.csr \
-req -days 365 -out domain.crt

6. Add example.docker.com to /etc/hosts file

sudo su && echo "127.0.0.1       example.docker.com" >> /etc/hosts

7. Now we can start implementing Authentication & Authorization.

According to the Extension point API, executable should exit with code 0 if the user is authorized and 1 if not.

Let’s work on a simple scenario as described below,

User name: admin
Password: admin
Repository: hello-world
Actions allowed: push/pull

Once the authentication executable is invoked, username and password are written to the standard input stream of the executable’s process. As you can see in the following code segment I have handled the input and verified whether the user is admin and password is admin. If the user is authenticated then the process should exit with code 0 and 1 if not.

func main() {
text := utils.ReadStdIn()
credentials := strings.Split(text, " ")// text = "admin admin"

if len(credentials) != 2 {
fmt.Println("Cannot parse the Input from the Auth service")
os.Exit(utils.ErrorExitCode)
}
uName := credentials[0]
password := credentials[1]

isUserAuthenticated := false
if
uName == "admin" && password == "admin" {
isUserAuthenticated = true
}


if isUserAuthenticated {
os.Exit(utils.SuccessExitCode)
} else {
os.Exit(utils.ErrorExitCode)
}
}

Input for the Authorization executable is little bit different. JSON string representation of AuthRequestInfo struct is written to the standard input stream. Attributes of this struct are as follow,

type AuthRequestInfo struct {
Account string //User Name
Type string
Name string // Repository
Service string
IP net.IP // IP of the client
Actions []string //[push, pull]
Labels authn.Labels
}

First, we can read the string from the input stream and construct the AuthRequestInfo object. Then you can use this object to validate ACL with username, Repository and the action whether the user is asking for a pull or push. In the following code segment, I have only allowed the admin user to download the hello-world image. Push action is restricted by checking the action array.

const  PushKeyWord  = "push"

func
main() {
text := utils.ReadStdIn()
// Create the authReqInfo object from the input
var authReqInfo authz.AuthRequestInfo
err := json.Unmarshal([]byte(text), &authReqInfo)
if err != nil {
os.Exit(utils.ErrorExitCode)
}

// Only allowed to "Pull". If "Push" access needed, define the rules via static ACL
if utils.ArrayContains(authReqInfo.Actions, PushKeyWord) {
fmt.Println("The user " + authReqInfo.Account + " requesting \"push\" access for the Repo: " + authReqInfo.Name)
os.Exit(utils.ErrorExitCode)
}

repo := authReqInfo.Name
user := authReqInfo.Account

isAuthorized := false
if
repo == "hello-world" && user == "admin" {
isAuthorized = true
}

if isAuthorized {
os.Exit(utils.SuccessExitCode)
} else {
os.Exit(utils.ErrorExitCode)
}
}

You may have to think about the process like Logging since a main limitation of this method is that you cannot maintain a lock between spawned executables since they are independent of each other.

Let’s build executables in the conf/extensions folder executing following command,

cd conf/extensions && \ 
GOOS=linux GOARCH=386 go build ../../main/authentication.go && \
GOOS=linux GOARCH=386 go build ../../main/authorization.go

Now we have to point these executables for the Auth server in the configuration for this deployment as shown below. Please find the detailed description of the configuration from here.

# A simple example. See reference.yml for explanation for explanation of all options.
#
# auth:
# token:
# realm: "https://127.0.0.1:5001/auth"
# service: "Docker registry"
# issuer: "Acme auth server"
# rootcertbundle: "/path/to/server.pem"

server:
addr: ":5001"
certificate: "/ssl/domain.crt" //
key: "/ssl/domain.key"

token:
issuer: "Auth Service"
# Must match issuer in the Registry config.
expiration: 900

acl:
# This will allow authenticated users to pull/push
- match:
account: "admin"
actions:
['push']

ext_auth:
command: "/extensions/authentication"
# Can be a relative path too; $PATH works.
args: [""]

ext_authz:
command: "/extensions/authorization"
args:
[""]

As you can see above, I have allowed the push action only for the user admin via static ACL in the extAuth.yml file. Authorization process starts with static ACL since it is defined before the extension point and goes through each defined methods until it succeeds.

8. Docker registry and the Auth server are deployed as containers using Docker-compose. conf folder contains the docker-compose.yml file.

version: "2.3"
services:
registry:
image:
registry:2
ports:
- "5000:5000"
volumes:
- ./ssl:/ssl
- ./data:/data
restart: always
environment:
- REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/data
- REGISTRY_AUTH=token
- REGISTRY_AUTH_TOKEN_REALM=https://example.docker.com:5001/auth
- REGISTRY_AUTH_TOKEN_SERVICE="Docker registry"
- REGISTRY_AUTH_TOKEN_ISSUER="Auth Service"
- REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/ssl/domain.crt
- REGISTRY_HTTP_TLS_CERTIFICATE=/ssl/domain.crt
- REGISTRY_HTTP_TLS_KEY=/ssl/domain.key
dockerauth:
image:
cesanta/docker_auth
ports:
- "5001:5001"
volumes:
- ./:/config:ro
- ./ssl:/ssl
- ./extensions:/extensions
command: -alsologtostderr=true -log_dir=/logs /config/extAuth.yml
restart: always

As shown above, SSL certificates, config files and extensions are mounted as volumes. Auth server is started with the extAuth.yml configuration file.

Now simply run docker-compose up from the conf directory. Once the deployment is up and running, let’s try to push hello-world:latest image first.

Login to docker registry,

docker login example.docker.com:5000

Enter admin for both username and password.

Download hello-world:latest image and push it to the registry,

docker pull hello-world:latest && \ 
docker tag hello-world:latest example.docker.com:5000/hello-world:latest && \
docker push example.docker.com:5000/hello-world:latest

Now try to pull it back,

docker rmi example.docker.com:5000/hello-world:latest && \
docker pull example.docker.com:5000/hello-world:latest

Now you have successfully deployed a Docker registry with custom Authorization.

--

--