gitlab.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 gitlab
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "net/http"
  20. "sort"
  21. "strconv"
  22. "strings"
  23. "github.com/tidwall/gjson"
  24. gitlab "gitlab.com/gitlab-org/api/client-go"
  25. corev1 "k8s.io/api/core/v1"
  26. ctrl "sigs.k8s.io/controller-runtime"
  27. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  28. "github.com/external-secrets/external-secrets/pkg/constants"
  29. "github.com/external-secrets/external-secrets/pkg/find"
  30. "github.com/external-secrets/external-secrets/pkg/metrics"
  31. "github.com/external-secrets/external-secrets/pkg/utils"
  32. "github.com/external-secrets/external-secrets/pkg/utils/resolvers"
  33. )
  34. const (
  35. errList = "could not verify whether the gitlabClient is valid: %w"
  36. errProjectAuth = "gitlabClient is not allowed to get secrets for project id [%s]"
  37. errGroupAuth = "gitlabClient is not allowed to get secrets for group id [%s]"
  38. errUninitializedGitlabProvider = "provider gitlab is not initialized"
  39. errNameNotDefined = "'find.name' is mandatory"
  40. errEnvironmentIsConstricted = "'find.tags' is constrained by 'environment_scope' of the store"
  41. errTagsOnlyEnvironmentSupported = "'find.tags' only supports 'environment_scope'"
  42. errPathNotImplemented = "'find.path' is not implemented in the GitLab provider"
  43. errJSONSecretUnmarshal = "unable to unmarshal secret from JSON: %w"
  44. errNotImplemented = "not implemented"
  45. )
  46. // https://github.com/external-secrets/external-secrets/issues/644
  47. var _ esv1.SecretsClient = &gitlabBase{}
  48. var _ esv1.Provider = &Provider{}
  49. type ProjectsClient interface {
  50. ListProjectsGroups(pid any, opt *gitlab.ListProjectGroupOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectGroup, *gitlab.Response, error)
  51. }
  52. type ProjectVariablesClient interface {
  53. GetVariable(pid any, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
  54. ListVariables(pid any, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error)
  55. }
  56. type GroupVariablesClient interface {
  57. GetVariable(gid any, key string, opts *gitlab.GetGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
  58. ListVariables(gid any, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
  59. }
  60. type ProjectGroupPathSorter []*gitlab.ProjectGroup
  61. func (a ProjectGroupPathSorter) Len() int { return len(a) }
  62. func (a ProjectGroupPathSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
  63. func (a ProjectGroupPathSorter) Less(i, j int) bool { return len(a[i].FullPath) < len(a[j].FullPath) }
  64. var log = ctrl.Log.WithName("provider").WithName("gitlab")
  65. // Set gitlabBase credentials to Access Token.
  66. func (g *gitlabBase) getAuth(ctx context.Context) (string, error) {
  67. return resolvers.SecretKeyRef(
  68. ctx,
  69. g.kube,
  70. g.storeKind,
  71. g.namespace,
  72. &g.store.Auth.SecretRef.AccessToken)
  73. }
  74. func (g *gitlabBase) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
  75. return errors.New(errNotImplemented)
  76. }
  77. func (g *gitlabBase) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
  78. return false, errors.New(errNotImplemented)
  79. }
  80. func (g *gitlabBase) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
  81. return errors.New(errNotImplemented)
  82. }
  83. // GetAllSecrets syncs all gitlab project and group variables into a single Kubernetes Secret.
  84. func (g *gitlabBase) GetAllSecrets(_ context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) {
  85. if utils.IsNil(g.projectVariablesClient) {
  86. return nil, errors.New(errUninitializedGitlabProvider)
  87. }
  88. var effectiveEnvironment = g.store.Environment
  89. if ref.Tags != nil {
  90. environment, err := ExtractTag(ref.Tags)
  91. if err != nil {
  92. return nil, err
  93. }
  94. if !isEmptyOrWildcard(effectiveEnvironment) && !isEmptyOrWildcard(environment) {
  95. return nil, errors.New(errEnvironmentIsConstricted)
  96. }
  97. effectiveEnvironment = environment
  98. }
  99. if ref.Path != nil {
  100. return nil, errors.New(errPathNotImplemented)
  101. }
  102. if ref.Name == nil {
  103. return nil, errors.New(errNameNotDefined)
  104. }
  105. var matcher *find.Matcher
  106. if ref.Name != nil {
  107. m, err := find.New(*ref.Name)
  108. if err != nil {
  109. return nil, err
  110. }
  111. matcher = m
  112. }
  113. err := g.ResolveGroupIds()
  114. if err != nil {
  115. return nil, err
  116. }
  117. secretData, err := g.fetchSecretData(effectiveEnvironment, matcher)
  118. if err != nil {
  119. return nil, err
  120. }
  121. // _Note_: fetchProjectVariables alters secret data map
  122. if err := g.fetchProjectVariables(effectiveEnvironment, matcher, secretData); err != nil {
  123. return nil, err
  124. }
  125. return secretData, nil
  126. }
  127. func (g *gitlabBase) fetchProjectVariables(effectiveEnvironment string, matcher *find.Matcher, secretData map[string][]byte) error {
  128. var popts = &gitlab.ListProjectVariablesOptions{PerPage: 100}
  129. nonWildcardSet := make(map[string]bool)
  130. for projectPage := 1; ; projectPage++ {
  131. popts.Page = projectPage
  132. projectData, response, err := g.projectVariablesClient.ListVariables(g.store.ProjectID, popts)
  133. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabProjectListVariables, err)
  134. if err != nil {
  135. return err
  136. }
  137. processProjectVariables(projectData, effectiveEnvironment, matcher, secretData, nonWildcardSet)
  138. if response.CurrentPage >= response.TotalPages {
  139. break
  140. }
  141. }
  142. return nil
  143. }
  144. func processProjectVariables(
  145. projectData []*gitlab.ProjectVariable,
  146. effectiveEnvironment string,
  147. matcher *find.Matcher,
  148. secretData map[string][]byte,
  149. nonWildcardSet map[string]bool,
  150. ) {
  151. for _, data := range projectData {
  152. matching, key, isWildcard := matchesFilter(effectiveEnvironment, data.EnvironmentScope, data.Key, matcher)
  153. if !matching {
  154. continue
  155. }
  156. if isWildcard && nonWildcardSet[key] {
  157. continue
  158. }
  159. secretData[key] = []byte(data.Value)
  160. if !isWildcard {
  161. nonWildcardSet[key] = true
  162. }
  163. }
  164. }
  165. func (g *gitlabBase) fetchSecretData(effectiveEnvironment string, matcher *find.Matcher) (map[string][]byte, error) {
  166. var gopts = &gitlab.ListGroupVariablesOptions{PerPage: 100}
  167. secretData := make(map[string][]byte)
  168. for _, groupID := range g.store.GroupIDs {
  169. if err := g.setVariablesForGroupID(effectiveEnvironment, matcher, gopts, groupID, secretData); err != nil {
  170. return nil, err
  171. }
  172. }
  173. return secretData, nil
  174. }
  175. func (g *gitlabBase) setVariablesForGroupID(
  176. effectiveEnvironment string,
  177. matcher *find.Matcher,
  178. gopts *gitlab.ListGroupVariablesOptions,
  179. groupID string,
  180. secretData map[string][]byte,
  181. ) error {
  182. for groupPage := 1; ; groupPage++ {
  183. gopts.Page = groupPage
  184. groupVars, response, err := g.groupVariablesClient.ListVariables(groupID, gopts)
  185. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabGroupListVariables, err)
  186. if err != nil {
  187. return err
  188. }
  189. g.setGroupValues(effectiveEnvironment, matcher, groupVars, secretData)
  190. if response.CurrentPage >= response.TotalPages {
  191. break
  192. }
  193. }
  194. return nil
  195. }
  196. func (g *gitlabBase) setGroupValues(
  197. effectiveEnvironment string,
  198. matcher *find.Matcher,
  199. groupVars []*gitlab.GroupVariable,
  200. secretData map[string][]byte,
  201. ) {
  202. for _, data := range groupVars {
  203. matching, key, isWildcard := matchesFilter(effectiveEnvironment, data.EnvironmentScope, data.Key, matcher)
  204. if !matching {
  205. continue
  206. }
  207. // Check if a more specific variable already exists (project environment > project variable > group environment > group variable)
  208. _, exists := secretData[key]
  209. if exists && isWildcard {
  210. continue
  211. }
  212. secretData[key] = []byte(data.Value)
  213. }
  214. }
  215. func ExtractTag(tags map[string]string) (string, error) {
  216. var environmentScope string
  217. for tag, value := range tags {
  218. if tag != "environment_scope" {
  219. return "", errors.New(errTagsOnlyEnvironmentSupported)
  220. }
  221. environmentScope = value
  222. }
  223. return environmentScope, nil
  224. }
  225. func (g *gitlabBase) getGroupVariables(groupID string, ref esv1.ExternalSecretDataRemoteRef, gopts *gitlab.GetGroupVariableOptions) (*gitlab.GroupVariable, *gitlab.Response, error) {
  226. groupVar, resp, err := g.groupVariablesClient.GetVariable(groupID, ref.Key, gopts)
  227. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabGroupGetVariable, err)
  228. if err != nil {
  229. if resp != nil && resp.StatusCode == http.StatusNotFound && !isEmptyOrWildcard(g.store.Environment) {
  230. if gopts == nil {
  231. gopts = &gitlab.GetGroupVariableOptions{}
  232. }
  233. if gopts.Filter == nil {
  234. gopts.Filter = &gitlab.VariableFilter{}
  235. }
  236. gopts.Filter.EnvironmentScope = "*"
  237. groupVar, resp, err = g.groupVariablesClient.GetVariable(groupID, ref.Key, gopts)
  238. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabGroupGetVariable, err)
  239. if err != nil || resp == nil {
  240. return nil, resp, fmt.Errorf("error getting group variable %s from GitLab: %w", ref.Key, err)
  241. }
  242. } else {
  243. return nil, resp, err
  244. }
  245. }
  246. return groupVar, resp, nil
  247. }
  248. func (g *gitlabBase) GetSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
  249. if utils.IsNil(g.projectVariablesClient) || utils.IsNil(g.groupVariablesClient) {
  250. return nil, errors.New(errUninitializedGitlabProvider)
  251. }
  252. // Need to replace hyphens with underscores to work with GitLab API
  253. ref.Key = strings.ReplaceAll(ref.Key, "-", "_")
  254. // Retrieves a gitlab variable in the form
  255. // {
  256. // "key": "TEST_VARIABLE_1",
  257. // "variable_type": "env_var",
  258. // "value": "TEST_1",
  259. // "protected": false,
  260. // "masked": true,
  261. // "environment_scope": "*"
  262. // }
  263. var gopts *gitlab.GetGroupVariableOptions
  264. var vopts *gitlab.GetProjectVariableOptions
  265. if g.store.Environment != "" {
  266. gopts = &gitlab.GetGroupVariableOptions{Filter: &gitlab.VariableFilter{EnvironmentScope: g.store.Environment}}
  267. vopts = &gitlab.GetProjectVariableOptions{Filter: &gitlab.VariableFilter{EnvironmentScope: g.store.Environment}}
  268. }
  269. // _Note_: getVariables potentially alters vopts environment variable.
  270. data, resp, err := g.getVariables(ref, vopts)
  271. if err != nil && (resp == nil || resp.StatusCode != http.StatusNotFound) {
  272. return nil, err
  273. }
  274. err = g.ResolveGroupIds()
  275. if err != nil {
  276. return nil, err
  277. }
  278. var result []byte
  279. if resp.StatusCode < 300 {
  280. result, err = extractVariable(ref, data.Value)
  281. }
  282. for i := len(g.store.GroupIDs) - 1; i >= 0; i-- {
  283. groupID := g.store.GroupIDs[i]
  284. if result != nil {
  285. return result, nil
  286. }
  287. groupVar, resp, err := g.getGroupVariables(groupID, ref, gopts)
  288. if err != nil {
  289. return nil, err
  290. }
  291. if resp != nil && resp.StatusCode < 300 {
  292. result, _ = extractVariable(ref, groupVar.Value)
  293. }
  294. }
  295. if result != nil {
  296. return result, nil
  297. }
  298. return nil, err
  299. }
  300. func extractVariable(ref esv1.ExternalSecretDataRemoteRef, value string) ([]byte, error) {
  301. if ref.Property == "" {
  302. if value != "" {
  303. return []byte(value), nil
  304. }
  305. return nil, fmt.Errorf("invalid secret received. no secret string for key: %s", ref.Key)
  306. }
  307. var payload string
  308. if value != "" {
  309. payload = value
  310. }
  311. val := gjson.Get(payload, ref.Property)
  312. if !val.Exists() {
  313. return nil, fmt.Errorf("key %s does not exist in secret %s", ref.Property, ref.Key)
  314. }
  315. return []byte(val.String()), nil
  316. }
  317. func (g *gitlabBase) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
  318. // Gets a secret as normal, expecting secret value to be a json object
  319. data, err := g.GetSecret(ctx, ref)
  320. if err != nil {
  321. return nil, fmt.Errorf("error getting secret %s: %w", ref.Key, err)
  322. }
  323. // Maps the json data to a string:string map
  324. kv := make(map[string]string)
  325. err = json.Unmarshal(data, &kv)
  326. if err != nil {
  327. return nil, fmt.Errorf(errJSONSecretUnmarshal, err)
  328. }
  329. // Converts values in K:V pairs into bytes, while leaving keys as strings
  330. secretData := make(map[string][]byte)
  331. for k, v := range kv {
  332. secretData[k] = []byte(v)
  333. }
  334. return secretData, nil
  335. }
  336. func isEmptyOrWildcard(environment string) bool {
  337. return environment == "" || environment == "*"
  338. }
  339. func matchesFilter(environment, varEnvironment, key string, matcher *find.Matcher) (bool, string, bool) {
  340. isWildcard := isEmptyOrWildcard(varEnvironment)
  341. if !isWildcard && !isEmptyOrWildcard(environment) {
  342. // as of now gitlab does not support filtering of EnvironmentScope through the api call
  343. if varEnvironment != environment {
  344. return false, "", isWildcard
  345. }
  346. }
  347. if key == "" || (matcher != nil && !matcher.MatchName(key)) {
  348. return false, "", isWildcard
  349. }
  350. return true, key, isWildcard
  351. }
  352. func (g *gitlabBase) Close(_ context.Context) error {
  353. return nil
  354. }
  355. func (g *gitlabBase) ResolveGroupIds() error {
  356. if g.store.InheritFromGroups {
  357. projectGroups, resp, err := g.projectsClient.ListProjectsGroups(g.store.ProjectID, nil)
  358. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabListProjectsGroups, err)
  359. if resp.StatusCode >= 400 && err != nil {
  360. return err
  361. }
  362. sort.Sort(ProjectGroupPathSorter(projectGroups))
  363. discoveredIds := make([]string, len(projectGroups))
  364. for i, group := range projectGroups {
  365. discoveredIds[i] = strconv.Itoa(group.ID)
  366. }
  367. g.store.GroupIDs = discoveredIds
  368. }
  369. return nil
  370. }
  371. // Validate will use the gitlab projectVariablesClient/groupVariablesClient to validate the gitlab provider using the ListVariable call to ensure get permissions without needing a specific key.
  372. func (g *gitlabBase) Validate() (esv1.ValidationResult, error) {
  373. if g.store.ProjectID != "" {
  374. _, resp, err := g.projectVariablesClient.ListVariables(g.store.ProjectID, nil)
  375. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabProjectListVariables, err)
  376. if err != nil {
  377. return esv1.ValidationResultError, fmt.Errorf(errList, err)
  378. } else if resp == nil || resp.StatusCode != http.StatusOK {
  379. return esv1.ValidationResultError, fmt.Errorf(errProjectAuth, g.store.ProjectID)
  380. }
  381. err = g.ResolveGroupIds()
  382. if err != nil {
  383. return esv1.ValidationResultError, fmt.Errorf(errList, err)
  384. }
  385. log.V(1).Info("discovered project groups", "name", g.store.GroupIDs)
  386. }
  387. if len(g.store.GroupIDs) > 0 {
  388. for _, groupID := range g.store.GroupIDs {
  389. _, resp, err := g.groupVariablesClient.ListVariables(groupID, nil)
  390. metrics.ObserveAPICall(constants.ProviderGitLab, constants.CallGitLabGroupListVariables, err)
  391. if err != nil {
  392. return esv1.ValidationResultError, fmt.Errorf(errList, err)
  393. } else if resp == nil || resp.StatusCode != http.StatusOK {
  394. return esv1.ValidationResultError, fmt.Errorf(errGroupAuth, groupID)
  395. }
  396. }
  397. }
  398. return esv1.ValidationResultReady, nil
  399. }