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