Files
vw-api/internal/vaultwarden/client.go
T
Jethro Young 76b82d4dba Filtering of synchronized secrets on collection, organization, and/or folder (#12)
* Filtering feature, minor logic changes
+: Add IP validation (to properly log IP even when trusted proxy is missing)
+: Added synchronization of decrypted organization, collection, and folder names (for filtering purposes)
+: Changed sync behavior to prevent collapsing multiple logins with the same name
+: Added possibility to filter logins based on organization, collection, and folder name/id in GetSecret
+: Added support for space character in secret name validation (it is also properly URL-decoded)

* Update README

* Fixes related to issues reported by CodeRabbit:
+: Changed british use of organisations in README
+: Reduced line count in parseSecretFilters by including helper function for filter dimensions resolution
+: Added comment clarifying absence of existence checking on dimension IDs as opposed to names
+: Removed efensive comma split logic on IPs (redundant due to IPValidation setting on Fiber)
+: Changed Query validator to align with name validator (mainly removing hard-coded values)
+: Added comments to decryptVaultLabel for clarity
+: Change empty SyncNameMaps instance with emptySyncNameMaps helper for consistency
+: Add lexical sort for lookupIDByName for deterministic result in case of multiple matches

* Bumped logging to warn and added entity metadata in log

* Unit tests:
+: Added unit tests for every newly introduced or changed function
+: Rewrote NewClient constructor to accept optional preloaded data (so it can be used in test scenarios)

* Changes requested by CodeRabbit
2026-05-20 23:26:29 +02:00

235 lines
5.5 KiB
Go

// Package vaultwarden provides a production-ready client for retrieving secrets
// from Vaultwarden using native Go HTTP and crypto (no CLI dependency).
package vaultwarden
import (
"fmt"
"maps"
"strings"
"sync"
"time"
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
)
// Client manages vault access, caching, and background sync.
type Client struct {
api *APIClient
cacheTTL time.Duration
syncEvery time.Duration
mu sync.RWMutex
items map[string]DecryptedItem // keyed by cipher id
// nameMaps from the last successful sync (for resolving filter names to UUIDs).
nameMaps SyncNameMaps
stopSync chan struct{}
}
// ClientOption configures NewClient.
type ClientOption func(*Client)
// WithState preloads decrypted items and name maps (e.g. unit tests with api set to nil).
func WithState(items map[string]DecryptedItem, nameMaps SyncNameMaps) ClientOption {
return func(c *Client) {
if items != nil {
c.items = items
}
c.nameMaps = nameMaps
}
}
// NewClient creates a vault client. Pass WithState to preload cache data without calling Initialize.
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration, opts ...ClientOption) *Client {
c := &Client{
api: api,
cacheTTL: cacheTTL,
syncEvery: syncInterval,
items: make(map[string]DecryptedItem),
nameMaps: emptySyncNameMaps(),
stopSync: make(chan struct{}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Initialize authenticates and performs the initial vault sync.
func (c *Client) Initialize() error {
if err := c.api.Authenticate(); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
if err := c.syncVault(); err != nil {
return fmt.Errorf("initial sync: %w", err)
}
// Start background sync.
go c.backgroundSync()
return nil
}
// SecretFilter limits lookup by vault placement. Empty fields are ignored (no constraint).
// Use at most one of organization id vs name, etc., enforced at the HTTP layer.
type SecretFilter struct {
OrganizationID string
CollectionID string
FolderID string
}
func matchesSecretFilter(item DecryptedItem, f SecretFilter) bool {
if f.OrganizationID != "" && !strings.EqualFold(item.OrganizationID, f.OrganizationID) {
return false
}
if f.CollectionID != "" {
found := false
for _, id := range item.CollectionIDs {
if strings.EqualFold(id, f.CollectionID) {
found = true
break
}
}
if !found {
return false
}
}
if f.FolderID != "" && !strings.EqualFold(item.FolderID, f.FolderID) {
return false
}
return true
}
// GetSecret retrieves a decrypted secret by name.
// It searches by exact name (case-insensitive), then falls back to partial match.
func (c *Client) GetSecret(name string, filter SecretFilter) (string, error) {
if name == "" {
return "", fmt.Errorf("secret name cannot be empty")
}
c.mu.RLock()
defer c.mu.RUnlock()
key := strings.ToLower(name)
candidates := make([]DecryptedItem, 0, len(c.items))
for _, item := range c.items {
if matchesSecretFilter(item, filter) {
candidates = append(candidates, item)
}
}
// Case 1: Exact match.
for _, item := range candidates {
if strings.EqualFold(item.Name, name) {
return extractSecret(item), nil
}
}
// Case 2: Partial match
for _, item := range candidates {
if strings.Contains(strings.ToLower(item.Name), key) {
logger.Debug.Printf("Partial match found for secret lookup")
return extractSecret(item), nil
}
}
return "", fmt.Errorf("secret not found")
}
// ClearCache triggers a fresh vault sync.
func (c *Client) ClearCache() {
if err := c.syncVault(); err != nil {
logger.Error.Printf("Cache refresh sync failed: %v", err)
}
}
// Stop stops the background sync goroutine.
func (c *Client) Stop() {
close(c.stopSync)
}
// NameMaps returns a copy of decrypted organization, folder, and collection names
// from the last successful vault sync.
func (c *Client) NameMaps() SyncNameMaps {
c.mu.RLock()
defer c.mu.RUnlock()
return SyncNameMaps{
Organizations: maps.Clone(c.nameMaps.Organizations),
Folders: maps.Clone(c.nameMaps.Folders),
Collections: maps.Clone(c.nameMaps.Collections),
}
}
// syncVault fetches and decrypts all items from the vault.
func (c *Client) syncVault() error {
items, nameMaps, err := c.api.Sync()
if err != nil {
return err
}
newItems := make(map[string]DecryptedItem, len(items))
for _, item := range items {
if item.ID == "" {
continue
}
newItems[item.ID] = item
}
c.mu.Lock()
c.items = newItems
c.nameMaps = nameMaps
c.mu.Unlock()
return nil
}
// backgroundSync periodically syncs the vault to pick up changes.
func (c *Client) backgroundSync() {
ticker := time.NewTicker(c.syncEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.syncVault(); err != nil {
logger.Warn.Printf("Background sync failed: %v", err)
} else {
logger.Debug.Println("Background vault sync completed")
}
case <-c.stopSync:
logger.Info.Println("Background sync stopped")
return
}
}
}
// extractSecret extracts the most relevant secret value from a decrypted item.
// Priority: password > field named "value"/"secret"/"api_key" > notes > first field.
func extractSecret(item DecryptedItem) string {
if item.Password != "" {
return item.Password
}
// Check custom fields by priority.
for _, name := range []string{"value", "secret", "api_key", "apikey", "token"} {
if v, ok := item.Fields[name]; ok && v != "" {
return v
}
}
if item.Notes != "" {
return item.Notes
}
// Return first non-empty field value.
for _, v := range item.Fields {
if v != "" {
return v
}
}
return ""
}