| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- /*
- Copyright © The ESO Authors
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
- https://www.apache.org/licenses/LICENSE-2.0
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
- */
- // Package beyondtrustworkloadcredentials provides a client for BeyondTrust Workload Credentials.
- package beyondtrustworkloadcredentials
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "path"
- "regexp"
- "strings"
- "time"
- corev1 "k8s.io/api/core/v1"
- esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
- "github.com/external-secrets/external-secrets/providers/v1/beyondtrustworkloadcredentials/httpclient"
- btwcutil "github.com/external-secrets/external-secrets/providers/v1/beyondtrustworkloadcredentials/util"
- "github.com/external-secrets/external-secrets/runtime/esutils"
- )
- const (
- // ErrMsgNotImplemented is the error message for unimplemented methods.
- ErrMsgNotImplemented = "not implemented: %s"
- // validationTimeout is the timeout for SecretStore validation operations (network check and session validation).
- // Set to 15 seconds to balance between allowing sufficient time for API responses and failing fast on connectivity issues.
- validationTimeout = 15 * time.Second
- )
- // Client implements the SecretsClient interface for BeyondTrust Secrets.
- type Client struct {
- beyondtrustWorkloadCredentialsClient btwcutil.Client
- store *esv1.BeyondtrustWorkloadCredentialsProvider
- }
- // Validate checks if the client is configured correctly
- // and is able to retrieve secrets from the BeyondTrust Secrets provider.
- // If the validation result is unknown it will be ignored.
- func (c *Client) Validate() (esv1.ValidationResult, error) {
- // Check for nil receiver
- if c == nil {
- return esv1.ValidationResultError, fmt.Errorf("client is nil")
- }
- // Check for nil beyondtrustWorkloadCredentialsClient
- if c.beyondtrustWorkloadCredentialsClient == nil {
- return esv1.ValidationResultError, fmt.Errorf("beyondtrustWorkloadCredentialsClient is not initialized")
- }
- // Check for nil BaseURL
- baseURL := c.beyondtrustWorkloadCredentialsClient.BaseURL()
- if baseURL == nil {
- return esv1.ValidationResultError, fmt.Errorf("base URL is not configured")
- }
- clientURL := baseURL.String()
- if err := esutils.NetworkValidate(clientURL, validationTimeout); err != nil {
- return esv1.ValidationResultError, err
- }
- // Validate authentication by checking session
- ctx, cancel := context.WithTimeout(context.Background(), validationTimeout)
- defer cancel()
- if err := c.beyondtrustWorkloadCredentialsClient.CheckSession(ctx); err != nil {
- return esv1.ValidationResultError, fmt.Errorf("authentication validation failed: %w", err)
- }
- return esv1.ValidationResultReady, nil
- }
- // GetSecret returns a single secret from the BeyondTrust Secrets provider
- //
- // if GetSecret returns an error with type NoSecretError
- // then the secret entry will be deleted depending on the deletionPolicy.
- func (c *Client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
- folderPath := c.store.FolderPath
- secret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, ref.Key, &folderPath)
- if err != nil {
- // Wrap 404s as NoSecretError to allow ESO deletionPolicy handling
- var apiErr *httpclient.APIError
- if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
- return nil, esv1.NoSecretError{}
- }
- return nil, fmt.Errorf("failed to get secret: %w", err)
- }
- // Extract value from map
- if secret.Secret == nil {
- return nil, fmt.Errorf("secret value is nil")
- }
- // If there's a property key in the remote reference, use it
- if ref.Property != "" {
- value, ok := secret.Secret[ref.Property]
- if !ok {
- return nil, fmt.Errorf("property %s not found in secret", ref.Property)
- }
- // Handle different value types to preserve binary and object data
- switch val := value.(type) {
- case string:
- return []byte(val), nil
- case []byte:
- return val, nil
- default:
- // non-string: marshal to JSON to preserve structure
- b, err := json.Marshal(val)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal secret value for property %q: %w", ref.Property, err)
- }
- return b, nil
- }
- }
- // If no property specified, return the entire secret as JSON
- secretBytes, err := json.Marshal(secret.Secret)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal secret: %w", err)
- }
- return secretBytes, nil
- }
- // GetAllSecrets retrieves all secrets from BeyondTrust Secrets that match the given criteria.
- func (c *Client) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) {
- // Determine folder path: use ref.Path as folder scope if provided, otherwise use store default
- folderPath := c.store.FolderPath
- if ref.Path != nil {
- folderPath = strings.TrimSuffix(*ref.Path, "/")
- }
- result := map[string][]byte{}
- // List all secrets in the folder (recursive to get all secrets under the path)
- secretsList, err := c.beyondtrustWorkloadCredentialsClient.GetSecrets(ctx, &folderPath, true)
- if err != nil {
- // Treat 404 from listing API as NoSecretError (folder not found or empty)
- var apiErr *httpclient.APIError
- if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
- return nil, esv1.NoSecretError{}
- }
- return nil, fmt.Errorf("failed to list secrets: %w", err)
- }
- // If no regexp provided, include everything. If regexp provided, filter names.
- var nameRe *regexp.Regexp
- if ref.Name != nil && ref.Name.RegExp != "" {
- nameRe, err = regexp.Compile(ref.Name.RegExp)
- if err != nil {
- return nil, fmt.Errorf("invalid name regexp %q: %w", ref.Name.RegExp, err)
- }
- }
- for _, item := range secretsList {
- // item.Path may be a full path; split to derive folder/name as the API expects
- dir, itemName := path.Split(item.Path)
- dir = strings.TrimSuffix(dir, "/")
- itemFolderPath := folderPath
- if dir != "" {
- itemFolderPath = dir
- }
- if nameRe != nil {
- if !nameRe.MatchString(itemName) {
- continue
- }
- }
- // Fetch the full secret for this matched item
- fullSecret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, itemName, &itemFolderPath)
- if err != nil {
- // In name-regex listing, skip missing items instead of failing the entire operation
- var apiErr *httpclient.APIError
- if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
- continue
- }
- return nil, fmt.Errorf("failed to get secret at path %q: %w", path.Join(itemFolderPath, itemName), err)
- }
- if fullSecret == nil || fullSecret.Secret == nil {
- // Skip empty/missing entries in list mode
- continue
- }
- // Filter by tags if specified (all tags must match)
- if ref.Tags != nil && len(ref.Tags) > 0 {
- if fullSecret.Metadata == nil || !matchTags(fullSecret.Metadata.Tags, ref.Tags) {
- continue
- }
- }
- // Add each property in the secret namespaced by the secret path to avoid key collisions
- // across secrets that share inner property names.
- for k, v := range fullSecret.Secret {
- key := item.Path + "/" + k
- switch val := v.(type) {
- case string:
- result[key] = []byte(val)
- case []byte:
- result[key] = val
- default:
- // non-string: marshal to JSON to preserve structure
- b, err := json.Marshal(val)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal secret value for key %q: %w", key, err)
- }
- result[key] = b
- }
- }
- }
- // If no secrets matched the criteria, return NoSecretError
- if len(result) == 0 {
- return nil, esv1.NoSecretError{}
- }
- return result, nil
- }
- // GetSecretMap returns multiple k/v pairs from the BeyondTrust Secrets provider as separate keys.
- // Each key-value pair in the secret becomes a separate entry in the returned map.
- func (c *Client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
- folderPath := c.store.FolderPath
- secret, err := c.beyondtrustWorkloadCredentialsClient.GetSecret(ctx, ref.Key, &folderPath)
- if err != nil {
- // Wrap 404s as NoSecretError to allow ESO deletionPolicy handling
- var apiErr *httpclient.APIError
- if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
- return nil, esv1.NoSecretError{}
- }
- return nil, fmt.Errorf("failed to get secret: %w", err)
- }
- if secret == nil || secret.Secret == nil {
- return nil, fmt.Errorf("secret value is nil")
- }
- // Convert all k/v pairs to []byte, preserving structure for non-string values
- result := make(map[string][]byte)
- for k, v := range secret.Secret {
- switch val := v.(type) {
- case string:
- result[k] = []byte(val)
- case []byte:
- result[k] = val
- default:
- // non-string: marshal to JSON to preserve structure
- b, err := json.Marshal(val)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal secret value for key %q: %w", k, err)
- }
- result[k] = b
- }
- }
- return result, nil
- }
- // matchTags checks if all required tags (filter) are present in the secret's tags
- // with matching values. Returns true if all filter tags match, false otherwise.
- func matchTags(secretTags, filterTags map[string]string) bool {
- if len(filterTags) == 0 {
- return true
- }
- if len(secretTags) == 0 {
- return false
- }
- // All filter tags must be present and match
- for filterKey, filterValue := range filterTags {
- secretValue, exists := secretTags[filterKey]
- if !exists || secretValue != filterValue {
- return false
- }
- }
- return true
- }
- /////////////////////////
- // NOT YET IMPLEMENTED //
- /////////////////////////
- // PushSecret will write a single secret into the BeyondTrust Secrets provider.
- func (c *Client) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
- return fmt.Errorf(ErrMsgNotImplemented, "PushSecret")
- }
- // DeleteSecret will delete the secret from the BeyondTrust Secrets provider.
- func (c *Client) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
- return fmt.Errorf(ErrMsgNotImplemented, "DeleteSecret")
- }
- // SecretExists checks if a secret is already present in the BeyondTrust Secrets provider at the given location.
- func (c *Client) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
- return false, fmt.Errorf(ErrMsgNotImplemented, "SecretExists")
- }
- // Close implements cleanup operations for the BeyondTrust Secrets client.
- func (c *Client) Close(_ context.Context) error {
- return nil
- }
|