client.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  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 secretserver
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "strconv"
  20. "strings"
  21. "unicode/utf8"
  22. "github.com/DelineaXPM/tss-sdk-go/v3/server"
  23. "github.com/tidwall/gjson"
  24. corev1 "k8s.io/api/core/v1"
  25. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  26. "github.com/external-secrets/external-secrets/runtime/esutils"
  27. "github.com/external-secrets/external-secrets/runtime/esutils/metadata"
  28. )
  29. const (
  30. // errMsgNoMatchingSecrets is returned by getSecretByName when a search returns zero results.
  31. // This preserves backward compatibility with the original error message used
  32. // before the PushSecret feature was added.
  33. errMsgNoMatchingSecrets = "unable to retrieve secret at this time"
  34. // errMsgNotFound is returned when a secret is not found in a specific folder.
  35. errMsgNotFound = "not found"
  36. // errMsgAmbiguousName is returned by lookupSecretStrict when a plain name
  37. // matches multiple secrets across folders and no folder scope is provided.
  38. errMsgAmbiguousName = "multiple secrets found with the same name across different folders; use the 'folderId:<id>/<name>' key format, a path-based key, or a numeric ID to disambiguate"
  39. // folderPrefix is the prefix used to encode a folder ID in a remote key.
  40. // Format: "folderId:<id>/<name>" (e.g. "folderId:73/my-secret").
  41. folderPrefix = "folderId:"
  42. )
  43. // isNotFoundError checks if an error indicates a secret was not found.
  44. // The TSS SDK (v3) returns all errors as plain fmt.Errorf strings with format
  45. // "<StatusCode> <StatusText>: <body>" — no typed/sentinel errors.
  46. //
  47. // This function uses case-insensitive substring matching with explicit exclusions
  48. // for false-positive patterns produced by our own code (e.g. "not found in secret"
  49. // from updateSecret or "not found in secret template" from createSecret) and
  50. // non-404 HTTP errors that happen to contain "not found" in their body.
  51. func isNotFoundError(err error) bool {
  52. if err == nil {
  53. return false
  54. }
  55. msg := strings.ToLower(err.Error())
  56. // Our own sentinel from getSecretByName / getSecretByNameStrict.
  57. if strings.Contains(msg, errMsgNoMatchingSecrets) {
  58. return true
  59. }
  60. // SDK HTTP 404 responses start with "404 ".
  61. if strings.HasPrefix(msg, "404 ") {
  62. return true
  63. }
  64. // Generic "not found" substring — but exclude false positives.
  65. if strings.Contains(msg, errMsgNotFound) {
  66. // Patterns like "field X not found in secret" or "field X not found in secret template"
  67. // are field-level errors, not secret-not-found errors.
  68. if strings.Contains(msg, "not found in secret") {
  69. return false
  70. }
  71. // Exclude non-404 HTTP errors that happen to contain "not found" in the body
  72. // (e.g. "401 Unauthorized: user not found"). The SDK formats all HTTP errors
  73. // as "<StatusCode> <StatusText>: <body>", so any message starting with a
  74. // 3-digit code followed by a space is an HTTP error.
  75. if len(msg) >= 4 && msg[3] == ' ' && msg[0] >= '0' && msg[0] <= '9' && msg[1] >= '0' && msg[1] <= '9' && msg[2] >= '0' && msg[2] <= '9' {
  76. return false // non-404 HTTP error (404 was already handled above)
  77. }
  78. return true
  79. }
  80. return false
  81. }
  82. // parseFolderPrefix extracts a folder ID and secret name from a key with the
  83. // format "folderId:<id>/<name>". If the key does not match the prefix format,
  84. // it returns (0, key, false) so callers can fall through to other resolution
  85. // strategies.
  86. func parseFolderPrefix(key string) (folderID int, name string, hasFolderPrefix bool) {
  87. if !strings.HasPrefix(key, folderPrefix) {
  88. return 0, key, false
  89. }
  90. rest := strings.TrimPrefix(key, folderPrefix) // "<id>/<name>"
  91. slashIdx := strings.Index(rest, "/") //nolint:modernize // Need index of first slash to separate folder ID from name
  92. if slashIdx < 0 {
  93. // "folderId:73" with no slash/name — treat as not having the prefix.
  94. return 0, key, false
  95. }
  96. idStr := rest[:slashIdx]
  97. secretName := rest[slashIdx+1:]
  98. id, err := strconv.Atoi(idStr)
  99. if err != nil || id <= 0 {
  100. // Non-numeric or non-positive folder ID — treat as not having the prefix.
  101. return 0, key, false
  102. }
  103. if secretName == "" {
  104. // "folderId:73/" with empty name — treat as not having the prefix.
  105. return 0, key, false
  106. }
  107. return id, secretName, true
  108. }
  109. // PushSecretMetadataSpec contains metadata information for pushing secrets to Delinea Secret Server.
  110. type PushSecretMetadataSpec struct {
  111. FolderID int `json:"folderId"`
  112. SecretTemplateID int `json:"secretTemplateId"`
  113. }
  114. type client struct {
  115. api secretAPI
  116. }
  117. var _ esv1.SecretsClient = &client{}
  118. // GetSecret supports several lookup modes:
  119. // 1. Get the secret using the secret ID in ref.Key (e.g. key: 53974).
  120. // 2. Get the secret using the secret "name" (e.g. key: "secretNameHere").
  121. // - Secret names must not contain spaces.
  122. // - If using the secret "name" and multiple secrets are found,
  123. // the first secret in the array will be the secret returned.
  124. // 3. Get the full secret as a JSON-encoded value by leaving ref.Property empty.
  125. // 4. Get a specific value by using a key from the JSON-formatted secret in
  126. // Items.0.ItemValue via gjson (supports nested paths like "server.1").
  127. // If the first field's ItemValue is not valid JSON or the gjson path
  128. // does not match, fall back to matching ref.Property against each field's
  129. // Slug or FieldName (useful for multi-field secrets).
  130. func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
  131. secret, err := c.getSecret(ctx, ref)
  132. if err != nil {
  133. return nil, err
  134. }
  135. if len(secret.Fields) == 0 {
  136. return nil, errors.New("secret contains no fields")
  137. }
  138. jsonStr, err := json.Marshal(secret)
  139. if err != nil {
  140. return nil, err
  141. }
  142. // Intentionally fetch and return the full secret as raw JSON when no specific property is provided.
  143. // This requires calling the API to retrieve the entire secret object.
  144. if ref.Property == "" {
  145. return jsonStr, nil
  146. }
  147. // Primary path: extract ref.Property from the first field's ItemValue via gjson.
  148. // This preserves backward compatibility with the original single-field JSON blob pattern.
  149. val := gjson.Get(string(jsonStr), "Items.0.ItemValue")
  150. if val.Exists() && gjson.Valid(val.String()) {
  151. out := gjson.Get(val.String(), ref.Property)
  152. if out.Exists() {
  153. return []byte(out.String()), nil
  154. }
  155. }
  156. // Fallback: match ref.Property against field Slug or FieldName.
  157. // This supports multi-field secrets where fields are accessed by name.
  158. for index := range secret.Fields {
  159. if secret.Fields[index].Slug == ref.Property || secret.Fields[index].FieldName == ref.Property {
  160. return []byte(secret.Fields[index].ItemValue), nil
  161. }
  162. }
  163. return nil, esv1.NoSecretError{}
  164. }
  165. // PushSecret creates or updates a secret in Delinea Secret Server.
  166. func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
  167. if data.GetRemoteKey() == "" {
  168. return errors.New("remote key must be defined")
  169. }
  170. value, err := esutils.ExtractSecretData(data, secret)
  171. if err != nil {
  172. return fmt.Errorf("failed to extract secret data: %w", err)
  173. }
  174. if !utf8.Valid(value) {
  175. return errors.New("secret value is not valid UTF-8")
  176. }
  177. meta, err := metadata.ParseMetadataParameters[PushSecretMetadataSpec](data.GetMetadata())
  178. if err != nil {
  179. return fmt.Errorf("failed to parse metadata: %w", err)
  180. }
  181. // Resolve the effective folder ID for both lookups AND creation.
  182. // A folderId encoded in the remoteKey takes precedence over metadata
  183. // (delete and existence-check never see metadata, so the prefix must win
  184. // to keep lookup and creation consistent).
  185. folderID := 0
  186. if meta != nil {
  187. folderID = meta.Spec.FolderID
  188. }
  189. if prefixFolderID, _, ok := parseFolderPrefix(data.GetRemoteKey()); ok {
  190. folderID = prefixFolderID
  191. }
  192. existingSecret, err := c.findExistingSecret(ctx, data.GetRemoteKey(), folderID)
  193. if err != nil {
  194. if !isNotFoundError(err) {
  195. return fmt.Errorf("failed to get secret: %w", err)
  196. }
  197. existingSecret = nil
  198. }
  199. if existingSecret != nil {
  200. // Update existing secret
  201. return c.updateSecret(existingSecret, data.GetProperty(), string(value))
  202. }
  203. if meta == nil || meta.Spec.SecretTemplateID <= 0 {
  204. return errors.New("folderId and secretTemplateId must be provided in metadata to create a new secret")
  205. }
  206. // Use the effective folderID (prefix-overridden or metadata-supplied) for creation.
  207. if folderID <= 0 {
  208. return errors.New("folderId and secretTemplateId must be provided in metadata to create a new secret")
  209. }
  210. createSpec := meta.Spec
  211. createSpec.FolderID = folderID
  212. return c.createSecret(data.GetRemoteKey(), data.GetProperty(), string(value), createSpec)
  213. }
  214. // updateSecret updates an existing secret in Delinea Secret Server.
  215. func (c *client) updateSecret(secret *server.Secret, property, value string) error {
  216. if property == "" {
  217. // If property is empty, put the JSON value in the first field, matching GetSecretMap logic
  218. if len(secret.Fields) > 0 {
  219. secret.Fields[0].ItemValue = value
  220. } else {
  221. return errors.New("secret has no fields to update")
  222. }
  223. } else {
  224. found := false
  225. for i, field := range secret.Fields {
  226. if field.Slug == property || field.FieldName == property {
  227. secret.Fields[i].ItemValue = value
  228. found = true
  229. break
  230. }
  231. }
  232. if !found {
  233. return fmt.Errorf("field %s not found in secret", property)
  234. }
  235. }
  236. _, err := c.api.UpdateSecret(*secret)
  237. if err != nil {
  238. return fmt.Errorf("failed to update secret: %w", err)
  239. }
  240. return nil
  241. }
  242. // createSecret creates a new secret in Delinea Secret Server.
  243. // Only the targeted field is populated; other required template fields
  244. // may cause an API error.
  245. func (c *client) createSecret(name, property, value string, meta PushSecretMetadataSpec) error {
  246. template, err := c.api.SecretTemplate(meta.SecretTemplateID)
  247. if err != nil {
  248. return fmt.Errorf("failed to get secret template: %w", err)
  249. }
  250. if strings.HasSuffix(name, "/") {
  251. return fmt.Errorf("invalid secret name %q: name must not be empty or end with a trailing slash", name)
  252. }
  253. // Strip the "folderId:<id>/" prefix if present so the secret is created
  254. // with just the plain name. The folder is already specified in meta.FolderID.
  255. if _, stripped, ok := parseFolderPrefix(name); ok {
  256. name = stripped
  257. // After prefix stripping, the name should be a simple name (no slashes).
  258. // The folderId format is "folderId:<id>/<name>", not "folderId:<id>/<path>".
  259. if strings.Contains(name, "/") {
  260. return fmt.Errorf("invalid secret name %q in folderId prefix: name must not contain path separators", name)
  261. }
  262. }
  263. // For path-based keys (e.g. "/Folder/SubFolder/SecretName"), extract the
  264. // basename. The folder structure is controlled by meta.FolderID.
  265. normalizedName := strings.TrimPrefix(name, "/")
  266. if strings.Contains(normalizedName, "/") {
  267. parts := strings.Split(normalizedName, "/")
  268. normalizedName = parts[len(parts)-1]
  269. }
  270. newSecret := server.Secret{
  271. Name: normalizedName,
  272. FolderID: meta.FolderID,
  273. SecretTemplateID: meta.SecretTemplateID,
  274. Fields: make([]server.SecretField, 0),
  275. }
  276. if property == "" {
  277. // No property specified: use the first template field.
  278. if len(template.Fields) == 0 {
  279. return errors.New("secret template has no fields")
  280. }
  281. newSecret.Fields = append(newSecret.Fields, server.SecretField{
  282. FieldID: template.Fields[0].SecretTemplateFieldID,
  283. ItemValue: value,
  284. })
  285. } else {
  286. // Use the field matching the specified property.
  287. fieldID, found := findTemplateFieldID(template, property)
  288. if !found {
  289. return fmt.Errorf("field %s not found in secret template", property)
  290. }
  291. newSecret.Fields = append(newSecret.Fields, server.SecretField{
  292. FieldID: fieldID,
  293. ItemValue: value,
  294. })
  295. }
  296. _, err = c.api.CreateSecret(newSecret)
  297. if err != nil {
  298. return fmt.Errorf("failed to create secret: %w", err)
  299. }
  300. return nil
  301. }
  302. // DeleteSecret deletes a secret in Delinea Secret Server.
  303. func (c *client) DeleteSecret(_ context.Context, ref esv1.PushSecretRemoteRef) error {
  304. secret, err := c.lookupSecretStrict(ref.GetRemoteKey())
  305. if err != nil {
  306. // If already deleted/not found, ignore
  307. if isNotFoundError(err) {
  308. return nil
  309. }
  310. return fmt.Errorf("failed to get secret for deletion: %w", err)
  311. }
  312. err = c.api.DeleteSecret(secret.ID)
  313. if err != nil {
  314. return fmt.Errorf("failed to delete secret: %w", err)
  315. }
  316. return nil
  317. }
  318. // SecretExists checks if a secret exists in Delinea Secret Server.
  319. func (c *client) SecretExists(_ context.Context, ref esv1.PushSecretRemoteRef) (bool, error) {
  320. _, err := c.lookupSecretStrict(ref.GetRemoteKey())
  321. if err != nil {
  322. if isNotFoundError(err) {
  323. return false, nil
  324. }
  325. return false, fmt.Errorf("failed to check if secret exists: %w", err)
  326. }
  327. return true, nil
  328. }
  329. // Validate not supported at this time.
  330. func (c *client) Validate() (esv1.ValidationResult, error) {
  331. return esv1.ValidationResultReady, nil
  332. }
  333. // GetSecretMap retrieves the secret referenced by ref from the Secret Server API
  334. // and returns it as a map of byte slices.
  335. func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
  336. secret, err := c.getSecret(ctx, ref)
  337. if err != nil {
  338. return nil, err
  339. }
  340. // Ensure secret has fields before indexing into them
  341. if len(secret.Fields) == 0 {
  342. return nil, errors.New("secret contains no fields")
  343. }
  344. secretData := make(map[string]any)
  345. err = json.Unmarshal([]byte(secret.Fields[0].ItemValue), &secretData)
  346. if err != nil {
  347. // Do not return the raw error as json.Unmarshal errors may contain
  348. // sensitive secret data in the error message
  349. return nil, errors.New("failed to unmarshal secret: invalid JSON format")
  350. }
  351. data := make(map[string][]byte)
  352. for k, v := range secretData {
  353. data[k], err = esutils.GetByteValue(v)
  354. if err != nil {
  355. return nil, err
  356. }
  357. }
  358. return data, nil
  359. }
  360. // GetAllSecrets is not supported. The tss-sdk-go v3 SDK search is hard-capped
  361. // at 30 results with no pagination, no tag filtering, and no folder enumeration.
  362. func (c *client) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
  363. return nil, errors.New("getting all secrets is not supported by Delinea Secret Server")
  364. }
  365. func (c *client) Close(context.Context) error {
  366. return nil
  367. }
  368. // getSecret retrieves the secret referenced by ref from the Secret Server API.
  369. func (c *client) getSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) (*server.Secret, error) {
  370. if ref.Version != "" {
  371. return nil, errors.New("specifying a version is not supported")
  372. }
  373. return c.lookupSecret(ref.Key, 0)
  374. }
  375. // findExistingSecret looks up a secret for PushSecret's find-or-create logic.
  376. // Unlike getSecret (used for reads), this refuses ambiguous plain-name matches
  377. // when no folder scope is available, matching the safety behavior of DeleteSecret
  378. // and SecretExists.
  379. func (c *client) findExistingSecret(_ context.Context, key string, folderID int) (*server.Secret, error) {
  380. // When a folder scope is available (either from prefix or metadata),
  381. // the lookup is unambiguous — use the regular (non-strict) resolver.
  382. if folderID > 0 {
  383. return c.lookupSecret(key, folderID)
  384. }
  385. // No folder scope: use strict lookup to reject ambiguous plain names.
  386. return c.lookupSecretStrict(key)
  387. }
  388. // lookupSecret resolves a secret by path ("/..."), numeric ID, folder-scoped
  389. // name ("folderId:<id>/<name>"), or plain name.
  390. // The folderID scopes name-based lookups (0 = any folder). A folder prefix
  391. // encoded in the key takes precedence over the folderID argument.
  392. func (c *client) lookupSecret(key string, folderID int) (*server.Secret, error) {
  393. // 1. Folder-scoped prefix: "folderId:<id>/<name>" — override folderID and
  394. // resolve by name within the specified folder.
  395. if prefixFolderID, name, ok := parseFolderPrefix(key); ok {
  396. return c.getSecretByName(name, prefixFolderID)
  397. }
  398. // 2. Path-based key: fully qualified, no disambiguation needed.
  399. if strings.HasPrefix(key, "/") {
  400. return c.api.SecretByPath(key)
  401. }
  402. // 3. Numeric key: treat as ID first; fall back to name-based lookup so that
  403. // secrets whose name happens to be a numeric string can still be resolved.
  404. if id, err := strconv.Atoi(key); err == nil {
  405. secret, err := c.api.Secret(id)
  406. if err == nil && secret != nil {
  407. return secret, nil
  408. }
  409. if !isNotFoundError(err) {
  410. return nil, err
  411. }
  412. }
  413. return c.getSecretByName(key, folderID)
  414. }
  415. // lookupSecretStrict resolves a secret like lookupSecret but refuses to
  416. // silently pick the first match when a plain name (no folderId prefix, no
  417. // path, no numeric ID) matches more than one secret across folders.
  418. // This is used by destructive operations (DeleteSecret) and existence checks
  419. // (SecretExists) that must not accidentally act on the wrong secret.
  420. func (c *client) lookupSecretStrict(key string) (*server.Secret, error) {
  421. // 1. Folder-scoped prefix: unambiguous — delegate directly.
  422. if prefixFolderID, name, ok := parseFolderPrefix(key); ok {
  423. return c.getSecretByName(name, prefixFolderID)
  424. }
  425. // 2. Path-based key: unambiguous.
  426. if strings.HasPrefix(key, "/") {
  427. return c.api.SecretByPath(key)
  428. }
  429. // 3. Numeric key: try as ID first; fall back to name-based lookup.
  430. if id, err := strconv.Atoi(key); err == nil {
  431. secret, err := c.api.Secret(id)
  432. if err == nil && secret != nil {
  433. return secret, nil
  434. }
  435. if !isNotFoundError(err) {
  436. return nil, err
  437. }
  438. }
  439. // 4. Plain name: reject if ambiguous (multiple matches without folder scope).
  440. return c.getSecretByNameStrict(key)
  441. }
  442. // getSecretByNameStrict searches for a secret by name and returns an error if
  443. // multiple secrets share the same name across different folders.
  444. func (c *client) getSecretByNameStrict(name string) (*server.Secret, error) {
  445. secrets, err := c.api.Secrets(name, "Name")
  446. if err != nil {
  447. return nil, err
  448. }
  449. if len(secrets) == 0 {
  450. return nil, errors.New(errMsgNoMatchingSecrets)
  451. }
  452. if len(secrets) > 1 {
  453. return nil, errors.New(errMsgAmbiguousName)
  454. }
  455. return &secrets[0], nil
  456. }
  457. func (c *client) getSecretByName(name string, folderID int) (*server.Secret, error) {
  458. secrets, err := c.api.Secrets(name, "Name")
  459. if err != nil {
  460. return nil, err
  461. }
  462. if len(secrets) == 0 {
  463. return nil, errors.New(errMsgNoMatchingSecrets)
  464. }
  465. // No folder constraint: return the first match.
  466. if folderID == 0 {
  467. return &secrets[0], nil
  468. }
  469. // Find the first secret matching the requested folder.
  470. for i, s := range secrets {
  471. if s.FolderID == folderID {
  472. return &secrets[i], nil
  473. }
  474. }
  475. return nil, errors.New(errMsgNotFound)
  476. }
  477. func findTemplateFieldID(template *server.SecretTemplate, property string) (int, bool) {
  478. fieldID, found := template.FieldSlugToId(property)
  479. if found {
  480. return fieldID, true
  481. }
  482. // fallback check if they used name instead of slug
  483. for _, f := range template.Fields {
  484. if f.Name == property || f.FieldSlugName == property {
  485. return f.SecretTemplateFieldID, true
  486. }
  487. }
  488. return 0, false
  489. }