/* 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 scaleway import ( "bytes" "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" smapi "github.com/scaleway/scaleway-sdk-go/api/secret/v1beta1" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/tidwall/gjson" corev1 "k8s.io/api/core/v1" esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1" "github.com/external-secrets/external-secrets/runtime/find" ) var errNoSecretForName = errors.New("no secret for this name") type client struct { api secretAPI projectID string cache cache } const ( refTypeName = "name" refTypeID = "id" refTypePath = "path" ) type scwSecretRef struct { RefType string Value string } func (r scwSecretRef) String() string { return fmt.Sprintf("%s:%s", r.RefType, r.Value) } func decodeScwSecretRef(key string) (*scwSecretRef, error) { sepIndex := strings.IndexRune(key, ':') if sepIndex < 0 { return nil, errors.New("invalid secret reference: missing colon ':'") } return &scwSecretRef{ RefType: key[:sepIndex], Value: key[sepIndex+1:], }, nil } func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) { scwRef, err := decodeScwSecretRef(ref.Key) if err != nil { return nil, err } versionSpec := "latest_enabled" if ref.Version != "" { versionSpec = ref.Version } value, err := c.accessSecretVersion(ctx, scwRef, versionSpec) if err != nil { //nolint:errorlint if _, isNotFoundErr := err.(*scw.ResourceNotFoundError); isNotFoundErr { return nil, esv1.NoSecretError{} } else if errors.Is(err, errNoSecretForName) { return nil, esv1.NoSecretError{} } return nil, err } if ref.Property != "" { extracted, err := extractJSONProperty(value, ref.Property) if err != nil { return nil, err } value = extracted } return value, nil } func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error { if data.GetSecretKey() == "" { return errors.New("pushing the whole secret is not yet implemented") } value := secret.Data[data.GetSecretKey()] scwRef, err := decodeScwSecretRef(data.GetRemoteKey()) if err != nil { return err } listSecretReq := &smapi.ListSecretsRequest{ ProjectID: &c.projectID, Page: scw.Int32Ptr(1), PageSize: scw.Uint32Ptr(1), } var secretName string secretPath := "/" switch scwRef.RefType { case refTypeName: listSecretReq.Name = &scwRef.Value secretName = scwRef.Value case refTypePath: name, path, ok := splitNameAndPath(scwRef.Value) if !ok { return errors.New("ref is not a path") } listSecretReq.Name = &name listSecretReq.Path = &path secretName = name secretPath = path default: return errors.New("secrets can only be pushed by name or path") } var secretID string existingSecretVersion := int64(-1) // list secret by ref listSecrets, err := c.api.ListSecrets(listSecretReq, scw.WithContext(ctx)) if err != nil { return err } // secret exists if len(listSecrets.Secrets) > 0 { secretID = listSecrets.Secrets[0].ID // get the latest version secretVersion, err := c.api.GetSecretVersion(&smapi.GetSecretVersionRequest{ SecretID: secretID, Revision: "latest", }, scw.WithContext(ctx)) if err != nil { var errNotNound *scw.ResourceNotFoundError if !errors.As(err, &errNotNound) { return err } } else { existingSecretVersion = int64(secretVersion.Revision) } if existingSecretVersion != -1 { data, err := c.accessSpecificSecretVersion(ctx, secretID, secretVersion.Revision) if err != nil { return err } if bytes.Equal(data, value) { // No change to push. return nil } } } else { secret, err := c.api.CreateSecret(&smapi.CreateSecretRequest{ ProjectID: c.projectID, Name: secretName, Path: &secretPath, }, scw.WithContext(ctx)) if err != nil { return err } secretID = secret.ID } // Finally, we push the new secret version. createSecretVersionRequest := smapi.CreateSecretVersionRequest{ SecretID: secretID, Data: value, } createSecretVersionResponse, err := c.api.CreateSecretVersion(&createSecretVersionRequest, scw.WithContext(ctx)) if err != nil { return err } c.cache.Put(secretID, createSecretVersionResponse.Revision, value) if existingSecretVersion != -1 { _, err := c.api.DisableSecretVersion(&smapi.DisableSecretVersionRequest{ SecretID: secretID, Revision: fmt.Sprintf("%d", existingSecretVersion), }) if err != nil { return err } } return nil } func (c *client) DeleteSecret(ctx context.Context, remoteRef esv1.PushSecretRemoteRef) error { scwRef, err := decodeScwSecretRef(remoteRef.GetRemoteKey()) if err != nil { return err } listSecretReq := &smapi.ListSecretsRequest{ ProjectID: &c.projectID, Page: scw.Int32Ptr(1), PageSize: scw.Uint32Ptr(1), } switch scwRef.RefType { case refTypeName: listSecretReq.Name = &scwRef.Value case refTypePath: name, path, ok := splitNameAndPath(scwRef.Value) if !ok { return errors.New("ref is not a path") } listSecretReq.Name = &name listSecretReq.Path = &path default: return errors.New("secrets can only be deleted by name or path") } listSecrets, err := c.api.ListSecrets(listSecretReq, scw.WithContext(ctx)) if err != nil { return err } if len(listSecrets.Secrets) == 0 { return nil } request := smapi.DeleteSecretRequest{ SecretID: listSecrets.Secrets[0].ID, } err = c.api.DeleteSecret(&request, scw.WithContext(ctx)) if err != nil { return err } return nil } func (c *client) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) { return false, errors.New("not implemented") } func (c *client) Validate() (esv1.ValidationResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := c.api.ListSecrets(&smapi.ListSecretsRequest{ ProjectID: &c.projectID, Page: scw.Int32Ptr(1), PageSize: scw.Uint32Ptr(0), }, scw.WithContext(ctx)) if err != nil { return esv1.ValidationResultError, nil } return esv1.ValidationResultReady, nil } func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) { rawData, err := c.GetSecret(ctx, ref) if err != nil { return nil, err } structuredData := make(map[string]json.RawMessage) err = json.Unmarshal(rawData, &structuredData) if err != nil { // Do not return the raw error as json.Unmarshal errors may contain // sensitive secret data in the error message return nil, errors.New("failed to unmarshal secret: invalid JSON format") } values := make(map[string][]byte) for key, value := range structuredData { values[key] = jsonToSecretData(value) } return values, nil } // GetAllSecrets lists secrets matching the given criteria and return their latest versions. func (c *client) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) { request := smapi.ListSecretsRequest{ ProjectID: &c.projectID, Page: scw.Int32Ptr(1), PageSize: scw.Uint32Ptr(50), } if ref.Path != nil { request.Path = ref.Path } var nameMatcher *find.Matcher if ref.Name != nil { var err error nameMatcher, err = find.New(*ref.Name) if err != nil { return nil, err } } for tag := range ref.Tags { request.Tags = append(request.Tags, tag) } results := map[string][]byte{} for done := false; !done; { response, err := c.api.ListSecrets(&request, scw.WithContext(ctx)) if err != nil { return nil, err } totalFetched := c.safeConvertInt32(request.Page)*uint64(*request.PageSize) + uint64(len(response.Secrets)) done = totalFetched == response.TotalCount *request.Page++ for _, secret := range response.Secrets { if nameMatcher != nil && !nameMatcher.MatchName(secret.Name) { continue } accessReq := smapi.AccessSecretVersionRequest{ Region: secret.Region, SecretID: secret.ID, Revision: "latest_enabled", } accessResp, err := c.api.AccessSecretVersion(&accessReq, scw.WithContext(ctx)) if err != nil { log.Error(err, "failed to access secret") continue } results[secret.Name] = accessResp.Data } } return results, nil } func (c *client) safeConvertInt32(page *int32) uint64 { if *page-1 < 0 { return 0 } return uint64(*page - 1) } func (c *client) Close(context.Context) error { return nil } func (c *client) accessSecretVersion(ctx context.Context, secretRef *scwSecretRef, versionSpec string) ([]byte, error) { // if we have a secret id and a revision number, we can avoid an extra GetSecret() if secretRef.RefType == refTypeID && versionSpec != "" && '0' <= versionSpec[0] && versionSpec[0] <= '9' { secretID := secretRef.Value revision, err := strconv.ParseUint(versionSpec, 10, 32) if err == nil { return c.accessSpecificSecretVersion(ctx, secretID, uint32(revision)) } } // otherwise, we do a GetSecret() first to avoid transferring the secret value if it is cached request := &smapi.ListSecretsRequest{ ProjectID: &c.projectID, Page: scw.Int32Ptr(1), PageSize: scw.Uint32Ptr(1), } switch secretRef.RefType { case refTypeID: request := smapi.GetSecretVersionRequest{ SecretID: secretRef.Value, Revision: versionSpec, } response, err := c.api.GetSecretVersion(&request, scw.WithContext(ctx)) if err != nil { return nil, err } return c.accessSpecificSecretVersion(ctx, response.SecretID, response.Revision) case refTypeName: request.Name = &secretRef.Value case refTypePath: name, path, ok := splitNameAndPath(secretRef.Value) if !ok { return nil, errors.New("ref is not a path") } request.Name = &name request.Path = &path default: return nil, fmt.Errorf("invalid secret reference: %q", secretRef.Value) } response, err := c.api.ListSecrets(request, scw.WithContext(ctx)) if err != nil { return nil, err } if len(response.Secrets) == 0 { return nil, errNoSecretForName } secretID := response.Secrets[0].ID secretVersion, err := c.api.GetSecretVersion(&smapi.GetSecretVersionRequest{ SecretID: secretID, Revision: versionSpec, }, scw.WithContext(ctx)) if err != nil { return nil, err } return c.accessSpecificSecretVersion(ctx, secretID, secretVersion.Revision) } func (c *client) accessSpecificSecretVersion(ctx context.Context, secretID string, revision uint32) ([]byte, error) { cachedValue, cacheHit := c.cache.Get(secretID, revision) if cacheHit { return cachedValue, nil } request := smapi.AccessSecretVersionRequest{ SecretID: secretID, Revision: fmt.Sprintf("%d", revision), } response, err := c.api.AccessSecretVersion(&request, scw.WithContext(ctx)) if err != nil { return nil, err } return response.Data, nil } func jsonToSecretData(value json.RawMessage) []byte { var stringValue string err := json.Unmarshal(value, &stringValue) if err == nil { return []byte(stringValue) } return []byte(strings.TrimSpace(string(value))) } func extractJSONProperty(secretData []byte, property string) ([]byte, error) { result := gjson.Get(string(secretData), property) if !result.Exists() { return nil, esv1.NoSecretError{} } return jsonToSecretData(json.RawMessage(result.Raw)), nil } func splitNameAndPath(ref string) (name, path string, ok bool) { if !strings.HasPrefix(ref, "/") { return } s := strings.Split(ref, "/") name = s[len(s)-1] if len(s) == 2 { path = "/" } else { path = strings.Join(s[:len(s)-1], "/") } ok = true return }