| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- /*
- 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 gitlab provides a generator for GitLab project and group deploy tokens.
- package gitlab
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
- apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/yaml"
- genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
- "github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
- )
- const (
- defaultGitlabAPI = "https://gitlab.com"
- apiPath = "/api/v4"
- errNoSpec = "no config spec provided"
- errParseSpec = "unable to parse spec: %w"
- // requestTimeout bounds a single Generate or Cleanup. Each makes exactly one
- // HTTP call, governed by this context deadline; the default client uses the
- // same value so a shorter transport timeout cannot preempt it and abandon an
- // in-flight create (which would orphan a deploy token GitLab already minted).
- requestTimeout = 30 * time.Second
- )
- // Generator implements GitLab deploy token generation.
- type Generator struct {
- httpClient *http.Client
- }
- // deployTokenState is persisted as the generator state so that Cleanup can revoke
- // the deploy token that Generate created. GitLab deploy tokens are persistent, so
- // without revoking them every refresh would leave a dangling token behind.
- type deployTokenState struct {
- URL string `json:"url"`
- ProjectID string `json:"projectID,omitempty"`
- GroupID string `json:"groupID,omitempty"`
- TokenID int `json:"tokenID"`
- }
- // createTokenResponse mirrors the fields returned by the GitLab deploy token API.
- type createTokenResponse struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Username string `json:"username"`
- Token string `json:"token"`
- }
- // Generate creates a new GitLab deploy token and returns its username and token.
- func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
- return g.generate(ctx, jsonSpec, kube, namespace)
- }
- // Cleanup revokes the deploy token created during Generate. It is idempotent: a
- // token that has already been deleted (HTTP 404) is treated as success.
- func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, kube client.Client, namespace string) error {
- return g.cleanup(ctx, jsonSpec, state, kube, namespace)
- }
- func (g *Generator) generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
- if jsonSpec == nil {
- return nil, nil, errors.New(errNoSpec)
- }
- ctx, cancel := context.WithTimeout(ctx, requestTimeout)
- defer cancel()
- spec, err := parseSpec(jsonSpec.Raw)
- if err != nil {
- return nil, nil, fmt.Errorf(errParseSpec, err)
- }
- token, err := g.fetchAuthToken(ctx, kube, namespace, &spec.Spec)
- if err != nil {
- return nil, nil, err
- }
- payload := map[string]any{
- "name": spec.Spec.Name,
- "scopes": spec.Spec.Scopes,
- }
- if spec.Spec.Username != "" {
- payload["username"] = spec.Spec.Username
- }
- if spec.Spec.ExpiresAt != nil {
- payload["expires_at"] = spec.Spec.ExpiresAt.UTC().Format(time.RFC3339)
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return nil, nil, fmt.Errorf("error marshaling payload: %w", err)
- }
- endpoint, err := deployTokensURL(&spec.Spec)
- if err != nil {
- return nil, nil, err
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil {
- return nil, nil, fmt.Errorf("error creating request: %w", err)
- }
- req.Header.Set("PRIVATE-TOKEN", token)
- req.Header.Set("Content-Type", "application/json")
- resp, err := g.client().Do(req)
- if err != nil {
- return nil, nil, fmt.Errorf("error performing request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- raw, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, nil, fmt.Errorf("error reading response: %w", err)
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, nil, fmt.Errorf("error generating deploy token: response code: %d, response: %s", resp.StatusCode, gitlabError(raw))
- }
- var parsed createTokenResponse
- if err := json.Unmarshal(raw, &parsed); err != nil {
- return nil, nil, fmt.Errorf("error decoding response: %w", err)
- }
- if parsed.Token == "" {
- return nil, nil, errors.New("deploy token missing from GitLab response")
- }
- state, err := json.Marshal(deployTokenState{
- URL: spec.Spec.URL,
- ProjectID: spec.Spec.ProjectID,
- GroupID: spec.Spec.GroupID,
- TokenID: parsed.ID,
- })
- if err != nil {
- return nil, nil, fmt.Errorf("error marshaling state: %w", err)
- }
- return map[string][]byte{
- "username": []byte(parsed.Username),
- "token": []byte(parsed.Token),
- }, &apiextensions.JSON{Raw: state}, nil
- }
- func (g *Generator) cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, rawState genv1alpha1.GeneratorProviderState, kube client.Client, namespace string) error {
- if jsonSpec == nil || rawState == nil {
- return nil
- }
- ctx, cancel := context.WithTimeout(ctx, requestTimeout)
- defer cancel()
- spec, err := parseSpec(jsonSpec.Raw)
- if err != nil {
- return fmt.Errorf(errParseSpec, err)
- }
- var state deployTokenState
- if err := json.Unmarshal(rawState.Raw, &state); err != nil {
- return fmt.Errorf("error parsing generator state: %w", err)
- }
- if state.TokenID == 0 {
- return nil
- }
- authToken, err := g.fetchAuthToken(ctx, kube, namespace, &spec.Spec)
- if err != nil {
- return err
- }
- // Build the revoke endpoint from the persisted state, not the (possibly
- // changed) current spec, so cleanup always targets where the token was made.
- base, err := deployTokensURL(&genv1alpha1.GitlabDeployTokenSpec{
- URL: state.URL,
- ProjectID: state.ProjectID,
- GroupID: state.GroupID,
- })
- if err != nil {
- return err
- }
- endpoint := base + "/" + strconv.Itoa(state.TokenID)
- req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, http.NoBody)
- if err != nil {
- return fmt.Errorf("error creating request: %w", err)
- }
- req.Header.Set("PRIVATE-TOKEN", authToken)
- resp, err := g.client().Do(req)
- if err != nil {
- return fmt.Errorf("error performing request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- // 204 No Content on success; 404 means the token is already gone.
- if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
- return nil
- }
- raw, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("error revoking deploy token: response code: %d, response: %s", resp.StatusCode, gitlabError(raw))
- }
- func (g *Generator) client() *http.Client {
- if g.httpClient != nil {
- return g.httpClient
- }
- return &http.Client{Timeout: requestTimeout}
- }
- func (g *Generator) fetchAuthToken(ctx context.Context, kube client.Client, namespace string, spec *genv1alpha1.GitlabDeployTokenSpec) (string, error) {
- token, err := resolvers.SecretKeyRef(ctx, kube, resolvers.EmptyStoreKind, namespace, &spec.Auth.Token.SecretRef)
- if err != nil {
- return "", fmt.Errorf("error getting GitLab token from secret: %w", err)
- }
- return token, nil
- }
- // deployTokensURL builds the deploy-tokens collection URL for the configured
- // project or group. Exactly one of projectID / groupID must be set.
- func deployTokensURL(spec *genv1alpha1.GitlabDeployTokenSpec) (string, error) {
- base := spec.URL
- if base == "" {
- base = defaultGitlabAPI
- }
- // Trim trailing slashes so a user-supplied url such as
- // "https://gitlab.example.com/" does not yield a double slash before the
- // API path.
- base = strings.TrimRight(base, "/")
- switch {
- case spec.ProjectID != "" && spec.GroupID == "":
- return base + apiPath + "/projects/" + url.PathEscape(spec.ProjectID) + "/deploy_tokens", nil
- case spec.GroupID != "" && spec.ProjectID == "":
- return base + apiPath + "/groups/" + url.PathEscape(spec.GroupID) + "/deploy_tokens", nil
- default:
- return "", errors.New("exactly one of projectID or groupID must be set")
- }
- }
- // gitlabError extracts a human-readable message from a GitLab error body, which
- // uses either a "message" or an "error" field.
- func gitlabError(raw []byte) string {
- var body map[string]any
- if err := json.Unmarshal(raw, &body); err == nil {
- if msg, ok := body["message"]; ok {
- return fmt.Sprintf("%v", msg)
- }
- if msg, ok := body["error"]; ok {
- return fmt.Sprintf("%v", msg)
- }
- }
- return string(raw)
- }
- func parseSpec(data []byte) (*genv1alpha1.GitlabDeployToken, error) {
- var spec genv1alpha1.GitlabDeployToken
- err := yaml.Unmarshal(data, &spec)
- return &spec, err
- }
- // NewGenerator creates a new Generator instance.
- func NewGenerator() genv1alpha1.Generator {
- return &Generator{}
- }
- // Kind returns the generator kind.
- func Kind() string {
- return string(genv1alpha1.GeneratorKindGitlabDeployToken)
- }
|