Files
sxchnge/backend/shared/database/vault/issuer.go
T

141 lines
5.1 KiB
Go

// 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
}