client.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  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 scaleway
  14. import (
  15. "bytes"
  16. "context"
  17. "encoding/json"
  18. "errors"
  19. "fmt"
  20. "strconv"
  21. "strings"
  22. "time"
  23. smapi "github.com/scaleway/scaleway-sdk-go/api/secret/v1beta1"
  24. "github.com/scaleway/scaleway-sdk-go/scw"
  25. "github.com/tidwall/gjson"
  26. corev1 "k8s.io/api/core/v1"
  27. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  28. "github.com/external-secrets/external-secrets/runtime/find"
  29. )
  30. var errNoSecretForName = errors.New("no secret for this name")
  31. type client struct {
  32. api secretAPI
  33. projectID string
  34. cache cache
  35. }
  36. const (
  37. refTypeName = "name"
  38. refTypeID = "id"
  39. refTypePath = "path"
  40. )
  41. type scwSecretRef struct {
  42. RefType string
  43. Value string
  44. }
  45. func (r scwSecretRef) String() string {
  46. return fmt.Sprintf("%s:%s", r.RefType, r.Value)
  47. }
  48. func decodeScwSecretRef(key string) (*scwSecretRef, error) {
  49. sepIndex := strings.IndexRune(key, ':')
  50. if sepIndex < 0 {
  51. return nil, errors.New("invalid secret reference: missing colon ':'")
  52. }
  53. return &scwSecretRef{
  54. RefType: key[:sepIndex],
  55. Value: key[sepIndex+1:],
  56. }, nil
  57. }
  58. func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
  59. scwRef, err := decodeScwSecretRef(ref.Key)
  60. if err != nil {
  61. return nil, err
  62. }
  63. versionSpec := "latest_enabled"
  64. if ref.Version != "" {
  65. versionSpec = ref.Version
  66. }
  67. value, err := c.accessSecretVersion(ctx, scwRef, versionSpec)
  68. if err != nil {
  69. //nolint:errorlint
  70. if _, isNotFoundErr := err.(*scw.ResourceNotFoundError); isNotFoundErr {
  71. return nil, esv1.NoSecretError{}
  72. } else if errors.Is(err, errNoSecretForName) {
  73. return nil, esv1.NoSecretError{}
  74. }
  75. return nil, err
  76. }
  77. if ref.Property != "" {
  78. extracted, err := extractJSONProperty(value, ref.Property)
  79. if err != nil {
  80. return nil, err
  81. }
  82. value = extracted
  83. }
  84. return value, nil
  85. }
  86. func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
  87. if data.GetSecretKey() == "" {
  88. return errors.New("pushing the whole secret is not yet implemented")
  89. }
  90. value := secret.Data[data.GetSecretKey()]
  91. scwRef, err := decodeScwSecretRef(data.GetRemoteKey())
  92. if err != nil {
  93. return err
  94. }
  95. listSecretReq := &smapi.ListSecretsRequest{
  96. ProjectID: &c.projectID,
  97. Page: scw.Int32Ptr(1),
  98. PageSize: scw.Uint32Ptr(1),
  99. }
  100. var secretName string
  101. secretPath := "/"
  102. switch scwRef.RefType {
  103. case refTypeName:
  104. listSecretReq.Name = &scwRef.Value
  105. secretName = scwRef.Value
  106. case refTypePath:
  107. name, path, ok := splitNameAndPath(scwRef.Value)
  108. if !ok {
  109. return errors.New("ref is not a path")
  110. }
  111. listSecretReq.Name = &name
  112. listSecretReq.Path = &path
  113. secretName = name
  114. secretPath = path
  115. default:
  116. return errors.New("secrets can only be pushed by name or path")
  117. }
  118. var secretID string
  119. existingSecretVersion := int64(-1)
  120. // list secret by ref
  121. listSecrets, err := c.api.ListSecrets(listSecretReq, scw.WithContext(ctx))
  122. if err != nil {
  123. return err
  124. }
  125. // secret exists
  126. if len(listSecrets.Secrets) > 0 {
  127. secretID = listSecrets.Secrets[0].ID
  128. // get the latest version
  129. secretVersion, err := c.api.GetSecretVersion(&smapi.GetSecretVersionRequest{
  130. SecretID: secretID,
  131. Revision: "latest",
  132. }, scw.WithContext(ctx))
  133. if err != nil {
  134. var errNotNound *scw.ResourceNotFoundError
  135. if !errors.As(err, &errNotNound) {
  136. return err
  137. }
  138. } else {
  139. existingSecretVersion = int64(secretVersion.Revision)
  140. }
  141. if existingSecretVersion != -1 {
  142. data, err := c.accessSpecificSecretVersion(ctx, secretID, secretVersion.Revision)
  143. if err != nil {
  144. return err
  145. }
  146. if bytes.Equal(data, value) {
  147. // No change to push.
  148. return nil
  149. }
  150. }
  151. } else {
  152. secret, err := c.api.CreateSecret(&smapi.CreateSecretRequest{
  153. ProjectID: c.projectID,
  154. Name: secretName,
  155. Path: &secretPath,
  156. }, scw.WithContext(ctx))
  157. if err != nil {
  158. return err
  159. }
  160. secretID = secret.ID
  161. }
  162. // Finally, we push the new secret version.
  163. createSecretVersionRequest := smapi.CreateSecretVersionRequest{
  164. SecretID: secretID,
  165. Data: value,
  166. }
  167. createSecretVersionResponse, err := c.api.CreateSecretVersion(&createSecretVersionRequest, scw.WithContext(ctx))
  168. if err != nil {
  169. return err
  170. }
  171. c.cache.Put(secretID, createSecretVersionResponse.Revision, value)
  172. if existingSecretVersion != -1 {
  173. _, err := c.api.DisableSecretVersion(&smapi.DisableSecretVersionRequest{
  174. SecretID: secretID,
  175. Revision: fmt.Sprintf("%d", existingSecretVersion),
  176. })
  177. if err != nil {
  178. return err
  179. }
  180. }
  181. return nil
  182. }
  183. func (c *client) DeleteSecret(ctx context.Context, remoteRef esv1.PushSecretRemoteRef) error {
  184. scwRef, err := decodeScwSecretRef(remoteRef.GetRemoteKey())
  185. if err != nil {
  186. return err
  187. }
  188. listSecretReq := &smapi.ListSecretsRequest{
  189. ProjectID: &c.projectID,
  190. Page: scw.Int32Ptr(1),
  191. PageSize: scw.Uint32Ptr(1),
  192. }
  193. switch scwRef.RefType {
  194. case refTypeName:
  195. listSecretReq.Name = &scwRef.Value
  196. case refTypePath:
  197. name, path, ok := splitNameAndPath(scwRef.Value)
  198. if !ok {
  199. return errors.New("ref is not a path")
  200. }
  201. listSecretReq.Name = &name
  202. listSecretReq.Path = &path
  203. default:
  204. return errors.New("secrets can only be deleted by name or path")
  205. }
  206. listSecrets, err := c.api.ListSecrets(listSecretReq, scw.WithContext(ctx))
  207. if err != nil {
  208. return err
  209. }
  210. if len(listSecrets.Secrets) == 0 {
  211. return nil
  212. }
  213. request := smapi.DeleteSecretRequest{
  214. SecretID: listSecrets.Secrets[0].ID,
  215. }
  216. err = c.api.DeleteSecret(&request, scw.WithContext(ctx))
  217. if err != nil {
  218. return err
  219. }
  220. return nil
  221. }
  222. func (c *client) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
  223. return false, errors.New("not implemented")
  224. }
  225. func (c *client) Validate() (esv1.ValidationResult, error) {
  226. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  227. defer cancel()
  228. _, err := c.api.ListSecrets(&smapi.ListSecretsRequest{
  229. ProjectID: &c.projectID,
  230. Page: scw.Int32Ptr(1),
  231. PageSize: scw.Uint32Ptr(0),
  232. }, scw.WithContext(ctx))
  233. if err != nil {
  234. return esv1.ValidationResultError, nil
  235. }
  236. return esv1.ValidationResultReady, nil
  237. }
  238. func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
  239. rawData, err := c.GetSecret(ctx, ref)
  240. if err != nil {
  241. return nil, err
  242. }
  243. structuredData := make(map[string]json.RawMessage)
  244. err = json.Unmarshal(rawData, &structuredData)
  245. if err != nil {
  246. return nil, err
  247. }
  248. values := make(map[string][]byte)
  249. for key, value := range structuredData {
  250. values[key] = jsonToSecretData(value)
  251. }
  252. return values, nil
  253. }
  254. // GetAllSecrets lists secrets matching the given criteria and return their latest versions.
  255. func (c *client) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) {
  256. request := smapi.ListSecretsRequest{
  257. ProjectID: &c.projectID,
  258. Page: scw.Int32Ptr(1),
  259. PageSize: scw.Uint32Ptr(50),
  260. }
  261. if ref.Path != nil {
  262. request.Path = ref.Path
  263. }
  264. var nameMatcher *find.Matcher
  265. if ref.Name != nil {
  266. var err error
  267. nameMatcher, err = find.New(*ref.Name)
  268. if err != nil {
  269. return nil, err
  270. }
  271. }
  272. for tag := range ref.Tags {
  273. request.Tags = append(request.Tags, tag)
  274. }
  275. results := map[string][]byte{}
  276. for done := false; !done; {
  277. response, err := c.api.ListSecrets(&request, scw.WithContext(ctx))
  278. if err != nil {
  279. return nil, err
  280. }
  281. totalFetched := c.safeConvertInt32(request.Page)*uint64(*request.PageSize) + uint64(len(response.Secrets))
  282. done = totalFetched == response.TotalCount
  283. *request.Page++
  284. for _, secret := range response.Secrets {
  285. if nameMatcher != nil && !nameMatcher.MatchName(secret.Name) {
  286. continue
  287. }
  288. accessReq := smapi.AccessSecretVersionRequest{
  289. Region: secret.Region,
  290. SecretID: secret.ID,
  291. Revision: "latest_enabled",
  292. }
  293. accessResp, err := c.api.AccessSecretVersion(&accessReq, scw.WithContext(ctx))
  294. if err != nil {
  295. log.Error(err, "failed to access secret")
  296. continue
  297. }
  298. results[secret.Name] = accessResp.Data
  299. }
  300. }
  301. return results, nil
  302. }
  303. func (c *client) safeConvertInt32(page *int32) uint64 {
  304. if *page-1 < 0 {
  305. return 0
  306. }
  307. return uint64(*page - 1) //nolint:gosec // already checked above
  308. }
  309. func (c *client) Close(context.Context) error {
  310. return nil
  311. }
  312. func (c *client) accessSecretVersion(ctx context.Context, secretRef *scwSecretRef, versionSpec string) ([]byte, error) {
  313. // if we have a secret id and a revision number, we can avoid an extra GetSecret()
  314. if secretRef.RefType == refTypeID && versionSpec != "" && '0' <= versionSpec[0] && versionSpec[0] <= '9' {
  315. secretID := secretRef.Value
  316. revision, err := strconv.ParseUint(versionSpec, 10, 32)
  317. if err == nil {
  318. return c.accessSpecificSecretVersion(ctx, secretID, uint32(revision))
  319. }
  320. }
  321. // otherwise, we do a GetSecret() first to avoid transferring the secret value if it is cached
  322. request := &smapi.ListSecretsRequest{
  323. ProjectID: &c.projectID,
  324. Page: scw.Int32Ptr(1),
  325. PageSize: scw.Uint32Ptr(1),
  326. }
  327. switch secretRef.RefType {
  328. case refTypeID:
  329. request := smapi.GetSecretVersionRequest{
  330. SecretID: secretRef.Value,
  331. Revision: versionSpec,
  332. }
  333. response, err := c.api.GetSecretVersion(&request, scw.WithContext(ctx))
  334. if err != nil {
  335. return nil, err
  336. }
  337. return c.accessSpecificSecretVersion(ctx, response.SecretID, response.Revision)
  338. case refTypeName:
  339. request.Name = &secretRef.Value
  340. case refTypePath:
  341. name, path, ok := splitNameAndPath(secretRef.Value)
  342. if !ok {
  343. return nil, errors.New("ref is not a path")
  344. }
  345. request.Name = &name
  346. request.Path = &path
  347. default:
  348. return nil, fmt.Errorf("invalid secret reference: %q", secretRef.Value)
  349. }
  350. response, err := c.api.ListSecrets(request, scw.WithContext(ctx))
  351. if err != nil {
  352. return nil, err
  353. }
  354. if len(response.Secrets) == 0 {
  355. return nil, errNoSecretForName
  356. }
  357. secretID := response.Secrets[0].ID
  358. secretVersion, err := c.api.GetSecretVersion(&smapi.GetSecretVersionRequest{
  359. SecretID: secretID,
  360. Revision: versionSpec,
  361. }, scw.WithContext(ctx))
  362. if err != nil {
  363. return nil, err
  364. }
  365. return c.accessSpecificSecretVersion(ctx, secretID, secretVersion.Revision)
  366. }
  367. func (c *client) accessSpecificSecretVersion(ctx context.Context, secretID string, revision uint32) ([]byte, error) {
  368. cachedValue, cacheHit := c.cache.Get(secretID, revision)
  369. if cacheHit {
  370. return cachedValue, nil
  371. }
  372. request := smapi.AccessSecretVersionRequest{
  373. SecretID: secretID,
  374. Revision: fmt.Sprintf("%d", revision),
  375. }
  376. response, err := c.api.AccessSecretVersion(&request, scw.WithContext(ctx))
  377. if err != nil {
  378. return nil, err
  379. }
  380. return response.Data, nil
  381. }
  382. func jsonToSecretData(value json.RawMessage) []byte {
  383. var stringValue string
  384. err := json.Unmarshal(value, &stringValue)
  385. if err == nil {
  386. return []byte(stringValue)
  387. }
  388. return []byte(strings.TrimSpace(string(value)))
  389. }
  390. func extractJSONProperty(secretData []byte, property string) ([]byte, error) {
  391. result := gjson.Get(string(secretData), property)
  392. if !result.Exists() {
  393. return nil, esv1.NoSecretError{}
  394. }
  395. return jsonToSecretData(json.RawMessage(result.Raw)), nil
  396. }
  397. func splitNameAndPath(ref string) (name, path string, ok bool) {
  398. if !strings.HasPrefix(ref, "/") {
  399. return
  400. }
  401. s := strings.Split(ref, "/")
  402. name = s[len(s)-1]
  403. if len(s) == 2 {
  404. path = "/"
  405. } else {
  406. path = strings.Join(s[:len(s)-1], "/")
  407. }
  408. ok = true
  409. return
  410. }