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