client.go 22 KB

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