gitlab.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /*
  2. Copyright © The ESO Authors
  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 provides a generator for GitLab project and group deploy tokens.
  14. package gitlab
  15. import (
  16. "bytes"
  17. "context"
  18. "encoding/json"
  19. "errors"
  20. "fmt"
  21. "io"
  22. "net/http"
  23. "net/url"
  24. "strconv"
  25. "strings"
  26. "time"
  27. apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  28. "sigs.k8s.io/controller-runtime/pkg/client"
  29. "sigs.k8s.io/yaml"
  30. genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
  31. "github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
  32. )
  33. const (
  34. defaultGitlabAPI = "https://gitlab.com"
  35. apiPath = "/api/v4"
  36. errNoSpec = "no config spec provided"
  37. errParseSpec = "unable to parse spec: %w"
  38. // requestTimeout bounds a single Generate or Cleanup. Each makes exactly one
  39. // HTTP call, governed by this context deadline; the default client uses the
  40. // same value so a shorter transport timeout cannot preempt it and abandon an
  41. // in-flight create (which would orphan a deploy token GitLab already minted).
  42. requestTimeout = 30 * time.Second
  43. )
  44. // Generator implements GitLab deploy token generation.
  45. type Generator struct {
  46. httpClient *http.Client
  47. }
  48. // deployTokenState is persisted as the generator state so that Cleanup can revoke
  49. // the deploy token that Generate created. GitLab deploy tokens are persistent, so
  50. // without revoking them every refresh would leave a dangling token behind.
  51. type deployTokenState struct {
  52. URL string `json:"url"`
  53. ProjectID string `json:"projectID,omitempty"`
  54. GroupID string `json:"groupID,omitempty"`
  55. TokenID int `json:"tokenID"`
  56. }
  57. // createTokenResponse mirrors the fields returned by the GitLab deploy token API.
  58. type createTokenResponse struct {
  59. ID int `json:"id"`
  60. Name string `json:"name"`
  61. Username string `json:"username"`
  62. Token string `json:"token"`
  63. }
  64. // Generate creates a new GitLab deploy token and returns its username and token.
  65. func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
  66. return g.generate(ctx, jsonSpec, kube, namespace)
  67. }
  68. // Cleanup revokes the deploy token created during Generate. It is idempotent: a
  69. // token that has already been deleted (HTTP 404) is treated as success.
  70. func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, kube client.Client, namespace string) error {
  71. return g.cleanup(ctx, jsonSpec, state, kube, namespace)
  72. }
  73. func (g *Generator) generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
  74. if jsonSpec == nil {
  75. return nil, nil, errors.New(errNoSpec)
  76. }
  77. ctx, cancel := context.WithTimeout(ctx, requestTimeout)
  78. defer cancel()
  79. spec, err := parseSpec(jsonSpec.Raw)
  80. if err != nil {
  81. return nil, nil, fmt.Errorf(errParseSpec, err)
  82. }
  83. token, err := g.fetchAuthToken(ctx, kube, namespace, &spec.Spec)
  84. if err != nil {
  85. return nil, nil, err
  86. }
  87. payload := map[string]any{
  88. "name": spec.Spec.Name,
  89. "scopes": spec.Spec.Scopes,
  90. }
  91. if spec.Spec.Username != "" {
  92. payload["username"] = spec.Spec.Username
  93. }
  94. if spec.Spec.ExpiresAt != nil {
  95. payload["expires_at"] = spec.Spec.ExpiresAt.UTC().Format(time.RFC3339)
  96. }
  97. body, err := json.Marshal(payload)
  98. if err != nil {
  99. return nil, nil, fmt.Errorf("error marshaling payload: %w", err)
  100. }
  101. endpoint, err := deployTokensURL(&spec.Spec)
  102. if err != nil {
  103. return nil, nil, err
  104. }
  105. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
  106. if err != nil {
  107. return nil, nil, fmt.Errorf("error creating request: %w", err)
  108. }
  109. req.Header.Set("PRIVATE-TOKEN", token)
  110. req.Header.Set("Content-Type", "application/json")
  111. resp, err := g.client().Do(req)
  112. if err != nil {
  113. return nil, nil, fmt.Errorf("error performing request: %w", err)
  114. }
  115. defer func() { _ = resp.Body.Close() }()
  116. raw, err := io.ReadAll(resp.Body)
  117. if err != nil {
  118. return nil, nil, fmt.Errorf("error reading response: %w", err)
  119. }
  120. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  121. return nil, nil, fmt.Errorf("error generating deploy token: response code: %d, response: %s", resp.StatusCode, gitlabError(raw))
  122. }
  123. var parsed createTokenResponse
  124. if err := json.Unmarshal(raw, &parsed); err != nil {
  125. return nil, nil, fmt.Errorf("error decoding response: %w", err)
  126. }
  127. if parsed.Token == "" {
  128. return nil, nil, errors.New("deploy token missing from GitLab response")
  129. }
  130. state, err := json.Marshal(deployTokenState{
  131. URL: spec.Spec.URL,
  132. ProjectID: spec.Spec.ProjectID,
  133. GroupID: spec.Spec.GroupID,
  134. TokenID: parsed.ID,
  135. })
  136. if err != nil {
  137. return nil, nil, fmt.Errorf("error marshaling state: %w", err)
  138. }
  139. return map[string][]byte{
  140. "username": []byte(parsed.Username),
  141. "token": []byte(parsed.Token),
  142. }, &apiextensions.JSON{Raw: state}, nil
  143. }
  144. func (g *Generator) cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, rawState genv1alpha1.GeneratorProviderState, kube client.Client, namespace string) error {
  145. if jsonSpec == nil || rawState == nil {
  146. return nil
  147. }
  148. ctx, cancel := context.WithTimeout(ctx, requestTimeout)
  149. defer cancel()
  150. spec, err := parseSpec(jsonSpec.Raw)
  151. if err != nil {
  152. return fmt.Errorf(errParseSpec, err)
  153. }
  154. var state deployTokenState
  155. if err := json.Unmarshal(rawState.Raw, &state); err != nil {
  156. return fmt.Errorf("error parsing generator state: %w", err)
  157. }
  158. if state.TokenID == 0 {
  159. return nil
  160. }
  161. authToken, err := g.fetchAuthToken(ctx, kube, namespace, &spec.Spec)
  162. if err != nil {
  163. return err
  164. }
  165. // Build the revoke endpoint from the persisted state, not the (possibly
  166. // changed) current spec, so cleanup always targets where the token was made.
  167. base, err := deployTokensURL(&genv1alpha1.GitlabDeployTokenSpec{
  168. URL: state.URL,
  169. ProjectID: state.ProjectID,
  170. GroupID: state.GroupID,
  171. })
  172. if err != nil {
  173. return err
  174. }
  175. endpoint := base + "/" + strconv.Itoa(state.TokenID)
  176. req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, http.NoBody)
  177. if err != nil {
  178. return fmt.Errorf("error creating request: %w", err)
  179. }
  180. req.Header.Set("PRIVATE-TOKEN", authToken)
  181. resp, err := g.client().Do(req)
  182. if err != nil {
  183. return fmt.Errorf("error performing request: %w", err)
  184. }
  185. defer func() { _ = resp.Body.Close() }()
  186. // 204 No Content on success; 404 means the token is already gone.
  187. if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
  188. return nil
  189. }
  190. raw, _ := io.ReadAll(resp.Body)
  191. return fmt.Errorf("error revoking deploy token: response code: %d, response: %s", resp.StatusCode, gitlabError(raw))
  192. }
  193. func (g *Generator) client() *http.Client {
  194. if g.httpClient != nil {
  195. return g.httpClient
  196. }
  197. return &http.Client{Timeout: requestTimeout}
  198. }
  199. func (g *Generator) fetchAuthToken(ctx context.Context, kube client.Client, namespace string, spec *genv1alpha1.GitlabDeployTokenSpec) (string, error) {
  200. token, err := resolvers.SecretKeyRef(ctx, kube, resolvers.EmptyStoreKind, namespace, &spec.Auth.Token.SecretRef)
  201. if err != nil {
  202. return "", fmt.Errorf("error getting GitLab token from secret: %w", err)
  203. }
  204. return token, nil
  205. }
  206. // deployTokensURL builds the deploy-tokens collection URL for the configured
  207. // project or group. Exactly one of projectID / groupID must be set.
  208. func deployTokensURL(spec *genv1alpha1.GitlabDeployTokenSpec) (string, error) {
  209. base := spec.URL
  210. if base == "" {
  211. base = defaultGitlabAPI
  212. }
  213. // Trim trailing slashes so a user-supplied url such as
  214. // "https://gitlab.example.com/" does not yield a double slash before the
  215. // API path.
  216. base = strings.TrimRight(base, "/")
  217. switch {
  218. case spec.ProjectID != "" && spec.GroupID == "":
  219. return base + apiPath + "/projects/" + url.PathEscape(spec.ProjectID) + "/deploy_tokens", nil
  220. case spec.GroupID != "" && spec.ProjectID == "":
  221. return base + apiPath + "/groups/" + url.PathEscape(spec.GroupID) + "/deploy_tokens", nil
  222. default:
  223. return "", errors.New("exactly one of projectID or groupID must be set")
  224. }
  225. }
  226. // gitlabError extracts a human-readable message from a GitLab error body, which
  227. // uses either a "message" or an "error" field.
  228. func gitlabError(raw []byte) string {
  229. var body map[string]any
  230. if err := json.Unmarshal(raw, &body); err == nil {
  231. if msg, ok := body["message"]; ok {
  232. return fmt.Sprintf("%v", msg)
  233. }
  234. if msg, ok := body["error"]; ok {
  235. return fmt.Sprintf("%v", msg)
  236. }
  237. }
  238. return string(raw)
  239. }
  240. func parseSpec(data []byte) (*genv1alpha1.GitlabDeployToken, error) {
  241. var spec genv1alpha1.GitlabDeployToken
  242. err := yaml.Unmarshal(data, &spec)
  243. return &spec, err
  244. }
  245. // NewGenerator creates a new Generator instance.
  246. func NewGenerator() genv1alpha1.Generator {
  247. return &Generator{}
  248. }
  249. // Kind returns the generator kind.
  250. func Kind() string {
  251. return string(genv1alpha1.GeneratorKindGitlabDeployToken)
  252. }