Keep Mongo operations online across Vault credential rotation

This commit is contained in:
Stephan D committed 2026-09-12 22:19:36 +02:00
1 parent 80d6917aa7
commit 23f7b78c3b
30 files changed
+1290 -202

No files matched your search

+19 -37
View File
@@ -4,56 +4,38 @@ import (
"context"
mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo"
"git.sendico.io/tech/sxchnge/shared/database/source"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
// Connection represents a low-level database connection lifecycle.
type Connection interface {
Disconnect(ctx context.Context) error
Ping(ctx context.Context) error
Disconnect(context.Context) error
Ping(context.Context) error
}
// MongoConnection provides direct access to the underlying mongo client.
type MongoConnection struct {
client *mongo.Client
database string
}
func (c *MongoConnection) Client() *mongo.Client {
return c.client
}
func (c *MongoConnection) Database() *mongo.Database {
return c.client.Database(c.database)
}
func (c *MongoConnection) Disconnect(ctx context.Context) error {
return c.client.Disconnect(ctx)
database source.Source
close func(context.Context) error
}
func (c *MongoConnection) Database() source.Source { return c.database }
func (c *MongoConnection) Disconnect(ctx context.Context) error { return c.close(ctx) }
func (c *MongoConnection) Ping(ctx context.Context) error {
return c.client.Ping(ctx, readpref.Primary())
database, ctx, release, err := c.database.Acquire(ctx)
if err != nil {
return err
}
defer release()
return database.Client().Ping(ctx, readpref.Primary())
}
// ConnectMongo returns a low-level MongoDB connection without constructing repositories.
func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) {
if config == nil {
return nil, merrors.InvalidArgument("database configuration is nil", "config")
if config == nil || config.Driver != Mongo {
return nil, merrors.InvalidArgument("MongoDB configuration is required")
}
if config.Driver != Mongo {
return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver")
}
client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings)
database, close, err := mongoimpl.OpenSource(logger, config.Settings)
if err != nil {
return nil, err
}
return &MongoConnection{
client: client,
database: settings.Database,
}, nil
return &MongoConnection{database: database, close: close}, nil
}
@@ -2,19 +2,19 @@ package mongo
import (
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
type Dependencies struct {
Database *mongo.Database
Database source.Source
Transactions transaction.Factory
}
type DatabaseProvider interface {
MongoDatabase() *mongo.Database
MongoDatabase() source.Source
}
func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
+64 -62
View File
@@ -3,15 +3,18 @@ package mongo
import (
"context"
"fmt"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"net"
"os"
"strings"
"time"
mutil "git.sendico.io/tech/sxchnge/shared/config"
"git.sendico.io/tech/sxchnge/shared/database/internal/transaction"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
dbvault "git.sendico.io/tech/sxchnge/shared/database/vault"
"git.sendico.io/tech/sxchnge/shared/logging"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-viper/mapstructure/v2"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
@@ -20,28 +23,29 @@ import (
// Config represents configuration
type Config struct {
Port *string `mapstructure:"port"`
PortEnv *string `mapstructure:"port_env"`
User *string `mapstructure:"user"`
UserEnv *string `mapstructure:"user_env"`
PasswordEnv string `mapstructure:"password_env"`
Database *string `mapstructure:"database"`
DatabaseEnv *string `mapstructure:"database_env"`
Host *string `mapstructure:"host"`
HostEnv *string `mapstructure:"host_env"`
Hosts []string `mapstructure:"hosts,omitempty"`
HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"`
HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"`
PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"`
PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"`
URI *string `mapstructure:"uri,omitempty"`
URIEnv *string `mapstructure:"uri_env,omitempty"`
AuthSource *string `mapstructure:"auth_source,omitempty"`
AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"`
AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"`
AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"`
ReplicaSet *string `mapstructure:"replica_set,omitempty"`
ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"`
Vault *dbvault.Config `mapstructure:"vault"`
Port *string `mapstructure:"port"`
PortEnv *string `mapstructure:"port_env"`
User *string `mapstructure:"user"`
UserEnv *string `mapstructure:"user_env"`
PasswordEnv string `mapstructure:"password_env"`
Database *string `mapstructure:"database"`
DatabaseEnv *string `mapstructure:"database_env"`
Host *string `mapstructure:"host"`
HostEnv *string `mapstructure:"host_env"`
Hosts []string `mapstructure:"hosts,omitempty"`
HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"`
HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"`
PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"`
PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"`
URI *string `mapstructure:"uri,omitempty"`
URIEnv *string `mapstructure:"uri_env,omitempty"`
AuthSource *string `mapstructure:"auth_source,omitempty"`
AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"`
AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"`
AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"`
ReplicaSet *string `mapstructure:"replica_set,omitempty"`
ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"`
}
type DBSettings struct {
@@ -119,37 +123,42 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
return client, nil
}
func ConnectClient(logger mlogger.Logger, settings sharedmodel.SettingsT) (*mongo.Client, *Config, *DBSettings, error) {
config, dbSettings, err := decodeConfig(logger, settings)
if err != nil {
return nil, nil, nil, err
}
client, err := dialMongo(logger, dbSettings)
if err != nil {
return nil, nil, nil, err
}
return client, config, dbSettings, nil
}
// DB represents the structure of the database
type DB struct {
logger mlogger.Logger
config *DBSettings
client *mongo.Client
}
func (db *DB) db() *mongo.Database {
return db.client.Database(db.config.Database)
}
func (db *DB) MongoDatabase() *mongo.Database {
return db.db()
logger mlogger.Logger
database source.Source
close func(context.Context) error
}
func (db *DB) MongoDatabase() source.Source { return db.database }
func (db *DB) TransactionFactory() transaction.Factory {
return transactionimp.CreateFactory(db.client)
return transactionimp.CreateSourceFactory(db.database)
}
// OpenSource owns either a fixed connection or a rotating Vault-backed source.
func OpenSource(logger mlogger.Logger, settings sharedmodel.SettingsT) (source.Source, func(context.Context) error, error) {
config, dbSettings, err := decodeConfig(logger, settings)
if err != nil {
return nil, nil, err
}
if config.Vault != nil {
issuer, err := dbvault.New(*config.Vault, dbSettings.URI)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
manager, err := source.New(ctx, logger.Named("credentials"), dbSettings.Database, issuer)
if err != nil {
return nil, nil, err
}
return manager, manager.Close, nil
}
client, err := dialMongo(logger, dbSettings)
if err != nil {
return nil, nil, err
}
return source.Fixed(client.Database(dbSettings.Database)), client.Disconnect, nil
}
func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string {
@@ -217,24 +226,17 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
}
func (db *DB) CloseConnection() {
if err := db.client.Disconnect(context.Background()); err != nil {
db.logger.Warn("Failed to close connection", zap.Error(err))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := db.close(ctx); err != nil {
db.logger.Warn("Failed to drain database connection", zap.Error(err))
}
db.logger.Info("Database connection closed")
}
// NewConnection creates a new database connection
func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) {
client, _, dbSettings, err := ConnectClient(logger, settings)
database, close, err := OpenSource(logger, settings)
if err != nil {
return nil, err
}
db := &DB{
logger: logger.Named("db"),
config: dbSettings,
client: client,
}
return db, nil
return &DB{logger: logger, database: database, close: close}, nil
}
@@ -12,7 +12,14 @@ import (
)
func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if r.collection == nil {
ctx := context.Background()
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set")
}
if len(def.Keys) == 0 {
@@ -49,28 +56,40 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
opts.SetSparse(def.Sparse)
}
_, err := r.collection.Indexes().CreateOne(
context.Background(),
_, err = collection.Indexes().CreateOne(
ctx,
mongo.IndexModel{Keys: keys, Options: opts},
)
return err
}
func (r *MongoRepository) DropIndex(ctx context.Context, name string) error {
if r.collection == nil {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set")
}
if name == "" {
return merrors.InvalidArgument("index name is required", "index.name")
}
return r.collection.Indexes().DropOne(ctx, name)
return collection.Indexes().DropOne(ctx, name)
}
func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) {
if r.collection == nil {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if collection == nil {
return nil, merrors.NoData("data collection is not set")
}
cursor, err := r.collection.Indexes().List(ctx)
cursor, err := collection.Indexes().List(ctx)
if err != nil {
return nil, err
}
@@ -8,6 +8,7 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -17,7 +18,7 @@ import (
type MongoRepository struct {
collectionName string
collection *mongo.Collection
source source.Source
}
func idFilter(id bson.ObjectID) bson.D {
@@ -27,10 +28,23 @@ func idFilter(id bson.ObjectID) bson.D {
}
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
return &MongoRepository{
collectionName: collection,
collection: db.Collection(collection),
return NewRepository(source.Fixed(db), collection)
}
func NewRepository(database source.Source, collection string) *MongoRepository {
return &MongoRepository{collectionName: collection, source: database}
}
func (r *MongoRepository) acquire(ctx context.Context) (*mongo.Collection, context.Context, func(), error) {
if r.source == nil {
return nil, ctx, nil, merrors.FailedPrecondition("database source is not configured")
}
database, scoped, release, err := r.source.Acquire(ctx)
if err != nil {
return nil, ctx, nil, err
}
return database.Collection(r.collectionName), scoped, release, nil
}
func (r *MongoRepository) Collection() string {
@@ -48,8 +62,14 @@ func prepareForWrite(obj storable.Storable) bson.ObjectID {
}
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
prepareForWrite(obj)
_, err := r.collection.InsertOne(ctx, obj)
_, err = collection.InsertOne(ctx, obj)
if mongo.IsDuplicateKeyError(err) {
if getFilter != nil {
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
@@ -62,6 +82,12 @@ func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, get
}
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if len(objects) == 0 {
return nil
}
@@ -72,12 +98,18 @@ func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Sto
docs[i] = obj
}
_, err := r.collection.InsertMany(ctx, docs)
_, err = collection.InsertMany(ctx, docs)
return err
}
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
err := r.collection.FindOne(ctx, filter).Decode(result)
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
err = collection.FindOne(ctx, filter).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData(errMessage)
}
@@ -94,7 +126,13 @@ func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result stor
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error)
func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
cursor, err := queryFunc(ctx, r.collection)
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
cursor, err := queryFunc(ctx, collection)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData("no_items_in_array")
}
@@ -111,7 +149,7 @@ func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc,
}
}
return nil
return cursor.Err()
}
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
@@ -129,8 +167,14 @@ func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Qu
}
func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if query == nil {
cursor, err := r.collection.Find(ctx, bson.D{})
cursor, err := collection.Find(ctx, bson.D{})
if err != nil {
return err
}
@@ -139,7 +183,7 @@ func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Que
}()
return cursor.All(ctx, result)
}
cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
cursor, err := collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
if err != nil {
return err
}
@@ -154,18 +198,30 @@ func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Que
}
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
obj.Update()
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
return collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
}
func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return false, err
}
defer release()
if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter")
}
if obj == nil {
return false, merrors.InvalidArgument("object is required", "obj")
}
result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj)
result, err := collection.ReplaceOne(ctx, query.BuildQuery(), obj)
if mongo.IsDuplicateKeyError(err) {
return false, merrors.DataConflict("duplicate_key")
}
@@ -176,21 +232,39 @@ func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.
}
func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
id := prepareForWrite(obj)
_, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
_, err = collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
return err
}
func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if id.IsZero() {
return merrors.InvalidArgument("zero id provided while patching", "id")
}
_, err := r.collection.UpdateByID(ctx, id, patch.Build())
_, err = collection.UpdateByID(ctx, id, patch.Build())
return err
}
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
result, err := collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
if err != nil {
return 0, err
}
@@ -198,10 +272,16 @@ func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, pa
}
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
filter := query.BuildQuery()
findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1})
cursor, err := r.collection.Find(ctx, filter, findOptions)
cursor, err := collection.Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
@@ -227,24 +307,45 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b
}
func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, idFilter(id))
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteOne(ctx, idFilter(id))
return err
}
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
_, err := r.collection.DeleteMany(ctx, query.BuildQuery())
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteMany(ctx, query.BuildQuery())
return err
}
func (r *MongoRepository) Name() string {
return r.collection.Name()
return r.collectionName
}
func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if cb == nil {
return nil, merrors.InvalidArgument("transaction callback is required", "callback")
}
session, err := r.collection.Database().Client().StartSession()
if transaction.ActiveMongoTransaction(ctx) {
return cb(ctx)
}
session, err := collection.Database().Client().StartSession()
if err != nil {
return nil, err
}
@@ -12,8 +12,14 @@ import (
)
func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
opts := query.BuildOptions().SetLimit(1)
cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts)
cursor, err := collection.Find(ctx, query.BuildQuery(), opts)
if err != nil {
return err
}
@@ -30,10 +36,22 @@ func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Q
}
func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) {
return r.collection.CountDocuments(ctx, query.BuildQuery())
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
return collection.CountDocuments(ctx, query.BuildQuery())
}
func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return false, err
}
defer release()
if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter")
}
@@ -43,7 +61,7 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
if result == nil {
return false, merrors.InvalidArgument("result is required", "result")
}
err := r.collection.FindOneAndUpdate(
err = collection.FindOneAndUpdate(
ctx,
query.BuildQuery(),
patch.Build(),
@@ -1,18 +1,15 @@
package transactionimp
import (
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type MongoTransactionFactory struct {
client *mongo.Client
}
type MongoTransactionFactory struct{ database source.Source }
func (mtf *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
return Create(mtf.client)
func (f *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
return &MongoTransaction{database: f.database}
}
func CreateFactory(client *mongo.Client) transaction.Factory {
return &MongoTransactionFactory{client: client}
func CreateSourceFactory(database source.Source) transaction.Factory {
return &MongoTransactionFactory{database: database}
}
@@ -3,32 +3,29 @@ package transactionimp
import (
"context"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/mongo"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type MongoTransaction struct {
client *mongo.Client
}
type MongoTransaction struct{ database source.Source }
func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
// Reuse an outer customer workflow transaction when a token store joins it.
func (t *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
if cb == nil {
return nil, merrors.InvalidArgument("transaction callback is required")
}
database, ctx, release, err := t.database.Acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if transaction.ActiveMongoTransaction(ctx) {
return cb(ctx)
}
session, err := mt.client.StartSession()
session, err := database.Client().StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
callback := func(c context.Context) (any, error) {
return cb(c)
}
return session.WithTransaction(ctx, callback)
}
func Create(client *mongo.Client) *MongoTransaction {
return &MongoTransaction{client: client}
return session.WithTransaction(ctx, cb)
}
@@ -8,6 +8,7 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
@@ -53,6 +54,11 @@ type Repository interface {
Collection() string
}
func CreateRepository(db source.Source, collection string) Repository {
return repositoryimp.NewRepository(db, collection)
}
// CreateMongoRepository adapts an externally owned fixed database.
func CreateMongoRepository(db *mongo.Database, collection string) Repository {
return repositoryimp.NewMongoRepository(db, collection)
return CreateRepository(source.Fixed(db), collection)
}
+275
View File
@@ -0,0 +1,275 @@
package db_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
dependencies "git.sendico.io/tech/sxchnge/shared/database/dependencies"
storable "git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.uber.org/zap"
)
type rotationRecord struct {
storable.Base `bson:",inline"`
Value string `bson:"value"`
}
func (*rotationRecord) Collection() string { return "rotation" }
type leaseServer struct {
admin *mongo.Database
issued atomic.Int64
revoked sync.Map
outage atomic.Bool
badPassword atomic.Bool
}
func (s *leaseServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.outage.Load() {
w.WriteHeader(503)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/auth/approle/login":
// A unique token owns each credential in this fake Vault HTTP boundary.
token := fmt.Sprintf("test-token-%d", s.issued.Load()+1)
_ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": token, "lease_duration": 60}})
case "/v1/database/creds/bff":
n := s.issued.Add(1)
user := fmt.Sprintf("lease%d", n)
err := s.admin.RunCommand(r.Context(), bson.D{{Key: "createUser", Value: user}, {Key: "pwd", Value: "test-password"}, {Key: "roles", Value: bson.A{bson.M{"role": "readWrite", "db": "rotation"}}}}).Err()
if err != nil {
w.WriteHeader(500)
return
}
password := "test-password"
if s.badPassword.Load() {
password = "wrong-password"
}
_ = json.NewEncoder(w).Encode(map[string]any{"lease_id": user, "lease_duration": 8, "data": map[string]string{"username": user, "password": password}})
case "/v1/auth/token/revoke-self":
user := "lease" + strings.TrimPrefix(r.Header.Get("X-Vault-Token"), "test-token-")
_ = s.admin.RunCommand(r.Context(), bson.D{{Key: "dropUser", Value: user}}).Err()
s.revoked.Store(user, true)
w.WriteHeader(204)
default:
w.WriteHeader(404)
}
}
func TestRotationPreservesRequestsCursorsAndTransactions(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"), mongodb.WithUsername("root"), mongodb.WithPassword("test-admin-password"))
require.NoError(t, err)
t.Cleanup(func() { _ = container.Terminate(context.Background()) })
raw, err := container.ConnectionString(ctx)
require.NoError(t, err)
uri, err := url.Parse(raw)
require.NoError(t, err)
q := uri.Query()
q.Set("directConnection", "true")
q.Set("authSource", "admin")
uri.RawQuery = q.Encode()
admin, err := mongo.Connect(options.Client().ApplyURI(uri.String()))
require.NoError(t, err)
defer admin.Disconnect(context.Background())
fake := &leaseServer{admin: admin.Database("admin")}
vault := httptest.NewServer(fake)
defer vault.Close()
directory := t.TempDir()
role := filepath.Join(directory, "role")
secret := filepath.Join(directory, "secret")
require.NoError(t, os.WriteFile(role, []byte("test-role"), 0600))
require.NoError(t, os.WriteFile(secret, []byte("test-secret"), 0600))
uri.User = nil
uri.Path = "/rotation"
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"database": "rotation", "uri": uri.String(), "vault": map[string]any{"address": vault.URL, "role": "bff", "role_id_file": role, "secret_id_file": secret}}})
require.NoError(t, err)
defer factory.CloseConnection()
deps, err := dependencies.ResolveDependencies(factory)
require.NoError(t, err)
repo := repository.CreateRepository(deps.Database, "rotation")
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "cursor-fixture"}, nil))
var failures, requests atomic.Int64
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, e := repo.CountByFilter(r.Context(), repository.Query()); e != nil {
failures.Add(1)
w.WriteHeader(503)
return
}
requests.Add(1)
w.WriteHeader(200)
}))
defer httpServer.Close()
loadCtx, stopLoad := context.WithCancel(ctx)
var load sync.WaitGroup
defer func() { stopLoad(); load.Wait() }()
for range 4 {
load.Add(1)
go func() {
defer load.Done()
for loadCtx.Err() == nil {
res, e := http.Get(httpServer.URL)
if e != nil {
failures.Add(1)
} else {
res.Body.Close()
if res.StatusCode != 200 {
failures.Add(1)
}
}
time.Sleep(5 * time.Millisecond)
}
}()
}
// A cursor and a transaction must keep generation 1 alive through publication.
first, scope, release, err := deps.Database.Acquire(ctx)
require.NoError(t, err)
firstClient := first.Client()
release()
_, _, _, err = deps.Database.Acquire(scope)
require.Error(t, err, "escaped operation scope must be rejected")
txEntered := make(chan struct{})
finishTx := make(chan struct{})
txDone := make(chan error, 1)
var entered sync.Once
go func() {
_, e := factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
if e := repo.Insert(tx, &rotationRecord{Value: "transaction-before"}, nil); e != nil {
return nil, e
}
entered.Do(func() { close(txEntered) })
select {
case <-finishTx:
case <-tx.Done():
return nil, tx.Err()
}
// Nested transaction must share the same session/generation.
return repo.WithTransaction(tx, func(nested context.Context) (any, error) {
return nil, repo.Insert(nested, &rotationRecord{Value: "transaction-after"}, nil)
})
})
txDone <- e
}()
select {
case <-txEntered:
case err := <-txDone:
t.Fatalf("transaction did not enter: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
cursorEntered := make(chan struct{})
finishCursor := make(chan struct{})
cursorDone := make(chan error, 1)
go func() {
var once sync.Once
cursorDone <- repo.FindManyByFilter(ctx, repository.Query(), func(d *mongo.Cursor) error {
once.Do(func() { close(cursorEntered) })
select {
case <-finishCursor:
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}()
select {
case <-cursorEntered:
case err := <-cursorDone:
t.Fatalf("cursor did not enter: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
require.Eventually(t, func() bool {
database, _, free, e := deps.Database.Acquire(ctx)
if e != nil {
return false
}
defer free()
return database.Client() != firstClient
}, 6*time.Second, 20*time.Millisecond)
_, revoked := fake.revoked.Load("lease1")
require.False(t, revoked, "in-flight work lost its credentials")
close(finishTx)
require.NoError(t, <-txDone)
_, revoked = fake.revoked.Load("lease1")
require.False(t, revoked, "cursor must retain old pool")
close(finishCursor)
require.NoError(t, <-cursorDone)
require.Eventually(t, func() bool { _, ok := fake.revoked.Load("lease1"); return ok }, 2*time.Second, 20*time.Millisecond)
count, err := repo.CountByFilter(ctx, repository.Filter("value", "transaction-after"))
require.NoError(t, err)
require.EqualValues(t, 1, count)
// Reject a candidate with invalid credentials while retaining the valid pool.
fake.badPassword.Store(true)
before := fake.issued.Load()
require.Eventually(t, func() bool { return fake.issued.Load() > before }, 5*time.Second, 20*time.Millisecond)
require.Eventually(t, func() bool { _, ok := fake.revoked.Load(fmt.Sprintf("lease%d", before+1)); return ok }, time.Second, 10*time.Millisecond)
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "during-bad-refresh"}, nil))
fake.badPassword.Store(false)
fake.outage.Store(true)
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "during-vault-outage"}, nil))
time.Sleep(300 * time.Millisecond)
fake.outage.Store(false)
require.Eventually(t, func() bool { return fake.issued.Load() > before+1 }, 3*time.Second, 20*time.Millisecond)
stopLoad()
load.Wait()
require.Greater(t, requests.Load(), int64(100))
require.Zero(t, failures.Load(), "HTTP requests failed during rotation")
// If Vault stays unavailable beyond the lease, fail closed without restarting.
fake.outage.Store(true)
require.Eventually(t, func() bool { _, e := repo.CountByFilter(ctx, repository.Query()); return e != nil }, 10*time.Second, 50*time.Millisecond)
fake.outage.Store(false)
require.Eventually(t, func() bool { _, e := repo.CountByFilter(ctx, repository.Query()); return e == nil }, 6*time.Second, 20*time.Millisecond)
// Transaction errors still roll back all writes after generation changes.
_, err = repo.WithTransaction(ctx, func(tx context.Context) (any, error) {
if e := repo.Insert(tx, &rotationRecord{Value: "rollback"}, nil); e != nil {
return nil, e
}
return nil, merrors.Internal("injected rollback")
})
require.Error(t, err)
count, err = repo.CountByFilter(ctx, repository.Filter("value", "rollback"))
require.NoError(t, err)
require.Zero(t, count)
held, heldContext, releaseHeld, err := deps.Database.Acquire(ctx)
require.NoError(t, err)
manager := deps.Database.(interface{ Close(context.Context) error })
short, stop := context.WithTimeout(ctx, 30*time.Millisecond)
require.ErrorIs(t, manager.Close(short), context.DeadlineExceeded)
stop()
_, _, _, err = deps.Database.Acquire(ctx)
require.Error(t, err)
require.NoError(t, held.Client().Ping(heldContext, nil), "shutdown must drain, not kill held work")
releaseHeld()
closed, stop := context.WithTimeout(ctx, 5*time.Second)
defer stop()
require.NoError(t, manager.Close(closed))
}
+253
View File
@@ -0,0 +1,253 @@
package source
import (
"context"
"sync"
"sync/atomic"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"go.uber.org/zap"
)
// Lease owns a credential and its revocation capability. Neither may be logged.
type Lease struct {
URI string
ExpiresAt time.Time
Revoke func(context.Context) error
}
type Issuer interface {
Issue(context.Context) (Lease, error)
}
type generation struct {
client *mongo.Client
lease Lease
refs int
retired bool
drained chan struct{}
}
type pin struct {
manager *Manager
generation *generation
active atomic.Bool
}
type pinKey struct{}
// Manager publishes a verified replacement before retiring the previous pool.
// Every cursor, operation and transaction retains its originating generation.
type Manager struct {
mu sync.Mutex
current *generation
closed bool
issuer Issuer
database string
logger mlogger.Logger
cancel context.CancelFunc
workerDone chan struct{}
draining sync.WaitGroup
}
func New(ctx context.Context, logger mlogger.Logger, database string, issuer Issuer) (*Manager, error) {
if database == "" || issuer == nil {
return nil, merrors.InvalidArgument("database and credential issuer are required")
}
life, cancel := context.WithCancel(context.Background())
m := &Manager{issuer: issuer, database: database, logger: logger, cancel: cancel, workerDone: make(chan struct{})}
g, err := m.prepare(ctx)
if err != nil {
cancel()
return nil, err
}
m.current = g
go m.run(life)
return m, nil
}
func (m *Manager) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
if err := ctx.Err(); err != nil {
return nil, ctx, nil, err
}
if p, ok := ctx.Value(pinKey{}).(*pin); ok && p.manager == m {
if !p.active.Load() {
return nil, ctx, nil, merrors.FailedPrecondition("database operation scope has ended")
}
return p.generation.client.Database(m.database), ctx, func() {}, nil
}
m.mu.Lock()
g := m.current
if m.closed || g == nil || !time.Now().Before(g.lease.ExpiresAt) {
m.mu.Unlock()
return nil, ctx, nil, merrors.ServiceUnavailable("no valid database credential is available")
}
if session := mongo.SessionFromContext(ctx); session != nil && session.Client() != g.client {
m.mu.Unlock()
return nil, ctx, nil, merrors.FailedPrecondition("transaction has no matching database operation scope")
}
g.refs++
m.mu.Unlock()
p := &pin{manager: m, generation: g}
p.active.Store(true)
bounded, cancel := context.WithDeadline(ctx, g.lease.ExpiresAt)
scoped := context.WithValue(bounded, pinKey{}, p)
release := func() {
if !p.active.CompareAndSwap(true, false) {
return
}
cancel()
m.mu.Lock()
g.refs--
if g.retired && g.refs == 0 {
close(g.drained)
}
m.mu.Unlock()
}
return g.client.Database(m.database), scoped, release, nil
}
func (m *Manager) prepare(ctx context.Context) (*generation, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
lease, err := m.issuer.Issue(ctx)
if err != nil {
return nil, err
}
var client *mongo.Client
success := false
defer func() {
if !success {
if client != nil {
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = client.Disconnect(cleanup)
cancel()
}
m.draining.Add(1)
go func() { defer m.draining.Done(); m.revoke(lease) }()
}
}()
if lease.Revoke == nil || time.Until(lease.ExpiresAt) < 2*time.Second {
return nil, merrors.FailedPrecondition("database credential lifetime is too short")
}
client, err = mongo.Connect(options.Client().ApplyURI(lease.URI).SetServerSelectionTimeout(5 * time.Second))
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create replacement database pool")
}
if err = client.Ping(ctx, readpref.Primary()); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "validate replacement database pool")
}
if time.Until(lease.ExpiresAt) < time.Second {
return nil, merrors.FailedPrecondition("replacement database credential expired during validation")
}
success = true
return &generation{client: client, lease: lease, drained: make(chan struct{})}, nil
}
func (m *Manager) retireLocked(g *generation) {
g.retired = true
if g.refs == 0 {
close(g.drained)
}
m.draining.Add(1)
go func() {
defer m.draining.Done()
<-g.drained
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := g.client.Disconnect(ctx); err != nil {
m.logger.Warn("Retired database pool close failed")
}
m.revoke(g.lease)
m.logger.Info("Retired database pool drained")
}()
}
func (m *Manager) revoke(lease Lease) {
if lease.Revoke == nil {
return
}
// Expiration is the backstop when Vault is unavailable during bounded cleanup.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := lease.Revoke(ctx); err != nil {
m.logger.Warn("Database credential cleanup failed; Vault expiry will revoke it")
}
}
func (m *Manager) run(ctx context.Context) {
defer close(m.workerDone)
m.mu.Lock()
wait := time.Until(m.current.lease.ExpiresAt) / 2
m.mu.Unlock()
backoff := 250 * time.Millisecond
failureReported, expiryReported := false, false
for {
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
next, err := m.prepare(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
m.mu.Lock()
remaining := time.Until(m.current.lease.ExpiresAt)
m.mu.Unlock()
if !failureReported {
m.logger.Warn("Database credential refresh failed; retaining current pool", zap.Duration("valid_for", max(remaining, 0)))
failureReported = true
}
if remaining <= 0 && !expiryReported {
m.logger.Error("Database credentials expired; rejecting new operations until refresh succeeds")
expiryReported = true
}
wait = backoff
if remaining > 0 {
wait = max(100*time.Millisecond, min(wait, remaining/4))
}
backoff = min(5*time.Second, backoff*2)
continue
}
m.mu.Lock()
if m.closed {
m.retireLocked(next)
m.mu.Unlock()
return
}
old := m.current
m.current = next
m.retireLocked(old)
m.mu.Unlock()
failureReported, expiryReported = false, false
backoff = 250 * time.Millisecond
m.logger.Info("Database pool replaced without restarting service")
wait = time.Until(next.lease.ExpiresAt) / 2
}
}
func (m *Manager) Close(ctx context.Context) error {
m.mu.Lock()
if !m.closed {
m.closed = true
m.cancel()
m.retireLocked(m.current)
}
m.mu.Unlock()
done := make(chan struct{})
go func() { <-m.workerDone; m.draining.Wait(); close(done) }()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
+30
View File
@@ -0,0 +1,30 @@
// Package source owns Mongo connection generations and operation lifetimes.
package source
import (
"context"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Source pins one database/client until release. The returned context must be
// passed to nested operations so transactions never cross client generations.
type Source interface {
Acquire(context.Context) (*mongo.Database, context.Context, func(), error)
}
type fixed struct{ database *mongo.Database }
// Fixed adapts an externally owned database, e.g. a test fixture. It never closes it.
func Fixed(database *mongo.Database) Source { return &fixed{database: database} }
func (s *fixed) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
if s.database == nil {
return nil, ctx, nil, merrors.FailedPrecondition("database is not configured")
}
if session := mongo.SessionFromContext(ctx); session != nil && session.Client() != s.database.Client() {
return nil, ctx, nil, merrors.FailedPrecondition("transaction belongs to another database client")
}
return s.database, ctx, func() {}, nil
}
+3 -3
View File
@@ -6,12 +6,12 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type CascadeDeleterT = func(ctx context.Context, objectRef bson.ObjectID) error
@@ -88,11 +88,11 @@ func (db *DBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID)
func Create[T storable.Storable](
logger mlogger.Logger,
collection mservice.Type,
db *mongo.Database,
db source.Source,
) *DBImp[T] {
res := &DBImp[T]{
Logger: logger.Named(collection),
Repository: repository.CreateMongoRepository(db, collection),
Repository: repository.CreateRepository(db, collection),
}
res.cdeleter = res.deleteCascadeStub
return res
+140
View File
@@ -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())
})
}
}