Skip to content

Commit

Permalink
config: rework auth config
Browse files Browse the repository at this point in the history
Over the course of writing this documentation, I realized that the
current authentication configuration was going to present problems when
running in non-combo modes and may even prevent running with more
automatic/secure configurations, like mutual TLS.

The code changes here are geared toward making those possible while
keeping a single-config setup.

Signed-off-by: Hank Donnay <hdonnay@redhat.com>
  • Loading branch information
hdonnay committed Apr 15, 2020
1 parent fa95f5d commit 2ed3c2c
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 39 deletions.
2 changes: 1 addition & 1 deletion Documentation/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Summary

- [About](./TODO.md)
- [Operation](./TODO.md)
- [Operation](./operation.md)
- [API](./TODO.md)
- [Internal Endpoints](./api_internal.md)
- [Contribution](./TODO.md)
Expand Down
106 changes: 106 additions & 0 deletions Documentation/operation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Operation

## Releases

All of the source code needed to build clair is packaged as an archive and
attached to the release. Releases are tracked at the [github releases].

[github releases]: https://github.com/quay/clair/releases

## Official Containers

Clair is officially packaged and released as a container at
[quay.io/projectquay/clair]. The `latest` tag tracks the git development branch,
and version tags are built from the corresponding release.

[quay.io/projectquay/clair]: https://quay.io/repository/projectquay/clair

## Architecture

Clair is structured so that it can be easily scaled with demand. It can be
broken up into up to 3 microservices as needed ([Indexer], [Matcher], and
[Notifier]) or run as a single monolith. Each process talks to separate tables
in the database and is responsible for disparate API endpoints.

[Indexer]: #indexer
[Matcher]: #matcher
[Notifier]: #notifier

### Indexer

Responsible for ...

### Matcher

Responsible for ...

### Notifier

Responsible for ...

## Ingress

One recommended configuration is to use some sort of service ingress to route
API endpoints to the component responsible for servicing it.

## Authentication

Previous versions of Clair used [jwtproxy] to gate authentication. For ease of
building and deployment, v4 handles authentication itself.

Authentication is configured by specifying configuration objects underneath the
`auth` key of the configuration. Multiple authentication configurations may be
present, but they will be used preferentially in the order laid out below.

[jwtproxy]: https://github.com/quay/jwtproxy

### Quay Integration

Quay implements a keyserver protocol that allows for publishing and rotating
keys in an automated fashion. Any process that has successfully enrolled in the
keyserver that Clair is configured to talk to should be able to sign requests to
Clair.

#### Configuration

The `auth` stanza of the configuration file requires one parameter, `api`, which
is the API endpoint of keyserver protocol.

```yaml
auth:
keyserver:
api: 'https://quay.example.com/keys/'
```

##### Intraservice

When Clair instances are configured with keyserver authentication and run in any
other mode besides "combo", an additional `intraservice` key is
required. This key is used for signing and verifying requests within the
Clair service cluster.

```yaml
auth:
keyserver:
api: 'https://quay.example.com/keys/'
intraservice: >-
MDQ4ODBlNDAtNDc0ZC00MWUxLThhMzAtOTk0MzEwMGQwYTMxCg==
```

### PSK

Clair implements JWT-based authentication using a pre-shared key.

#### Configuration

The `auth` stanza of the configuration file requires two parameters: `iss`, which
is the issuer to validate on all incoming requests; and `key`, which is a base64
encoded symmetric key for validating the requests.

```yaml
auth:
psk:
key: >-
MDQ4ODBlNDAtNDc0ZC00MWUxLThhMzAtOTk0MzEwMGQwYTMxCg==
iss: 'issuer'
```
32 changes: 30 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,37 @@ type Config struct {
Metrics Metrics `yaml:"metrics"`
}

// Auth holds the specific configs for different authentication methods.
//
// These should be pointers to structs, so that it's possible to distinguish
// between "absent" and "present and misconfigured."
type Auth struct {
Name string `yaml:"name"`
Params map[string]string `yaml:"params"`
PSK *AuthPSK `yaml:"psk,omitempty"`
Keyserver *AuthKeyserver `yaml:"keyserver,omitempty"`
}

// Any reports whether any sort of authentication is configured.
func (a Auth) Any() bool {
return a.PSK != nil ||
a.Keyserver != nil
}

// AuthKeyserver is the configuration for doing authentication with the Quay
// keyserver protocol.
//
// The "Intraservice" key is only needed when the overall config mode is not
// "combo".
type AuthKeyserver struct {
API string `yaml:"api"`
Intraservice []byte `yaml:"intraservice"`
}

// AuthPSK is the configuration for doing pre-shared key based authentication.
//
// The "Issuer" key is what the service expects to verify as the "issuer claim.
type AuthPSK struct {
Key []byte `yaml:"key"`
Issuer string `yaml:"iss"`
}

type Indexer struct {
Expand Down
75 changes: 75 additions & 0 deletions config/httpclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package config

import (
"net/http"
"time"

"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)

// Client returns an http.Client configured according to the supplied
// configuration.
//
// It returns an *http.Client and a boolean indicating whether the client is
// configured for authentication, or an error that occurred during construction.
func (cfg *Config) Client(next *http.Transport) (c *http.Client, authed bool, err error) {
authed = false
sk := jose.SigningKey{Algorithm: jose.HS256}

// Keep this organized from "best" to "worst". That way, we can add methods
// and keep everything working with some careful cluster rolling.
switch {
case cfg.Auth.Keyserver != nil:
sk.Key = cfg.Auth.Keyserver.Intraservice
case cfg.Auth.PSK != nil:
sk.Key = cfg.Auth.PSK.Key
default:
}
rt := &transport{next: next}
c = &http.Client{Transport: rt}

// Both of the JWT-based methods set the signing key.
if sk.Key != nil {
signer, err := jose.NewSigner(sk, nil)
if err != nil {
return nil, false, err
}
rt.Signer = signer
authed = true
}
return c, authed, nil
}

var _ http.RoundTripper = (*transport)(nil)

// Transport does request modification common to all requests.
type transport struct {
jose.Signer
next http.RoundTripper
}

func (cs *transport) RoundTrip(r *http.Request) (*http.Response, error) {
const (
issuer = `clair-intraservice`
userAgent = `clair/v4`
)
r.Header.Set("user-agent", userAgent)
if cs.Signer != nil {
// TODO(hank) Make this mint longer-lived tokens and re-use them, only
// refreshing when needed. Like a resettable sync.Once.
now := time.Now()
cl := jwt.Claims{
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now.Add(-jwt.DefaultLeeway)),
Expiry: jwt.NewNumericDate(now.Add(jwt.DefaultLeeway)),
Issuer: issuer,
}
h, err := jwt.Signed(cs).Claims(&cl).CompactSerialize()
if err != nil {
return nil, err
}
r.Header.Add("authorization", "Bearer "+h)
}
return cs.next.RoundTrip(r)
}
53 changes: 25 additions & 28 deletions httptransport/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package httptransport

import (
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
Expand Down Expand Up @@ -85,7 +84,7 @@ func New(ctx context.Context, conf config.Config, indexer indexer.Service, match

// add endpoint authentication if configured add auth. must happen after
// mux was configured for given mode.
if conf.Auth.Name != "" {
if conf.Auth.Any() {
err := t.configureWithAuth()
if err != nil {
log.Warn().Err(err).Msg("received error configuring auth middleware")
Expand Down Expand Up @@ -234,49 +233,47 @@ func (t *Server) configureUpdateEndpoints() error {
return nil
}

// IntraserviceIssuer is the issuer that will be used if Clair is configured to
// mint its own JWTs.
const IntraserviceIssuer = `clair-intraservice`

// configureWithAuth will take the current serve mux and wrap it
// in an Auth middleware handler.
//
// must be ran after the config*Mode method of choice.
func (t *Server) configureWithAuth() error {
switch t.conf.Auth.Name {
case "keyserver":
const param = "api"
api, ok := t.conf.Auth.Params[param]
if !ok {
return fmt.Errorf("missing needed config key: %q", param)
}
ks, err := auth.NewQuayKeyserver(api)
// Keep this ordered "best" to "worst".
switch {
case t.conf.Auth.Keyserver != nil:
cfg := t.conf.Auth.Keyserver
checks := []auth.Checker{}
ks, err := auth.NewQuayKeyserver(cfg.API)
if err != nil {
return fmt.Errorf("failed to initialize quay keyserver: %v", err)
}
t.Server.Handler = auth.Handler(t.Server.Handler, ks)
case "psk":
const (
iss = "issuer"
key = "key"
)
ek, ok := t.conf.Auth.Params[key]
if !ok {
return fmt.Errorf("missing needed config key: %q", key)
checks = append(checks, ks)
if cfg.Intraservice != nil {
psk, err := auth.NewPSK(cfg.Intraservice, IntraserviceIssuer)
if err != nil {
return fmt.Errorf("failed to initialize quay keyserver: %w", err)
}
checks = append(checks, psk)
}
k, err := base64.StdEncoding.DecodeString(ek)
t.Server.Handler = auth.Handler(t.Server.Handler, checks...)
case t.conf.Auth.PSK != nil:
cfg := t.conf.Auth.PSK
intra, err := auth.NewPSK(cfg.Key, IntraserviceIssuer)
if err != nil {
return err
}
i, ok := t.conf.Auth.Params[iss]
if !ok {
return fmt.Errorf("missing needed config key: %q", iss)
}
psk, err := auth.NewPSK(k, i)
psk, err := auth.NewPSK(cfg.Key, cfg.Issuer)
if err != nil {
return err
}
t.Server.Handler = auth.Handler(t.Server.Handler, psk)
t.Server.Handler = auth.Handler(t.Server.Handler, intra, psk)
default:
return fmt.Errorf("failed to recognize auth middle type: %v", t.conf.Auth.Name)
}
panic("should not reach")
return nil
}

// WriterError is a helper that closes over an error that may be returned after
Expand Down
14 changes: 13 additions & 1 deletion initialize/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,19 @@ func (i *Init) Services() error {
return fmt.Errorf("failed to initialize libvuln: %v", err)
}
// matcher mode needs a remote indexer client
remoteIndexer, err := client.NewHTTP(i.GlobalCTX, client.WithAddr(i.conf.Matcher.IndexerAddr))
c, auth, err := i.conf.Client(nil)
switch {
case err != nil:
return err
case !auth && i.conf.Auth.Any():
return &clairerror.ErrNotInitialized{
Msg: "client authorization required but not provided",
}
default: // OK
}
remoteIndexer, err := client.NewHTTP(i.GlobalCTX,
client.WithAddr(i.conf.Matcher.IndexerAddr),
client.WithClient(c))
if err != nil {
return err
}
Expand Down
Loading

0 comments on commit 2ed3c2c

Please sign in to comment.