Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not store local service account token and CA to config. #122

Merged
merged 11 commits into from
Jan 7, 2022
86 changes: 82 additions & 4 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"strings"
"sync"
"time"

"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
Expand All @@ -27,6 +29,17 @@ var (
// when adding new alias name sources make sure to update the corresponding FieldSchema description in path_role.go
aliasNameSources = []string{aliasNameSourceSAUid, aliasNameSourceSAName}
errInvalidAliasNameSource = fmt.Errorf(`invalid alias_name_source, must be one of: %s`, strings.Join(aliasNameSources, ", "))

// jwtReloadPeriod is the time period how often the in-memory copy of local
// service account token can be used, before reading it again from disk.
//
// Background for the selected value:
// Service account token expiration time is configurable by Kubernetes API
// server parameter service-account-max-token-expiration. The token is
// renewed when 80% of the expiration time is reached. The lowest allowed
tomhjp marked this conversation as resolved.
Show resolved Hide resolved
// value is 1h. Reload every of 10 minutes guarantees that we will re-read
// the file before expiration, even if cluster uses minimum expiration time.
jwtReloadPeriod = 10 * time.Minute
)

// kubeAuthBackend implements logical.Backend
Expand All @@ -38,6 +51,19 @@ type kubeAuthBackend struct {
// review. Mocks should only be used in tests.
reviewFactory tokenReviewFactory

// localSATokenReader caches the service account token in memory.
// It periodically reloads the token to support token rotation/renewal.
// Local token is used when running in a pod with following configuration
// - token_reviewer_jwt is not set
// - disable_local_ca_jwt is true
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
localSATokenReader *cachingFileReader

// localCACert contains the local CA certificate. Local CA certificate is
// used when running in a pod with following configuration
// - kubernetes_ca_cert is not set
// - disable_local_ca_jwt is true
localCACert string

l sync.RWMutex
}

Expand All @@ -54,9 +80,10 @@ func Backend() *kubeAuthBackend {
b := &kubeAuthBackend{}

b.Backend = &framework.Backend{
AuthRenew: b.pathLoginRenew(),
BackendType: logical.TypeCredential,
Help: backendHelp,
InitializeFunc: b.initialize,
AuthRenew: b.pathLoginRenew(),
BackendType: logical.TypeCredential,
Help: backendHelp,
PathsSpecial: &logical.Paths{
Unauthenticated: []string{
"login",
Expand All @@ -80,7 +107,8 @@ func Backend() *kubeAuthBackend {
return b
}

// config takes a storage object and returns a kubeConfig object
// config takes a storage object and returns a kubeConfig object.
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
// It does not return local token and CA file which are specific to the pod we run in.
func (b *kubeAuthBackend) config(ctx context.Context, s logical.Storage) (*kubeConfig, error) {
raw, err := s.Get(ctx, configPath)
if err != nil {
Expand All @@ -107,6 +135,8 @@ func (b *kubeAuthBackend) config(ctx context.Context, s logical.Storage) (*kubeC
return conf, nil
}

// loadConfig fetches the kubeConfig from storage and optionally decorates it with
// local token and CA certificate.
func (b *kubeAuthBackend) loadConfig(ctx context.Context, s logical.Storage) (*kubeConfig, error) {
config, err := b.config(ctx, s)
if err != nil {
Expand All @@ -115,6 +145,20 @@ func (b *kubeAuthBackend) loadConfig(ctx context.Context, s logical.Storage) (*k
if config == nil {
return nil, errors.New("could not load backend configuration")
}

// Add the local files if required.
if !config.DisableLocalCAJwt {
if len(config.TokenReviewerJWT) == 0 {
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
config.TokenReviewerJWT, err = b.localSATokenReader.ReadFile()
if err != nil {
return nil, err
}
}
if len(config.CACert) == 0 {
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
config.CACert = b.localCACert
}
}

return config, nil
}

Expand Down Expand Up @@ -156,6 +200,40 @@ func (b *kubeAuthBackend) role(ctx context.Context, s logical.Storage, name stri
return role, nil
}

// initialize will initialize the global data.
func (b *kubeAuthBackend) initialize(ctx context.Context, req *logical.InitializationRequest) error {
// Check if configuration exists and load local token and CA cert files
// if they are used.
config, _ := b.config(ctx, req.Storage)
if config != nil && !config.DisableLocalCAJwt && (len(config.TokenReviewerJWT) == 0 || len(config.CACert) == 0) {
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
err := b.loadLocalFiles()
if err != nil {
return err
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
}
}
return nil
}

// loadLocalFiles reads the local token and CA file into memory.
//
// The function should be called only in context where write lock to backend is
// held or it is otherwise guaranteed that we can update backend object.
func (b *kubeAuthBackend) loadLocalFiles() error {
b.localSATokenReader = newCachingFileReader(localJWTPath, jwtReloadPeriod)
_, err := b.localSATokenReader.ReadFile()
if err != nil {
return err
}

buf, err := ioutil.ReadFile(localCACertPath)
if err != nil {
return err
}
b.localCACert = string(buf)

return nil
}

func validateAliasNameSource(source string) error {
for _, s := range aliasNameSources {
if s == source {
Expand Down
64 changes: 64 additions & 0 deletions caching_file_reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kubeauth

import (
"io/ioutil"
"sync"
"time"
)

// cachingFileReader reads a file and keeps an in-memory copy of it, until the
// copy is considered stale. Next ReadFile() after expiry will re-read the file from disk.
type cachingFileReader struct {
// path is the file path to the cached file.
path string

// ttl is the time-to-live duration when cached file is considered stale
ttl time.Duration

// cache is the buffer holding the in-memory copy of the file.
cache *cachedFile

l sync.RWMutex
}

type cachedFile struct {
// buf is the buffer holding the in-memory copy of the file.
buf string

// expiry is the time when the cached copy is considered stale and must be re-read.
expiry time.Time
}

func newCachingFileReader(path string, ttl time.Duration) *cachingFileReader {
return &cachingFileReader{
path: path,
ttl: ttl,
}
}

func (r *cachingFileReader) ReadFile() (string, error) {
// Fast path requiring read lock only: file is already in memory and not stale.
now := time.Now()
r.l.RLock()
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
cache := r.cache
tsaarni marked this conversation as resolved.
Show resolved Hide resolved
r.l.RUnlock()

if cache != nil && now.Before(cache.expiry) {
return cache.buf, nil
}

// Slow path: read the file from disk.
r.l.Lock()
defer r.l.Unlock()

buf, err := ioutil.ReadFile(r.path)
if err != nil {
return "", err
}
r.cache = &cachedFile{
buf: string(buf),
expiry: now.Add(r.ttl),
}

return r.cache.buf, nil
}
57 changes: 57 additions & 0 deletions caching_file_reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package kubeauth

import (
"io/ioutil"
"os"
"testing"
"time"
)

func TestCachingFileReader(t *testing.T) {
content1 := "before"
content2 := "after"

// Create temporary file.
f, err := ioutil.TempFile("", "testfile")
if err != nil {
t.Error(err)
}
f.Close()
defer os.Remove(f.Name())

r := newCachingFileReader(f.Name(), 1*time.Second)

// Write initial content to file and check that we can read it.
ioutil.WriteFile(f.Name(), []byte(content1), 0644)
got, err := r.ReadFile()
if err != nil {
t.Error(err)
}
if got != content1 {
t.Errorf("got '%s', expected '%s'", got, content1)
}

// Write new content to the file.
ioutil.WriteFile(f.Name(), []byte(content2), 0644)

// Read again and check we still got the old cached content.
got, err = r.ReadFile()
if err != nil {
t.Error(err)
}
if got != content1 {
t.Errorf("got '%s', expected '%s'", got, content1)
}

// Wait for cache to expire.
time.Sleep(2 * time.Second)
tsaarni marked this conversation as resolved.
Show resolved Hide resolved

// Read again and check that we got the new content.
got, err = r.ReadFile()
if err != nil {
t.Error(err)
}
if got != content2 {
t.Errorf("got '%s', expected '%s'", got, content2)
}
}
40 changes: 18 additions & 22 deletions path_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"crypto/x509"
"encoding/pem"
"errors"
"io/ioutil"

"github.com/briankassouf/jose/jws"
"github.com/hashicorp/vault/sdk/framework"
Expand Down Expand Up @@ -122,34 +121,31 @@ func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Requ
}

disableLocalJWT := data.Get("disable_local_ca_jwt").(bool)
localCACert := []byte{}
localTokenReviewer := []byte{}
if !disableLocalJWT {
localCACert, _ = ioutil.ReadFile(localCACertPath)
localTokenReviewer, _ = ioutil.ReadFile(localJWTPath)
}
pemList := data.Get("pem_keys").([]string)
caCert := data.Get("kubernetes_ca_cert").(string)
issuer := data.Get("issuer").(string)
disableIssValidation := data.Get("disable_iss_validation").(bool)
if len(pemList) == 0 && len(caCert) == 0 {
tomhjp marked this conversation as resolved.
Show resolved Hide resolved
if len(localCACert) > 0 {
caCert = string(localCACert)
} else {
return logical.ErrorResponse("one of pem_keys or kubernetes_ca_cert must be set"), nil
}
}

tokenReviewer := data.Get("token_reviewer_jwt").(string)
if !disableLocalJWT && len(tokenReviewer) == 0 && len(localTokenReviewer) > 0 {
tokenReviewer = string(localTokenReviewer)
}

if len(tokenReviewer) > 0 {
// Validate it's a JWT
_, err := jws.ParseJWT([]byte(tokenReviewer))
if disableLocalJWT {
if len(tokenReviewer) > 0 {
// Validate it's a JWT
_, err := jws.ParseJWT([]byte(tokenReviewer))
if err != nil {
return nil, err
}
}
if len(caCert) == 0 {
return logical.ErrorResponse("kubernetes_ca_cert must be given when disable_local_ca_jwt is true"), nil
}
} else if len(tokenReviewer) == 0 || len(caCert) == 0 {
// User did not provide token or CA certificate:
// load local token and CA cert into memory but do not store them persistently.
b.l.Lock()
defer b.l.Unlock()
err := b.loadLocalFiles()
if err != nil {
return nil, err
return logical.ErrorResponse(err.Error()), nil
}
}

Expand Down
Loading