Keep Mongo operations online across Vault credential rotation
This commit is contained in:
1 parent
80d6917aa7
commit
23f7b78c3b
30 files changed
+1290
-202
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// Package vault issues independently owned MongoDB leases through Vault AppRole.
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address"`
|
||||
DatabaseRole string `mapstructure:"role"`
|
||||
RoleIDFile string `mapstructure:"role_id_file"`
|
||||
SecretIDFile string `mapstructure:"secret_id_file"`
|
||||
}
|
||||
type Issuer struct {
|
||||
config Config
|
||||
uri *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(config Config, uri string) (*Issuer, error) {
|
||||
endpoint, err := url.Parse(config.Address)
|
||||
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("invalid Vault address")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(config.DatabaseRole) || config.RoleIDFile == "" || config.SecretIDFile == "" {
|
||||
return nil, merrors.InvalidArgument("Vault database role and AppRole files are required")
|
||||
}
|
||||
database, err := url.Parse(uri)
|
||||
if err != nil || database.Scheme != "mongodb" || database.Host == "" || database.User != nil {
|
||||
return nil, merrors.InvalidArgument("Vault requires a MongoDB URI without embedded credentials")
|
||||
}
|
||||
return &Issuer{config: config, uri: database, http: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Auth struct {
|
||||
Token string `json:"client_token"`
|
||||
TTL int `json:"lease_duration"`
|
||||
} `json:"auth"`
|
||||
TTL int `json:"lease_duration"`
|
||||
LeaseID string `json:"lease_id"`
|
||||
Data struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (i *Issuer) request(ctx context.Context, path, token string, body any) (response, error) {
|
||||
var out response
|
||||
var input io.Reader
|
||||
method := http.MethodGet
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return out, merrors.InternalWrap(err, "encode Vault request")
|
||||
}
|
||||
input = bytes.NewReader(raw)
|
||||
method = http.MethodPost
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(i.config.Address, "/")+"/v1/"+path, input)
|
||||
if err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "create Vault request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("X-Vault-Token", token)
|
||||
}
|
||||
res, err := i.http.Do(req)
|
||||
if err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Vault request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return out, merrors.ServiceUnavailable("Vault request was rejected")
|
||||
}
|
||||
if res.StatusCode == http.StatusNoContent {
|
||||
return out, nil
|
||||
}
|
||||
if err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid Vault response")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (i *Issuer) Issue(ctx context.Context) (source.Lease, error) {
|
||||
var lease source.Lease
|
||||
role, err := os.ReadFile(i.config.RoleIDFile)
|
||||
if err != nil {
|
||||
return lease, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Vault AppRole identity")
|
||||
}
|
||||
secret, err := os.ReadFile(i.config.SecretIDFile)
|
||||
if err != nil {
|
||||
return lease, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Vault AppRole credential")
|
||||
}
|
||||
started := time.Now()
|
||||
login, err := i.request(ctx, "auth/approle/login", "", map[string]string{"role_id": strings.TrimSpace(string(role)), "secret_id": strings.TrimSpace(string(secret))})
|
||||
if err != nil {
|
||||
return lease, err
|
||||
}
|
||||
if login.Auth.Token == "" {
|
||||
return lease, merrors.FailedPrecondition("Vault returned no application identity")
|
||||
}
|
||||
// One token owns one database lease. Revoking it never touches Agent's token
|
||||
// or the replacement generation, and requires only the default self policy.
|
||||
revoke := func(ctx context.Context) error {
|
||||
_, err := i.request(ctx, "auth/token/revoke-self", login.Auth.Token, map[string]any{})
|
||||
return err
|
||||
}
|
||||
if login.Auth.TTL <= 0 {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = revoke(cleanup)
|
||||
return lease, merrors.FailedPrecondition("Vault returned no bounded identity lifetime")
|
||||
}
|
||||
credential, err := i.request(ctx, "database/creds/"+i.config.DatabaseRole, login.Auth.Token, nil)
|
||||
if err != nil || credential.Data.Username == "" || credential.Data.Password == "" || credential.TTL <= 0 || credential.LeaseID == "" {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = revoke(cleanup)
|
||||
if err != nil {
|
||||
return lease, err
|
||||
}
|
||||
return lease, merrors.FailedPrecondition("Vault returned incomplete database credentials")
|
||||
}
|
||||
uri := *i.uri
|
||||
uri.User = url.UserPassword(credential.Data.Username, credential.Data.Password)
|
||||
return source.Lease{URI: uri.String(), ExpiresAt: started.Add(time.Duration(min(login.Auth.TTL, credential.TTL)) * time.Second), Revoke: revoke}, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssuerLifetimeEncodingAndCleanup(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
identityTTL, databaseTTL int
|
||||
reject bool
|
||||
wantError bool
|
||||
}{
|
||||
{"identity limits lease", 4, 20, false, false},
|
||||
{"database limits lease", 20, 4, false, false},
|
||||
{"invalid identity", 0, 20, false, true},
|
||||
{"invalid database lifetime", 20, 0, false, true},
|
||||
{"rejected issuance", 20, 20, true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var revoked atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/auth/approle/login":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": "private-token", "lease_duration": test.identityTTL}})
|
||||
case "/v1/database/creds/bff":
|
||||
if test.reject {
|
||||
http.Error(w, "private-password", 403)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"lease_id": "lease", "lease_duration": test.databaseTTL, "data": map[string]string{"username": "user@host", "password": "p@ss:/?#"}})
|
||||
case "/v1/auth/token/revoke-self":
|
||||
if r.Header.Get("X-Vault-Token") == "private-token" {
|
||||
revoked.Store(true)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
directory := t.TempDir()
|
||||
identity, secret := filepath.Join(directory, "identity"), filepath.Join(directory, "secret")
|
||||
require.NoError(t, os.WriteFile(identity, []byte("identity"), 0600))
|
||||
require.NoError(t, os.WriteFile(secret, []byte("secret"), 0600))
|
||||
issuer, err := New(Config{Address: server.URL, DatabaseRole: "bff", RoleIDFile: identity, SecretIDFile: secret}, "mongodb://localhost/db?authSource=admin")
|
||||
require.NoError(t, err)
|
||||
started := time.Now()
|
||||
lease, err := issuer.Issue(context.Background())
|
||||
if test.wantError {
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "private-")
|
||||
require.True(t, revoked.Load())
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, started.Add(4*time.Second), lease.ExpiresAt, time.Second)
|
||||
parsed, err := url.Parse(lease.URI)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user@host", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
require.Equal(t, "p@ss:/?#", password)
|
||||
require.False(t, revoked.Load())
|
||||
require.NoError(t, lease.Revoke(context.Background()))
|
||||
require.True(t, revoked.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user