All Vault store backends implement a single composite interface: store.Store. This guide breaks down the interface, explains each sub-store, and walks through building a custom implementation from scratch.
The composite store interface01
The store.Store interface in github.com/xraph/vault/store embeds six subsystem interfaces plus three lifecycle methods:
package store
type Store interface {
secret.Store
flag.Store
config.Store
override.Store
rotation.Store
audit.Store
Migrate(ctx context.Context) error
Ping(ctx context.Context) error
Close() error
}Sub-store interfaces02
secret.Store (6 methods)
package secret
type Store interface {
GetSecret(ctx context.Context, key, appID string) (*Secret, error)
SetSecret(ctx context.Context, s *Secret) error
DeleteSecret(ctx context.Context, key, appID string) error
ListSecrets(ctx context.Context, appID string, opts ListOpts) ([]*Meta, error)
GetSecretVersion(ctx context.Context, key, appID string, version int64) (*Secret, error)
ListSecretVersions(ctx context.Context, key, appID string) ([]*Version, error)
}Key behaviours:
SetSecretmust auto-increment theVersionfield when the key already exists.SetSecretmust create aVersionrecord alongside the secret update.ListSecretsreturns[]*Meta(never includes the secret value).DeleteSecretremoves the secret and all its versions.Return
vault.ErrSecretNotFoundwhen a secret does not exist.
flag.Store (10 methods)
package flag
type Store interface {
DefineFlag(ctx context.Context, f *Definition) error
GetFlagDefinition(ctx context.Context, key, appID string) (*Definition, error)
ListFlagDefinitions(ctx context.Context, appID string, opts ListOpts) ([]*Definition, error)
DeleteFlagDefinition(ctx context.Context, key, appID string) error
SetFlagRules(ctx context.Context, key, appID string, rules []*Rule) error
GetFlagRules(ctx context.Context, key, appID string) ([]*Rule, error)
SetFlagTenantOverride(ctx context.Context, key, appID, tenantID string, value any) error
GetFlagTenantOverride(ctx context.Context, key, appID, tenantID string) (any, error)
DeleteFlagTenantOverride(ctx context.Context, key, appID, tenantID string) error
ListFlagTenantOverrides(ctx context.Context, key, appID string) ([]*TenantOverride, error)
}Key behaviours:
SetFlagRulesreplaces all rules for a flag (delete-and-insert pattern).GetFlagRulesreturns rules sorted byPriorityascending (lower = higher priority).DeleteFlagDefinitionmust also delete associated rules and tenant overrides.Return
vault.ErrFlagNotFoundwhen a flag does not exist.Return
vault.ErrOverrideNotFoundwhen a tenant override does not exist.
config.Store (6 methods)
package config
type Store interface {
GetConfig(ctx context.Context, key, appID string) (*Entry, error)
SetConfig(ctx context.Context, e *Entry) error
DeleteConfig(ctx context.Context, key, appID string) error
ListConfig(ctx context.Context, appID string, opts ListOpts) ([]*Entry, error)
GetConfigVersion(ctx context.Context, key, appID string, version int64) (*Entry, error)
ListConfigVersions(ctx context.Context, key, appID string) ([]*EntryVersion, error)
}Key behaviours:
SetConfigmust auto-increment theVersionfield when the key already exists.SetConfigmust create anEntryVersionrecord alongside the entry update.DeleteConfigremoves the entry and all its versions.Return
vault.ErrConfigNotFoundwhen a config entry does not exist.
override.Store (5 methods)
package override
type Store interface {
GetOverride(ctx context.Context, key, appID, tenantID string) (*Override, error)
SetOverride(ctx context.Context, o *Override) error
DeleteOverride(ctx context.Context, key, appID, tenantID string) error
ListOverridesByTenant(ctx context.Context, appID, tenantID string) ([]*Override, error)
ListOverridesByKey(ctx context.Context, key, appID string) ([]*Override, error)
}Key behaviours:
Overrides are scoped by the triple
(key, appID, tenantID).ListOverridesByTenantreturns all overrides for a specific tenant across all keys.ListOverridesByKeyreturns all tenant overrides for a specific config key.Return
vault.ErrOverrideNotFoundwhen an override does not exist.
rotation.Store (6 methods)
package rotation
type Store interface {
SaveRotationPolicy(ctx context.Context, p *Policy) error
GetRotationPolicy(ctx context.Context, key, appID string) (*Policy, error)
ListRotationPolicies(ctx context.Context, appID string) ([]*Policy, error)
DeleteRotationPolicy(ctx context.Context, key, appID string) error
RecordRotation(ctx context.Context, r *Record) error
ListRotationRecords(ctx context.Context, key, appID string, opts ListOpts) ([]*Record, error)
}Key behaviours:
SaveRotationPolicyis an upsert: creates or updates the policy for(secretKey, appID).ListRotationRecordsreturns records sorted byRotatedAtdescending (newest first).Return
vault.ErrRotationNotFoundwhen a rotation policy does not exist.
audit.Store (3 methods)
package audit
type Store interface {
RecordAudit(ctx context.Context, e *Entry) error
ListAudit(ctx context.Context, appID string, opts ListOpts) ([]*Entry, error)
ListAuditByKey(ctx context.Context, key, appID string, opts ListOpts) ([]*Entry, error)
}Key behaviours:
The audit store is append-only. There is no
DeleteAuditmethod.ListAuditreturns entries sorted byCreatedAtdescending (newest first).ListAuditByKeyfilters by a specific key within an app.Both list methods support
OffsetandLimitfor pagination.
Total method count03
| Sub-store | Methods |
secret.Store | 6 |
flag.Store | 10 |
config.Store | 6 |
override.Store | 5 |
rotation.Store | 6 |
audit.Store | 3 |
Lifecycle (Migrate, Ping, Close) | 3 |
| Total | 39 |
Compile-time interface check04
Always add a compile-time assertion at the top of your store file. This ensures your implementation satisfies the interface at build time rather than at runtime:
var _ store.Store = (*MyStore)(nil)Or verify each sub-interface individually:
var (
_ secret.Store = (*MyStore)(nil)
_ flag.Store = (*MyStore)(nil)
_ config.Store = (*MyStore)(nil)
_ override.Store = (*MyStore)(nil)
_ rotation.Store = (*MyStore)(nil)
_ audit.Store = (*MyStore)(nil)
)Step-by-step implementation guide05
Step 1: Define the struct
package redisstore
import (
"context"
"log/slog"
"github.com/redis/go-redis/v9"
"github.com/xraph/vault/audit"
"github.com/xraph/vault/config"
"github.com/xraph/vault/flag"
"github.com/xraph/vault/override"
"github.com/xraph/vault/rotation"
"github.com/xraph/vault/secret"
"github.com/xraph/vault/store"
)
// Compile-time interface check.
var _ store.Store = (*Store)(nil)
type Store struct {
client *redis.Client
logger *slog.Logger
}
func New(client *redis.Client) *Store {
return &Store{
client: client,
logger: slog.Default(),
}
}Step 2: Implement lifecycle methods
func (s *Store) Migrate(_ context.Context) error {
// Redis is schema-less -- nothing to migrate.
return nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.client.Ping(ctx).Err()
}
func (s *Store) Close() error {
return s.client.Close()
}Step 3: Implement secret.Store
Design a key scheme for Redis. A common pattern:
| Entity | Redis key pattern | Value |
| Secret | vault:secret:{appID}:{key} | JSON-serialized secret.Secret |
| Secret versions | vault:secret_versions:{appID}:{key} | Redis list of JSON secret.Version |
import (
"encoding/json"
"github.com/xraph/vault"
"github.com/xraph/vault/id"
"github.com/xraph/vault/secret"
)
func secretKey(key, appID string) string {
return "vault:secret:" + appID + ":" + key
}
func (s *Store) GetSecret(ctx context.Context, key, appID string) (*secret.Secret, error) {
data, err := s.client.Get(ctx, secretKey(key, appID)).Bytes()
if err != nil {
if err == redis.Nil {
return nil, vault.ErrSecretNotFound
}
return nil, err
}
var sec secret.Secret
if err := json.Unmarshal(data, &sec); err != nil {
return nil, err
}
return &sec, nil
}
func (s *Store) SetSecret(ctx context.Context, sec *secret.Secret) error {
k := secretKey(sec.Key, sec.AppID)
// Auto-version: check if secret exists.
existing, err := s.GetSecret(ctx, sec.Key, sec.AppID)
if err == nil {
sec.Version = existing.Version + 1
} else {
sec.Version = 1
}
// Store the secret.
data, _ := json.Marshal(sec)
if err := s.client.Set(ctx, k, data, 0).Err(); err != nil {
return err
}
// Store the version record.
ver := &secret.Version{
ID: id.NewVersionID(),
SecretKey: sec.Key,
AppID: sec.AppID,
Version: sec.Version,
EncryptedValue: sec.EncryptedValue,
}
verData, _ := json.Marshal(ver)
return s.client.RPush(ctx, k+":versions", verData).Err()
}
// ... implement DeleteSecret, ListSecrets, GetSecretVersion, ListSecretVersionsStep 4: Implement remaining sub-stores
Follow the same pattern for each sub-store:
Define a Redis key scheme for the entity.
Use JSON serialization for complex types.
Return the appropriate sentinel error (e.g.
vault.ErrFlagNotFound) when an entity is not found.Implement pagination using
OffsetandLimitfrom theListOptsstructs.Ensure atomicity where needed (e.g. version auto-increment).
Step 5: Test with the store test suite
Write tests that exercise the full store.Store interface. A common approach is to define a shared test function:
func TestRedisStore(t *testing.T) {
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store := redisstore.New(client)
ctx := context.Background()
_ = store.Migrate(ctx)
// Test secret CRUD.
t.Run("secrets", func(t *testing.T) {
sec := &secret.Secret{
Key: "test-key",
AppID: "testapp",
EncryptedValue: []byte("encrypted"),
}
err := store.SetSecret(ctx, sec)
require.NoError(t, err)
require.Equal(t, int64(1), sec.Version)
got, err := store.GetSecret(ctx, "test-key", "testapp")
require.NoError(t, err)
require.Equal(t, "test-key", got.Key)
})
// ... test all other sub-stores
}Error handling06
Your custom store must return Vault's sentinel errors for "not found" cases:
| Sentinel error | When to return |
vault.ErrSecretNotFound | GetSecret, DeleteSecret, GetSecretVersion when key does not exist |
vault.ErrFlagNotFound | GetFlagDefinition, DeleteFlagDefinition, SetFlagRules, GetFlagRules when flag does not exist |
vault.ErrConfigNotFound | GetConfig, DeleteConfig, GetConfigVersion when config entry does not exist |
vault.ErrOverrideNotFound | GetOverride, DeleteOverride, GetFlagTenantOverride, DeleteFlagTenantOverride when override does not exist |
vault.ErrRotationNotFound | GetRotationPolicy, DeleteRotationPolicy when policy does not exist |
The flag engine, override resolver, and service layers depend on these exact errors for control flow. Using errors.Is checks will fail if you return different errors.
Tips07
Deep-copy returned values -- The memory store deep-copies all returned data to prevent callers from mutating internal state. Your store should do the same if you cache data in-process.
Pagination -- All list methods accept
OffsetandLimitvia their respectiveListOpts. Apply these consistently.Sorting --
ListSecretssorts by key ascending.ListAuditandListRotationRecordssort by timestamp descending. Check the memory store implementation for the expected sort order of each method.Idempotent writes --
SetSecret,SetConfig,DefineFlag,SetOverride, andSaveRotationPolicyshould all be upserts (create or update).