client.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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 beyondtrustworkloadcredentials provides a client for BeyondTrust Workload Credentials.
  14. package beyondtrustworkloadcredentials
  15. import (
  16. "context"
  17. "encoding/json"
  18. "errors"
  19. "fmt"
  20. "path"
  21. "regexp"
  22. "strings"
  23. "time"
  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/providers/v1/beyondtrustworkloadcredentials/httpclient"
  27. btwcutil "github.com/external-secrets/external-secrets/providers/v1/beyondtrustworkloadcredentials/util"
  28. "github.com/external-secrets/external-secrets/runtime/esutils"
  29. )
  30. const (
  31. // ErrMsgNotImplemented is the error message for unimplemented methods.
  32. ErrMsgNotImplemented = "not implemented: %s"
  33. // validationTimeout is the timeout for SecretStore validation operations (network check and session validation).
  34. // Set to 15 seconds to balance between allowing sufficient time for API responses and failing fast on connectivity issues.
  35. validationTimeout = 15 * time.Second
  36. )
  37. // Client implements the SecretsClient interface for BeyondTrust Secrets.
  38. type Client struct {
  39. beyondtrustWorkloadCredentialsClient btwcutil.Client
  40. store *esv1.BeyondtrustWorkloadCredentialsProvider
  41. }
  42. // Validate checks if the client is configured correctly
  43. // and is able to retrieve secrets from the BeyondTrust Secrets provider.
  44. // If the validation result is unknown it will be ignored.
  45. func (c *Client) Validate() (esv1.ValidationResult, error) {
  46. // Check for nil receiver
  47. if c == nil {
  48. return esv1.ValidationResultError, fmt.Errorf("client is nil")
  49. }
  50. // Check for nil beyondtrustWorkloadCredentialsClient
  51. if c.beyondtrustWorkloadCredentialsClient == nil {
  52. return esv1.ValidationResultError, fmt.Errorf("beyondtrustWorkloadCredentialsClient is not initialized")
  53. }
  54. // Check for nil BaseURL
  55. baseURL := c.beyondtrustWorkloadCredentialsClient.BaseURL()
  56. if baseURL == nil {
  57. return esv1.ValidationResultError, fmt.Errorf("base URL is not configured")
  58. }
  59. clientURL := baseURL.String()
  60. if err := esutils.NetworkValidate(clientURL, validationTimeout); err != nil {
  61. return esv1.ValidationResultError, err
  62. }
  63. // Validate authentication by checking session
  64. ctx, cancel := context.WithTimeout(context.Background(), validationTimeout)
  65. defer cancel()
  66. if err := c.beyondtrustWorkloadCredentialsClient.CheckSession(ctx); err != nil {
  67. return esv1.ValidationResultError, fmt.Errorf("authentication validation failed: %w", err)
  68. }
  69. return esv1.ValidationResultReady, nil
  70. }
  71. // GetSecret returns a single secret from the BeyondTrust Secrets provider
  72. //
  73. // if GetSecret returns an error with type NoSecretError
  74. // then the secret entry will be deleted depending on the deletionPolicy.
  75. func (c *Client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
  76. folderPath := c.store.FolderPath
  77. secret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, ref.Key, &folderPath)
  78. if err != nil {
  79. // Wrap 404s as NoSecretError to allow ESO deletionPolicy handling
  80. var apiErr *httpclient.APIError
  81. if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
  82. return nil, esv1.NoSecretError{}
  83. }
  84. return nil, fmt.Errorf("failed to get secret: %w", err)
  85. }
  86. // Extract value from map
  87. if secret.Secret == nil {
  88. return nil, fmt.Errorf("secret value is nil")
  89. }
  90. // If there's a property key in the remote reference, use it
  91. if ref.Property != "" {
  92. value, ok := secret.Secret[ref.Property]
  93. if !ok {
  94. return nil, fmt.Errorf("property %s not found in secret", ref.Property)
  95. }
  96. // Handle different value types to preserve binary and object data
  97. switch val := value.(type) {
  98. case string:
  99. return []byte(val), nil
  100. case []byte:
  101. return val, nil
  102. default:
  103. // non-string: marshal to JSON to preserve structure
  104. b, err := json.Marshal(val)
  105. if err != nil {
  106. return nil, fmt.Errorf("failed to marshal secret value for property %q: %w", ref.Property, err)
  107. }
  108. return b, nil
  109. }
  110. }
  111. // If no property specified, return the entire secret as JSON
  112. secretBytes, err := json.Marshal(secret.Secret)
  113. if err != nil {
  114. return nil, fmt.Errorf("failed to marshal secret: %w", err)
  115. }
  116. return secretBytes, nil
  117. }
  118. // GetAllSecrets retrieves all secrets from BeyondTrust Secrets that match the given criteria.
  119. func (c *Client) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) {
  120. // Determine folder path: use ref.Path as folder scope if provided, otherwise use store default
  121. folderPath := c.store.FolderPath
  122. if ref.Path != nil {
  123. folderPath = strings.TrimSuffix(*ref.Path, "/")
  124. }
  125. result := map[string][]byte{}
  126. // List all secrets in the folder (recursive to get all secrets under the path)
  127. secretsList, err := c.beyondtrustWorkloadCredentialsClient.GetSecrets(ctx, &folderPath, true)
  128. if err != nil {
  129. // Treat 404 from listing API as NoSecretError (folder not found or empty)
  130. var apiErr *httpclient.APIError
  131. if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
  132. return nil, esv1.NoSecretError{}
  133. }
  134. return nil, fmt.Errorf("failed to list secrets: %w", err)
  135. }
  136. // If no regexp provided, include everything. If regexp provided, filter names.
  137. var nameRe *regexp.Regexp
  138. if ref.Name != nil && ref.Name.RegExp != "" {
  139. nameRe, err = regexp.Compile(ref.Name.RegExp)
  140. if err != nil {
  141. return nil, fmt.Errorf("invalid name regexp %q: %w", ref.Name.RegExp, err)
  142. }
  143. }
  144. for _, item := range secretsList {
  145. // item.Path may be a full path; split to derive folder/name as the API expects
  146. dir, itemName := path.Split(item.Path)
  147. dir = strings.TrimSuffix(dir, "/")
  148. itemFolderPath := folderPath
  149. if dir != "" {
  150. itemFolderPath = dir
  151. }
  152. if nameRe != nil {
  153. if !nameRe.MatchString(itemName) {
  154. continue
  155. }
  156. }
  157. // Fetch the full secret for this matched item
  158. fullSecret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, itemName, &itemFolderPath)
  159. if err != nil {
  160. // In name-regex listing, skip missing items instead of failing the entire operation
  161. var apiErr *httpclient.APIError
  162. if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
  163. continue
  164. }
  165. return nil, fmt.Errorf("failed to get secret at path %q: %w", path.Join(itemFolderPath, itemName), err)
  166. }
  167. if fullSecret == nil || fullSecret.Secret == nil {
  168. // Skip empty/missing entries in list mode
  169. continue
  170. }
  171. // Filter by tags if specified (all tags must match)
  172. if ref.Tags != nil && len(ref.Tags) > 0 {
  173. if fullSecret.Metadata == nil || !matchTags(fullSecret.Metadata.Tags, ref.Tags) {
  174. continue
  175. }
  176. }
  177. // Add each property in the secret namespaced by the secret path to avoid key collisions
  178. // across secrets that share inner property names.
  179. for k, v := range fullSecret.Secret {
  180. key := item.Path + "/" + k
  181. switch val := v.(type) {
  182. case string:
  183. result[key] = []byte(val)
  184. case []byte:
  185. result[key] = val
  186. default:
  187. // non-string: marshal to JSON to preserve structure
  188. b, err := json.Marshal(val)
  189. if err != nil {
  190. return nil, fmt.Errorf("failed to marshal secret value for key %q: %w", key, err)
  191. }
  192. result[key] = b
  193. }
  194. }
  195. }
  196. // If no secrets matched the criteria, return NoSecretError
  197. if len(result) == 0 {
  198. return nil, esv1.NoSecretError{}
  199. }
  200. return result, nil
  201. }
  202. // GetSecretMap returns multiple k/v pairs from the BeyondTrust Secrets provider as separate keys.
  203. // Each key-value pair in the secret becomes a separate entry in the returned map.
  204. func (c *Client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
  205. folderPath := c.store.FolderPath
  206. secret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, ref.Key, &folderPath)
  207. if err != nil {
  208. // Wrap 404s as NoSecretError to allow ESO deletionPolicy handling
  209. var apiErr *httpclient.APIError
  210. if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
  211. return nil, esv1.NoSecretError{}
  212. }
  213. return nil, fmt.Errorf("failed to get secret: %w", err)
  214. }
  215. if secret == nil || secret.Secret == nil {
  216. return nil, fmt.Errorf("secret value is nil")
  217. }
  218. // Convert all k/v pairs to []byte, preserving structure for non-string values
  219. result := make(map[string][]byte)
  220. for k, v := range secret.Secret {
  221. switch val := v.(type) {
  222. case string:
  223. result[k] = []byte(val)
  224. case []byte:
  225. result[k] = val
  226. default:
  227. // non-string: marshal to JSON to preserve structure
  228. b, err := json.Marshal(val)
  229. if err != nil {
  230. return nil, fmt.Errorf("failed to marshal secret value for key %q: %w", k, err)
  231. }
  232. result[k] = b
  233. }
  234. }
  235. return result, nil
  236. }
  237. // matchTags checks if all required tags (filter) are present in the secret's tags
  238. // with matching values. Returns true if all filter tags match, false otherwise.
  239. func matchTags(secretTags, filterTags map[string]string) bool {
  240. if len(filterTags) == 0 {
  241. return true
  242. }
  243. if len(secretTags) == 0 {
  244. return false
  245. }
  246. // All filter tags must be present and match
  247. for filterKey, filterValue := range filterTags {
  248. secretValue, exists := secretTags[filterKey]
  249. if !exists || secretValue != filterValue {
  250. return false
  251. }
  252. }
  253. return true
  254. }
  255. /////////////////////////
  256. // NOT YET IMPLEMENTED //
  257. /////////////////////////
  258. // PushSecret will write a single secret into the BeyondTrust Secrets provider.
  259. func (c *Client) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
  260. return fmt.Errorf(ErrMsgNotImplemented, "PushSecret")
  261. }
  262. // DeleteSecret will delete the secret from the BeyondTrust Secrets provider.
  263. func (c *Client) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
  264. return fmt.Errorf(ErrMsgNotImplemented, "DeleteSecret")
  265. }
  266. // SecretExists checks if a secret is already present in the BeyondTrust Secrets provider at the given location.
  267. func (c *Client) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
  268. return false, fmt.Errorf(ErrMsgNotImplemented, "SecretExists")
  269. }
  270. // Close implements cleanup operations for the BeyondTrust Secrets client.
  271. func (c *Client) Close(_ context.Context) error {
  272. return nil
  273. }