client.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package dvls
  14. import (
  15. "context"
  16. "errors"
  17. "fmt"
  18. "strings"
  19. "sync"
  20. "github.com/Devolutions/go-dvls"
  21. "github.com/google/uuid"
  22. corev1 "k8s.io/api/core/v1"
  23. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  24. )
  25. const (
  26. errFailedToGetEntry = "failed to get entry: %w"
  27. errVaultNotFound = "vault %q was not found or has been deleted: %w"
  28. )
  29. var errNotImplemented = errors.New("not implemented")
  30. var _ esv1.SecretsClient = &Client{}
  31. // Client implements the SecretsClient interface for DVLS.
  32. // The nameCache maps entry name/path keys to resolved UUIDs, avoiding
  33. // repeated GetEntries calls during a single reconciliation. The cache is
  34. // not persisted: each reconciliation creates a new Client via NewClient,
  35. // so stale entries (e.g. deleted or renamed) are naturally discarded.
  36. type Client struct {
  37. cred credentialClient
  38. vaultID string
  39. mu sync.RWMutex
  40. nameCache map[string]string
  41. }
  42. type credentialClient interface {
  43. GetByID(ctx context.Context, vaultID, entryID string) (dvls.Entry, error)
  44. GetEntries(ctx context.Context, vaultID string, opts dvls.GetEntriesOptions) ([]dvls.Entry, error)
  45. Update(ctx context.Context, entry dvls.Entry) (dvls.Entry, error)
  46. DeleteByID(ctx context.Context, vaultID, entryID string) error
  47. }
  48. type vaultGetter interface {
  49. GetByName(ctx context.Context, name string) (dvls.Vault, error)
  50. }
  51. type realCredentialClient struct {
  52. cred *dvls.EntryCredentialService
  53. }
  54. func (r *realCredentialClient) GetByID(ctx context.Context, vaultID, entryID string) (dvls.Entry, error) {
  55. return r.cred.GetByIdWithContext(ctx, vaultID, entryID)
  56. }
  57. func (r *realCredentialClient) GetEntries(ctx context.Context, vaultID string, opts dvls.GetEntriesOptions) ([]dvls.Entry, error) {
  58. return r.cred.GetEntriesWithContext(ctx, vaultID, opts)
  59. }
  60. func (r *realCredentialClient) Update(ctx context.Context, entry dvls.Entry) (dvls.Entry, error) {
  61. return r.cred.UpdateWithContext(ctx, entry)
  62. }
  63. func (r *realCredentialClient) DeleteByID(ctx context.Context, vaultID, entryID string) error {
  64. return r.cred.DeleteByIdWithContext(ctx, vaultID, entryID)
  65. }
  66. // NewClient creates a new DVLS secrets client.
  67. func NewClient(cred credentialClient, vaultID string) *Client {
  68. return &Client{cred: cred, vaultID: vaultID, nameCache: make(map[string]string)}
  69. }
  70. // GetSecret retrieves a secret from DVLS.
  71. func (c *Client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
  72. vaultID, entryID, err := c.resolveRef(ctx, ref.Key)
  73. if isNotFoundError(err) {
  74. return nil, esv1.NoSecretErr
  75. }
  76. if err != nil {
  77. return nil, err
  78. }
  79. entry, err := c.cred.GetByID(ctx, vaultID, entryID)
  80. if isVaultNotFoundError(err) {
  81. return nil, fmt.Errorf(errVaultNotFound, vaultID, err)
  82. }
  83. if isNotFoundError(err) {
  84. return nil, esv1.NoSecretErr
  85. }
  86. if err != nil {
  87. return nil, fmt.Errorf(errFailedToGetEntry, err)
  88. }
  89. secretMap, err := entryToSecretMap(entry)
  90. if err != nil {
  91. return nil, err
  92. }
  93. // Default to "password" when no property specified (consistent with 1Password provider).
  94. property := ref.Property
  95. if property == "" {
  96. property = "password"
  97. }
  98. value, ok := secretMap[property]
  99. if !ok {
  100. return nil, fmt.Errorf("property %q not found in entry", property)
  101. }
  102. return value, nil
  103. }
  104. // GetSecretMap retrieves all fields from a DVLS entry.
  105. func (c *Client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
  106. vaultID, entryID, err := c.resolveRef(ctx, ref.Key)
  107. if isNotFoundError(err) {
  108. return nil, esv1.NoSecretErr
  109. }
  110. if err != nil {
  111. return nil, err
  112. }
  113. entry, err := c.cred.GetByID(ctx, vaultID, entryID)
  114. if isVaultNotFoundError(err) {
  115. return nil, fmt.Errorf(errVaultNotFound, vaultID, err)
  116. }
  117. if isNotFoundError(err) {
  118. return nil, esv1.NoSecretErr
  119. }
  120. if err != nil {
  121. return nil, fmt.Errorf(errFailedToGetEntry, err)
  122. }
  123. return entryToSecretMap(entry)
  124. }
  125. // GetAllSecrets is not implemented for DVLS.
  126. func (c *Client) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
  127. return nil, errNotImplemented
  128. }
  129. // PushSecret updates an existing entry's password field.
  130. func (c *Client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
  131. if secret == nil {
  132. return errors.New("secret is required for DVLS push")
  133. }
  134. vaultID, entryID, err := c.resolveRef(ctx, data.GetRemoteKey())
  135. if isVaultNotFoundError(err) {
  136. return fmt.Errorf(errVaultNotFound, c.vaultID, err)
  137. }
  138. if isNotFoundError(err) {
  139. return fmt.Errorf("entry %q not found: entry must exist before pushing secrets", data.GetRemoteKey())
  140. }
  141. if err != nil {
  142. return err
  143. }
  144. value, err := extractPushValue(secret, data)
  145. if err != nil {
  146. return err
  147. }
  148. existingEntry, err := c.cred.GetByID(ctx, vaultID, entryID)
  149. if isVaultNotFoundError(err) {
  150. return fmt.Errorf(errVaultNotFound, vaultID, err)
  151. }
  152. if isNotFoundError(err) {
  153. return fmt.Errorf("entry %s not found in vault %s: entry must exist before pushing secrets", entryID, vaultID)
  154. }
  155. if err != nil {
  156. return fmt.Errorf(errFailedToGetEntry, err)
  157. }
  158. // SetCredentialSecret only updates the password/secret field.
  159. if err := existingEntry.SetCredentialSecret(string(value)); err != nil {
  160. return err
  161. }
  162. _, err = c.cred.Update(ctx, existingEntry)
  163. if err != nil {
  164. return fmt.Errorf("failed to update entry: %w", err)
  165. }
  166. return nil
  167. }
  168. // DeleteSecret deletes a secret from DVLS.
  169. func (c *Client) DeleteSecret(ctx context.Context, ref esv1.PushSecretRemoteRef) error {
  170. vaultID, entryID, err := c.resolveRef(ctx, ref.GetRemoteKey())
  171. if isNotFoundError(err) {
  172. return nil
  173. }
  174. if err != nil {
  175. return err
  176. }
  177. if err := c.cred.DeleteByID(ctx, vaultID, entryID); err != nil {
  178. if isNotFoundError(err) {
  179. return nil
  180. }
  181. return fmt.Errorf("failed to delete entry %q from vault %q: %w", entryID, vaultID, err)
  182. }
  183. return nil
  184. }
  185. // SecretExists checks if a secret exists in DVLS.
  186. func (c *Client) SecretExists(ctx context.Context, ref esv1.PushSecretRemoteRef) (bool, error) {
  187. vaultID, entryID, err := c.resolveRef(ctx, ref.GetRemoteKey())
  188. if isNotFoundError(err) {
  189. return false, nil
  190. }
  191. if err != nil {
  192. return false, err
  193. }
  194. _, err = c.cred.GetByID(ctx, vaultID, entryID)
  195. if isNotFoundError(err) {
  196. return false, nil
  197. }
  198. if err != nil {
  199. return false, err
  200. }
  201. return true, nil
  202. }
  203. // Validate checks if the client is properly configured.
  204. func (c *Client) Validate() (esv1.ValidationResult, error) {
  205. if c.cred == nil {
  206. return esv1.ValidationResultError, errors.New("DVLS client is not initialized")
  207. }
  208. return esv1.ValidationResultReady, nil
  209. }
  210. // Close is a no-op for the DVLS client.
  211. func (c *Client) Close(_ context.Context) error {
  212. return nil
  213. }
  214. // resolveRef resolves a key to a vault ID and entry ID.
  215. // When c.vaultID is set, the key is treated as an entry reference.
  216. // When c.vaultID is empty, the key is parsed as the legacy "<vault-uuid>/<entry-uuid>" format.
  217. func (c *Client) resolveRef(ctx context.Context, key string) (vaultID, entryID string, err error) {
  218. if c.vaultID == "" {
  219. return parseLegacyRef(key)
  220. }
  221. entryID, err = c.resolveEntryRef(ctx, key)
  222. return c.vaultID, entryID, err
  223. }
  224. // resolveEntryRef resolves an entry reference to a UUID.
  225. // The key can be:
  226. // - A UUID: used directly.
  227. // - A name: looked up via GetEntries.
  228. // - A path/name: "folder/subfolder/entry-name" — path is used to filter.
  229. func (c *Client) resolveEntryRef(ctx context.Context, key string) (entryID string, err error) {
  230. key = strings.TrimSpace(key)
  231. if key == "" {
  232. return "", errors.New("entry reference cannot be empty")
  233. }
  234. // UUID passes through directly.
  235. if isUUID(key) {
  236. return key, nil
  237. }
  238. // Return cached result if available.
  239. c.mu.RLock()
  240. id, ok := c.nameCache[key]
  241. c.mu.RUnlock()
  242. if ok {
  243. return id, nil
  244. }
  245. // Split into optional path + entry name.
  246. entryName, entryPath := parseEntryRef(key)
  247. if entryName == "" {
  248. return "", errors.New("entry name cannot be empty")
  249. }
  250. opts := dvls.GetEntriesOptions{Name: &entryName}
  251. if entryPath != "" {
  252. opts.Path = &entryPath
  253. }
  254. entries, err := c.cred.GetEntries(ctx, c.vaultID, opts)
  255. if isVaultNotFoundError(err) {
  256. return "", fmt.Errorf(errVaultNotFound, c.vaultID, err)
  257. }
  258. if err != nil {
  259. return "", fmt.Errorf("failed to resolve entry %q: %w", key, err)
  260. }
  261. switch len(entries) {
  262. case 0:
  263. return "", fmt.Errorf("entry %q not found in vault: %w", key, dvls.ErrEntryNotFound)
  264. case 1:
  265. c.mu.Lock()
  266. c.nameCache[key] = entries[0].Id
  267. c.mu.Unlock()
  268. return entries[0].Id, nil
  269. default:
  270. details := make([]string, len(entries))
  271. for i, e := range entries {
  272. details[i] = fmt.Sprintf(" %s (path=%q, type=%s)", e.Id, e.Path, e.Type)
  273. }
  274. return "", fmt.Errorf("found %d credential entries named %q; use the entry UUID to select one:\n%s", len(entries), entryName, strings.Join(details, "\n"))
  275. }
  276. }
  277. // parseLegacyRef parses the legacy secret reference format "<vault-uuid>/<entry-uuid>".
  278. // This preserves backward compatibility for users who don't set the vault field.
  279. func parseLegacyRef(key string) (vaultID, entryID string, err error) {
  280. parts := strings.SplitN(key, "/", 2)
  281. if len(parts) != 2 {
  282. return "", "", fmt.Errorf("invalid key format: expected '<vault-id>/<entry-id>', got %q", key)
  283. }
  284. vaultID = strings.TrimSpace(parts[0])
  285. entryID = strings.TrimSpace(parts[1])
  286. if vaultID == "" {
  287. return "", "", errors.New("vault ID cannot be empty")
  288. }
  289. if entryID == "" {
  290. return "", "", errors.New("entry ID cannot be empty")
  291. }
  292. if !isUUID(vaultID) {
  293. return "", "", fmt.Errorf("invalid vault UUID: %q", vaultID)
  294. }
  295. if !isUUID(entryID) {
  296. return "", "", fmt.Errorf("invalid entry UUID: %q", entryID)
  297. }
  298. return vaultID, entryID, nil
  299. }
  300. // resolveVaultRef resolves a vault reference (name or UUID) to a vault UUID.
  301. func resolveVaultRef(ctx context.Context, vaultRef string, vc vaultGetter) (string, error) {
  302. if isUUID(vaultRef) {
  303. return vaultRef, nil
  304. }
  305. vault, err := vc.GetByName(ctx, vaultRef)
  306. if err != nil {
  307. return "", fmt.Errorf("failed to resolve vault %q: %w", vaultRef, err)
  308. }
  309. return vault.Id, nil
  310. }
  311. // parseEntryRef splits an entry reference into name and optional path.
  312. // Both forward slashes and backslashes are accepted as path separators.
  313. // The last separator splits the path from the entry name.
  314. // Paths are normalized to backslashes to match the DVLS path format.
  315. // e.g. "folder/subfolder/my-entry" → name="my-entry", path="folder\subfolder".
  316. // e.g. "folder\subfolder\my-entry" → name="my-entry", path="folder\subfolder".
  317. func parseEntryRef(ref string) (name, path string) {
  318. // Normalize forward slashes to backslashes.
  319. normalized := strings.ReplaceAll(ref, "/", `\`)
  320. if idx := strings.LastIndex(normalized, `\`); idx >= 0 {
  321. return normalized[idx+1:], normalized[:idx]
  322. }
  323. return ref, ""
  324. }
  325. // isUUID returns true if the string is a valid UUID.
  326. func isUUID(s string) bool {
  327. _, err := uuid.Parse(s)
  328. return err == nil
  329. }
  330. // entryToSecretMap converts a DVLS entry to a map of secret values.
  331. func entryToSecretMap(entry dvls.Entry) (map[string][]byte, error) {
  332. secretMap, err := entry.ToCredentialMap()
  333. if err != nil {
  334. return nil, err
  335. }
  336. result := make(map[string][]byte, len(secretMap))
  337. for k, v := range secretMap {
  338. result[k] = []byte(v)
  339. }
  340. return result, nil
  341. }
  342. func extractPushValue(secret *corev1.Secret, data esv1.PushSecretData) ([]byte, error) {
  343. if data.GetSecretKey() == "" {
  344. return nil, fmt.Errorf("secretKey is required for DVLS push")
  345. }
  346. if secret.Data == nil {
  347. return nil, fmt.Errorf("secret %q has no data", secret.Name)
  348. }
  349. value, ok := secret.Data[data.GetSecretKey()]
  350. if !ok {
  351. return nil, fmt.Errorf("key %q not found in secret %q", data.GetSecretKey(), secret.Name)
  352. }
  353. if len(value) == 0 {
  354. return nil, fmt.Errorf("key %q in secret %q is empty", data.GetSecretKey(), secret.Name)
  355. }
  356. return value, nil
  357. }
  358. func isNotFoundError(err error) bool {
  359. if err == nil {
  360. return false
  361. }
  362. if dvls.IsNotFound(err) {
  363. return true
  364. }
  365. if errors.Is(err, dvls.ErrEntryNotFound) {
  366. return true
  367. }
  368. return false
  369. }
  370. func isVaultNotFoundError(err error) bool {
  371. return err != nil && errors.Is(err, dvls.ErrVaultNotFound)
  372. }