/* 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 }