From 9a96efec806bf7bee52e406f7641e4c718ed82f3 Mon Sep 17 00:00:00 2001 From: Justine Bachelard Date: Wed, 20 Mar 2024 16:50:21 +0100 Subject: [PATCH] patch for v1.15.6 with graphql --- .gitignore | 3 + README.md | 10 +- docker/Dockerfile | 2 +- docker/configuration.json | 4 +- patch.sh | 2 +- patches/v1.15.6.patch | 1596 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1610 insertions(+), 7 deletions(-) create mode 100644 patches/v1.15.6.patch diff --git a/.gitignore b/.gitignore index e1b8fdd..7338066 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ build/ /vault.bin.tar.gz /vault/ /data/ + +## Configuration for development ## +/configuration.json \ No newline at end of file diff --git a/README.md b/README.md index eabbf42..6efb379 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Vault Patches -Project who takes [HashiCorp Vault](https://github.com/hashicorp/vault/) sources, and add Gitlab interconnexion on it. +Project which takes [HashiCorp Vault](https://github.com/hashicorp/vault/) sources, and add Gitlab interconnexion on it. + +> ***Warning*** : Before v1.15.6, policies had different name format. +> Before migration to v1.15.6, don't forget to duplicate all your policies with the new formatted names. +> After migration, you'll be able to remove old policies. ## Building the image @@ -92,9 +96,9 @@ Basically, Vault will replace all non-alphanumeric characters from the group/pro concatenate the user role after it. A few examples to fully understand it: * A user have access to `group/project1` with role `maintainer` - * The matching policy will be named `group_project1_maintainer`. + * The matching policy will be named `group:project1:maintainer`. * A user have access to `group-one/` with role `owner`, and `group-two/very/long/pa-th/project` with role `reporter` - * The matching policies will be named `group_one_owner` and `group_two_very_long_pa_th_project_reporter` + * The matching policies will be named `group_one:owner` and `group_two:very:long:pa_th:project:reporter` Every matching Vault policy will be loaded to the user token. diff --git a/docker/Dockerfile b/docker/Dockerfile index d44bd53..b7c33c1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21.5-bullseye as build +FROM golang:1.21.7-bullseye as build RUN mkdir /builddir WORKDIR /builddir diff --git a/docker/configuration.json b/docker/configuration.json index 5518f79..22b770a 100644 --- a/docker/configuration.json +++ b/docker/configuration.json @@ -7,7 +7,7 @@ }, "listener": [ { - "tcp": { + "tcp": { "address": "0.0.0.0:8200", "tls_disable": true } @@ -16,4 +16,4 @@ "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true -} +} \ No newline at end of file diff --git a/patch.sh b/patch.sh index efe459f..0054b40 100755 --- a/patch.sh +++ b/patch.sh @@ -1,7 +1,7 @@ #!/bin/sh if [ -z "$1" ]; then - echo "ERROR : You must define a variable." + echo "ERROR : You must define a version." exit 1 fi diff --git a/patches/v1.15.6.patch b/patches/v1.15.6.patch new file mode 100644 index 0000000..2545ac6 --- /dev/null +++ b/patches/v1.15.6.patch @@ -0,0 +1,1596 @@ +diff --git a/builtin/credential/gitlab/backend.go b/builtin/credential/gitlab/backend.go +new file mode 100644 +index 0000000000..77fed07c34 +--- /dev/null ++++ b/builtin/credential/gitlab/backend.go +@@ -0,0 +1,52 @@ ++package gitlab ++ ++import ( ++ "context" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ mathrand "math/rand" ++) ++ ++const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" ++ ++// Factory of gitlab backend ++func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { ++ b := Backend() ++ if err := b.Setup(ctx, conf); err != nil { ++ return nil, err ++ } ++ b.CipherKey = make([]byte, 16) ++ for i := range b.CipherKey { ++ b.CipherKey[i] = letterBytes[mathrand.Intn(len(letterBytes))] ++ } ++ return b, nil ++} ++ ++// Backend constructor ++func Backend() *backend { ++ ++ var b backend ++ ++ b.Backend = &framework.Backend{ ++ Help: backendHelp, ++ PathsSpecial: &logical.Paths{Unauthenticated: []string{"login", "login/*", "oauth", "ci"}}, ++ Paths: append([]*framework.Path{pathConfig(&b), pathLoginToken(&b), pathOauthLogin(&b), pathLoginJob(&b)}), ++ BackendType: logical.TypeCredential, ++ } ++ ++ return &b ++} ++ ++type backend struct { ++ *framework.Backend ++ CipherKey []byte ++} ++ ++const backendHelp = ` ++The Gitlab credential provider allows authentication via Gitlab. ++ ++Users provide a personal access token to log in, and the credential ++provider maps the user to a set of Vault policies according to the groups he is part of. ++After enabling the credential provider, use the "config" route to ++configure it. ++` +diff --git a/builtin/credential/gitlab/backend_test.go b/builtin/credential/gitlab/backend_test.go +new file mode 100644 +index 0000000000..99b34095dc +--- /dev/null ++++ b/builtin/credential/gitlab/backend_test.go +@@ -0,0 +1,165 @@ ++package gitlab ++ ++import ( ++ "context" ++ "os" ++ "strings" ++ "testing" ++ ++ logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical" ++ "github.com/hashicorp/vault/sdk/logical" ++) ++ ++func TestBackend_Config(t *testing.T) { ++ b, err := Factory(context.Background(), &logical.BackendConfig{ ++ Logger: nil, ++ }) ++ if err != nil { ++ t.Fatalf("Unable to create backend: %s", err) ++ } ++ ++ loginData := map[string]interface{}{ ++ // This token has to be replaced with a working token for the test to work. ++ "token": os.Getenv("GITLAB_TOKEN"), ++ } ++ configData := map[string]interface{}{ ++ "group": os.Getenv("GITLAB_GROUP"), ++ } ++ ++ logicaltest.Test(t, logicaltest.TestCase{ ++ PreCheck: func() { testAccPreCheck(t) }, ++ LogicalBackend: b, ++ Steps: []logicaltest.TestStep{ ++ testConfigWrite(t, loginData), ++ testLoginWrite(t, configData, false), ++ }, ++ }) ++} ++ ++func testLoginWrite(t *testing.T, d map[string]interface{}, expectFail bool) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "login", ++ ErrorOk: true, ++ Data: d, ++ Check: func(resp *logical.Response) error { ++ if resp.IsError() && expectFail { ++ return nil ++ } ++ return nil ++ }, ++ } ++} ++ ++func testConfigWrite(t *testing.T, d map[string]interface{}) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: d, ++ } ++} ++ ++func TestBackend_basic(t *testing.T) { ++ b, err := Factory(context.Background(), &logical.BackendConfig{ ++ Logger: nil, ++ }) ++ if err != nil { ++ t.Fatalf("Unable to create backend: %s", err) ++ } ++ ++ logicaltest.Test(t, logicaltest.TestCase{ ++ PreCheck: func() { testAccPreCheck(t) }, ++ LogicalBackend: b, ++ Steps: []logicaltest.TestStep{ ++ testAccStepConfig(t, false), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccStepConfig(t, true), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccStepConfigWithBaseURL(t), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccMap(t, "default", "fakepol"), ++ testAccStepConfig(t, true), ++ mapUserToPolicy(t, os.Getenv("GITLAB_USER"), "userpolicy"), ++ testAccLogin(t, []string{"default", "fakepol", "userpolicy"}), ++ }, ++ }) ++} ++ ++func testAccPreCheck(t *testing.T) { ++ if v := os.Getenv("GITLAB_TOKEN"); v == "" { ++ t.Skip("GITLAB_TOKEN must be set for acceptance tests") ++ } ++ ++ if v := os.Getenv("GITLAB_GROUP"); v == "" { ++ t.Skip("GITLAB_GROUP must be set for acceptance tests") ++ } ++ ++ if v := os.Getenv("GITLAB_BASEURL"); v == "" { ++ t.Skip("GITLAB_BASEURL must be set for acceptance tests (use 'https://gitlab.com/api/v4/' if you don't know what you're doing)") ++ } ++} ++ ++func testAccStepConfig(t *testing.T, upper bool) logicaltest.TestStep { ++ ts := logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: map[string]interface{}{ ++ "organization": os.Getenv("GITLAB_GROUP"), ++ }, ++ } ++ if upper { ++ ts.Data["organization"] = strings.ToUpper(os.Getenv("GITLAB_GROUP")) ++ } ++ return ts ++} ++ ++func testAccStepConfigWithBaseURL(t *testing.T) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: map[string]interface{}{ ++ "organization": os.Getenv("GITLAB_GROUP"), ++ "base_url": os.Getenv("GITLAB_BASEURL"), ++ }, ++ } ++} ++ ++func testAccMap(t *testing.T, k string, v string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "map/teams/" + k, ++ Data: map[string]interface{}{ ++ "value": v, ++ }, ++ } ++} ++ ++func mapUserToPolicy(t *testing.T, k string, v string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "map/users/" + k, ++ Data: map[string]interface{}{ ++ "value": v, ++ }, ++ } ++} ++ ++func testAccLogin(t *testing.T, policies []string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "login", ++ Data: map[string]interface{}{ ++ "token": os.Getenv("GITLAB_TOKEN"), ++ }, ++ Unauthenticated: true, ++ ++ Check: logicaltest.TestCheckAuth(policies), ++ } ++} ++ +diff --git a/builtin/credential/gitlab/cli.go b/builtin/credential/gitlab/cli.go +new file mode 100644 +index 0000000000..6f8ad6d806 +--- /dev/null ++++ b/builtin/credential/gitlab/cli.go +@@ -0,0 +1,99 @@ ++package gitlab ++ ++import ( ++"fmt" ++"io" ++"os" ++"strings" ++ ++"github.com/hashicorp/errwrap" ++"github.com/hashicorp/vault/api" ++"github.com/hashicorp/vault/sdk/helper/password" ++) ++ ++// CLIHandler structure ++type CLIHandler struct { ++ // for tests ++ testStdout io.Writer ++} ++ ++// Auth return secret token ++func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (*api.Secret, error) { ++ mount, ok := m["mount"] ++ if !ok { ++ mount = "gitlab" ++ } ++ ++ // Extract or prompt for token ++ token := m["token"] ++ if token == "" { ++ token = os.Getenv("VAULT_AUTH_GITLAB_TOKEN") ++ } ++ if token == "" { ++ // Override the output ++ stdout := h.testStdout ++ if stdout == nil { ++ stdout = os.Stderr ++ } ++ ++ var err error ++ fmt.Fprintf(stdout, "Gitlab Access Token (will be hidden): ") ++ token, err = password.Read(os.Stdin) ++ fmt.Fprintf(stdout, "\n") ++ if err != nil { ++ if err == password.ErrInterrupted { ++ return nil, fmt.Errorf("user interrupted") ++ } ++ ++ return nil, errwrap.Wrapf("An error occurred attempting to "+ ++ "ask for a token. The raw error message is shown below, but usually "+ ++ "this is because you attempted to pipe a value into the command or "+ ++ "you are executing outside of a terminal (tty). If you want to pipe "+ ++ "the value, pass \"-\" as the argument to read from stdin. The raw "+ ++ "error was: {{err}}", err) ++ } ++ } ++ ++ path := fmt.Sprintf("auth/%s/login", mount) ++ secret, err := c.Logical().Write(path, map[string]interface{}{ ++ "token": strings.TrimSpace(token), ++ }) ++ if err != nil { ++ return nil, err ++ } ++ if secret == nil { ++ return nil, fmt.Errorf("empty response from credential provider") ++ } ++ ++ return secret, nil ++} ++ ++// Help return help message ++func (h *CLIHandler) Help() string { ++ help := ` ++Usage: vault login -method=gitlab [CONFIG K=V...] ++ ++ The Gitlab auth method allows users to authenticate using a Gitlab ++ access token. Users can generate a personal access token from the ++ settings page on their Gitlab account. ++ ++ Authenticate using a Gitlab token: ++ ++ $ vault login -method=gitlab token=abcd1234 ++ ++Configuration: ++ ++ mount= ++ Path where the Gitlab credential method is mounted. This is usually ++ provided via the -path flag in the "vault login" command, but it can be ++ specified here as well. If specified here, it takes precedence over the ++ value for -path. The default value is "gitlab". ++ ++ token= ++ Gitlab access token to use for authentication. If not provided, ++ Vault will prompt for the value. ++` ++ ++ return strings.TrimSpace(help) ++} ++ +diff --git a/builtin/credential/gitlab/clients.go b/builtin/credential/gitlab/clients.go +new file mode 100644 +index 0000000000..c3212de492 +--- /dev/null ++++ b/builtin/credential/gitlab/clients.go +@@ -0,0 +1,40 @@ ++package gitlab ++ ++import ( ++ "errors" ++ "github.com/xanzy/go-gitlab" ++ "strconv" ++ "strings" ++) ++ ++func (b *backend) TokenClient(baseUrl string, token string) (*gitlab.Client, error) { ++ if strings.HasPrefix(token, "OAuth-") { ++ return gitlab.NewOAuthClient(strings.TrimPrefix(token, "OAuth-"), gitlab.WithBaseURL(baseUrl)) ++ } ++ return gitlab.NewClient(token, gitlab.WithBaseURL(baseUrl)) ++} ++ ++// -------------------------------- ++ ++func (b *backend) JobClient(baseURL, CIToken, project, job, commit, token string) (*gitlab.Client, error) { ++ client, err := gitlab.NewClient(CIToken, gitlab.WithBaseURL(baseURL)) ++ if err != nil { ++ return nil, err ++ } ++ ++ jobID, err := strconv.Atoi(job) ++ if err != nil { ++ return nil, err ++ } ++ ++ j, _, err := client.Jobs.GetJob(project, jobID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if j.Status != string(gitlab.Running) || j.Commit.ID != commit { ++ return nil, errors.New("invalid job arguments") ++ } ++ ++ return client, nil ++} +diff --git a/builtin/credential/gitlab/cmd/gitlab/main.go b/builtin/credential/gitlab/cmd/gitlab/main.go +new file mode 100644 +index 0000000000..26e3917c23 +--- /dev/null ++++ b/builtin/credential/gitlab/cmd/gitlab/main.go +@@ -0,0 +1,30 @@ ++package main ++ ++import ( ++ "github.com/hashicorp/vault/api" ++ "github.com/hashicorp/vault/builtin/credential/gitlab" ++ "os" ++ ++ hclog "github.com/hashicorp/go-hclog" ++ "github.com/hashicorp/vault/sdk/plugin" ++) ++ ++func main() { ++ apiClientMeta := &api.PluginAPIClientMeta{} ++ flags := apiClientMeta.FlagSet() ++ flags.Parse(os.Args[1:]) ++ ++ tlsConfig := apiClientMeta.GetTLSConfig() ++ tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) ++ ++ if err := plugin.Serve(&plugin.ServeOpts{ ++ BackendFactoryFunc: gitlab.Factory, ++ TLSProviderFunc: tlsProviderFunc, ++ }); err != nil { ++ logger := hclog.New(&hclog.LoggerOptions{}) ++ ++ logger.Error("plugin shutting down", "error", err) ++ os.Exit(1) ++ } ++} ++ +diff --git a/builtin/credential/gitlab/path_config.go b/builtin/credential/gitlab/path_config.go +new file mode 100644 +index 0000000000..23c5f25256 +--- /dev/null ++++ b/builtin/credential/gitlab/path_config.go +@@ -0,0 +1,194 @@ ++package gitlab ++ ++import ( ++ "context" ++ "fmt" ++ "github.com/hashicorp/errwrap" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "net/url" ++) ++ ++func pathConfig(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: "config", ++ Fields: map[string]*framework.FieldSchema{ ++ "base_url": { ++ Type: framework.TypeString, ++ Description: "The Gitlab API endpoint to use.", ++ }, ++ "min_access_level": { ++ Type: framework.TypeString, ++ Description: "The minimal project access level that users must have", ++ Default: "guest", ++ }, ++ "app_id": { ++ Type: framework.TypeString, ++ Description: "The OAuth appId", ++ Default: "", ++ }, ++ "app_secret": { ++ Type: framework.TypeString, ++ Description: "The OAuth appSecret", ++ Default: "", ++ }, ++ "callback_url": { ++ Type: framework.TypeString, ++ Description: "The Vault OAuth API endpoint to use.", ++ Default: "", ++ }, ++ "ci_token": { ++ Type: framework.TypeString, ++ Description: "The CI token API to use.", ++ Default: "", ++ }, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathConfigWrite, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ logical.ReadOperation: &framework.PathOperation{ ++ Callback: b.pathConfigRead, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ baseURL := data.Get("base_url").(string) ++ if len(baseURL) > 0 { ++ _, err := url.Parse(baseURL) ++ if err != nil { ++ return logical.ErrorResponse(fmt.Sprintf("Error parsing given base_url: %s", err)), nil ++ } ++ } ++ minAccessLevel := data.Get("min_access_level").(string) ++ appID := data.Get("app_id").(string) ++ appSecret := data.Get("app_secret").(string) ++ callbackURL := data.Get("callback_url").(string) ++ ciToken := data.Get("ci_token").(string) ++ if len(callbackURL) > 0 { ++ _, err := url.Parse(callbackURL) ++ if err != nil { ++ return logical.ErrorResponse(fmt.Sprintf("Error parsing given callback_url: %s", err)), nil ++ } ++ } ++ entry, err := logical.StorageEntryJSON("config", config{ ++ BaseURL: baseURL, ++ MinAccessLevel: minAccessLevel, ++ AppID: appID, ++ AppSecret: appSecret, ++ CallbackURL: callbackURL, ++ CIToken: ciToken, ++ }) ++ ++ if err != nil { ++ return nil, err ++ } ++ ++ if err := req.Storage.Put(ctx, entry); err != nil { ++ return nil, err ++ } ++ ++ return nil, nil ++} ++ ++func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config == nil { ++ return nil, fmt.Errorf("configuration object not found") ++ } ++ ++ resp := &logical.Response{ ++ Data: map[string]interface{}{ ++ "base_url": config.BaseURL, ++ "min_access_level": config.MinAccessLevel, ++ "app_id": config.AppID, ++ "app_secret": config.AppSecret, ++ "callback_url": config.CallbackURL, ++ "ci_token": config.CIToken, ++ }, ++ } ++ return resp, nil ++} ++ ++// Config returns the configuration for this backend. ++func (b *backend) Config(ctx context.Context, s logical.Storage) (*config, error) { ++ entry, err := s.Get(ctx, "config") ++ if err != nil { ++ return nil, err ++ } ++ ++ var result config ++ if entry != nil { ++ if err := entry.DecodeJSON(&result); err != nil { ++ return nil, errwrap.Wrapf("error reading configuration: {{err}}", err) ++ } ++ } ++ ++ return &result, nil ++} ++ ++func (b *backend) AccessLevelValue(level string) *gitlab.AccessLevelValue { ++ if level == "" { ++ return gitlab.AccessLevel(gitlab.OwnerPermission) ++ } ++ return gitlab.AccessLevel(accessLevelNameToValue[level]) ++} ++ ++// AllAccessValuesFromLevel Meaning for a owner access, we also have access to developer, guest, etc. ++func (b *backend) AllAccessValuesFromLevel(level string) []string { ++ var accessLevelValues []string ++ ++ var start = int(*b.AccessLevelValue(level)) ++ for k, v := range accessLevelNameToValue { ++ if int(v) <= start && v != 0 { // not adding "none" level access ++ accessLevelValues = append(accessLevelValues, k) ++ } ++ } ++ return accessLevelValues ++} ++ ++func (b *backend) AccessLevelValueToString(level gitlab.AccessLevelValue) string { ++ for k, v := range accessLevelNameToValue { ++ if v == level { ++ return k ++ } ++ } ++ return "none" ++} ++ ++var accessLevelNameToValue = map[string]gitlab.AccessLevelValue{ ++ "none": gitlab.NoPermissions, ++ "guest": gitlab.GuestPermissions, ++ "reporter": gitlab.ReporterPermissions, ++ "developer": gitlab.DeveloperPermissions, ++ "maintainer": gitlab.MaintainerPermissions, ++ "owner": gitlab.OwnerPermissions, ++} ++ ++type config struct { ++ BaseURL string `json:"baseURL" structs:"baseURL" mapstructure:"baseURL"` ++ MinAccessLevel string `json:"minAccessLevel" structs:"minAccessLevel" mapstructure:"minAccessLevel"` ++ AppID string `json:"appID" structs:"appID" mapstructure:"appID"` ++ AppSecret string `json:"appSecret" structs:"appSecret" mapstructure:"appSecret"` ++ CallbackURL string `json:"callbackURL" structs:"callbackURL" mapstructure:"callbackURL"` ++ CIToken string `json:"ciToken" structs:"ciToken" mapstructure:"ciToken"` ++} +diff --git a/builtin/credential/gitlab/path_login_commons.go b/builtin/credential/gitlab/path_login_commons.go +new file mode 100644 +index 0000000000..659205afd6 +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_commons.go +@@ -0,0 +1,185 @@ ++package gitlab ++ ++import ( ++ "context" ++ "fmt" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/hasura/go-graphql-client" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "strings" ++) ++ ++func (b *backend) pathLoginOk(verifyResp *verifyCredentialsResp, internalData map[string]interface{}) *logical.Response { ++ resp := &logical.Response{ ++ Auth: &logical.Auth{ ++ InternalData: internalData, ++ Metadata: map[string]string{ ++ "username": verifyResp.Username, ++ }, ++ DisplayName: verifyResp.Username, ++ LeaseOptions: logical.LeaseOptions{ ++ Renewable: false, ++ }, ++ Alias: &logical.Alias{ ++ Name: verifyResp.Username, ++ }, ++ EntityID: verifyResp.Username, ++ }, ++ } ++ ++ if verifyResp.IsAdmin { ++ if b.Logger().IsDebug() { ++ b.Logger().Debug("User " + verifyResp.Username + " is admin") ++ } ++ resp.Auth.Policies = append(resp.Auth.Policies, "admins") ++ } ++ ++ for _, name := range verifyResp.Rights { ++ resp.Auth.GroupAliases = append(resp.Auth.GroupAliases, &logical.Alias{Name: name}) ++ resp.Auth.Policies = append(resp.Auth.Policies, name) ++ } ++ ++ return resp ++} ++ ++func (b *backend) parseAdminRights(username string, isAdmin bool, access []string) (*verifyCredentialsResp, error) { ++ if isAdmin { ++ if b.Logger().IsDebug() { ++ b.Logger().Debug("User " + username + " is admin") ++ } ++ access = append(access, "admins") ++ } ++ ++ return &verifyCredentialsResp{ ++ Username: username, ++ Rights: access, ++ IsAdmin: isAdmin, ++ }, nil ++} ++ ++func (b *backend) getProjectsAndGroupsAccess(ctx context.Context, req *logical.Request, client *gitlab.Client, userToken string) (access []string, err error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ var noneAccessLevels []string ++ minAccessLevel := int(accessLevelNameToValue[config.MinAccessLevel]) ++ lastCursorProjects := "" ++ nextPageProjects := true ++ lastCursorGroups := "" ++ nextPageGroups := true ++ graphQLClient := graphql.NewClient(fmt.Sprintf("%s://%s/api/graphql", client.BaseURL().Scheme, client.BaseURL().Host), http.DefaultClient).WithRequestModifier(func(r *http.Request) { ++ r.Header.Set("Authorization", "Bearer "+userToken) ++ }) ++ ++ for nextPageProjects || nextPageGroups { ++ var query graphQLProjectsGroups ++ variables := map[string]interface{}{ ++ "afterProjects": lastCursorProjects, ++ "afterGroups": lastCursorGroups, ++ } ++ err := graphQLClient.Query(context.Background(), &query, variables) ++ if err != nil { ++ b.Logger().Error("Something went wrong while getting query " + err.Error()) ++ break ++ } ++ ++ // parse GraphQL groups and projects response ++ if nextPageProjects { ++ var tmpMaxRights []string ++ ++ nextPageProjects = query.Projects.PageInfo.HasNextPage ++ tmpMaxRights, _, lastCursorProjects = b.parseGraphQLEdgesToMaxRights(query.Projects.Edges, minAccessLevel) ++ access = append(access, tmpMaxRights...) ++ } ++ // we can only get group direct membership, so we keep "none" access groups ++ if nextPageGroups { ++ var tmpMaxRights, tmpNone []string ++ ++ nextPageGroups = query.Groups.PageInfo.HasNextPage ++ tmpMaxRights, tmpNone, lastCursorGroups = b.parseGraphQLEdgesToMaxRights(query.Groups.Edges, minAccessLevel) ++ access = append(access, tmpMaxRights...) ++ noneAccessLevels = append(noneAccessLevels, tmpNone...) ++ } ++ ++ } ++ // parse "none" access groups to their MaxAccess according to root groups ++ access = append(access, b.parseNoneAccessLevelsToMaxRights(access, noneAccessLevels)...) ++ // parse all rights access inheritance (E.g: owner = owner + maintainer + dev + guest) ++ access = b.parseMaxRightsToRights(access) ++ return access, nil ++} ++ ++// parseGraphQLEdgesToMaxRights parses GraphQL results to MaxAccess rights, also returns "none" access levels ++func (b *backend) parseGraphQLEdgesToMaxRights(edges []graphQLEdge, minAccessLevel int) (rights []string, nonAccessLevels []string, lastCursor string) { ++ for _, e := range edges { ++ accessLevelInt := int(e.Node.MaxAccessLevel.IntegerValue) ++ if accessLevelInt >= minAccessLevel && accessLevelInt > 0 { ++ rights = append(rights, fmt.Sprintf("%s:%s", strings.ReplaceAll(strings.ReplaceAll(e.Node.FullPath, "/", ":"), "-", "_"), b.AccessLevelValueToString(e.Node.MaxAccessLevel.IntegerValue))) ++ } ++ if accessLevelInt == 0 { ++ nonAccessLevels = append(nonAccessLevels, strings.ReplaceAll(strings.ReplaceAll(e.Node.FullPath, "/", ":"), "-", "_")) ++ } ++ lastCursor = e.Cursor ++ } ++ return ++} ++ ++// parseNoneAccessLevelsToMaxRights parses "none" access level rights to MaxAccess rights if user has inherited rights ++func (b *backend) parseNoneAccessLevelsToMaxRights(access, noneAccess []string) (rights []string) { ++ for _, value := range noneAccess { ++ for _, aValue := range access { ++ splitAValue := strings.Split(aValue, ":") ++ aValueWithoutAccess, _ := strings.CutSuffix(aValue, ":"+splitAValue[len(splitAValue)-1]) // remove last :access ++ if strings.HasPrefix(value, aValueWithoutAccess) { ++ rights = append(rights, fmt.Sprintf("%s:%s", value, splitAValue[len(splitAValue)-1])) ++ break // only has one per group / project ++ } ++ } ++ } ++ return ++} ++ ++// parseMaxRightsToRights parses MaxAccess rights to all rights ++func (b *backend) parseMaxRightsToRights(maxRights []string) (rights []string) { ++ for _, r := range maxRights { ++ rSplit := strings.Split(r, ":") ++ rWithoutAccess, _ := strings.CutSuffix(r, ":"+rSplit[len(rSplit)-1]) // remove last :access ++ for _, level := range b.AllAccessValuesFromLevel(rSplit[len(rSplit)-1]) { ++ rights = append(rights, fmt.Sprintf("%s:%s", rWithoutAccess, level)) ++ } ++ } ++ return ++} ++ ++type verifyCredentialsResp struct { ++ Username string ++ Rights []string ++ IsAdmin bool ++} ++ ++type graphQLProjectsGroups struct { ++ Projects struct { ++ PageInfo struct { ++ HasNextPage bool `json:"hasNextPage"` ++ } `json:"pageInfo"` ++ Edges []graphQLEdge ++ } `graphql:"projects(membership:true,after:$afterProjects)"` ++ Groups struct { ++ PageInfo struct { ++ HasNextPage bool `json:"hasNextPage"` ++ } `json:"pageInfo"` ++ Edges []graphQLEdge ++ } `graphql:"groups(after:$afterGroups)"` ++} ++ ++type graphQLEdge struct { ++ Cursor string ++ Node struct { ++ FullPath string `json:"fullPath"` ++ MaxAccessLevel struct { ++ IntegerValue gitlab.AccessLevelValue `json:"integerValue"` ++ } `json:"maxAccessLevel"` ++ } ++} +diff --git a/builtin/credential/gitlab/path_login_job.go b/builtin/credential/gitlab/path_login_job.go +new file mode 100644 +index 0000000000..16727802ea +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_job.go +@@ -0,0 +1,139 @@ ++package gitlab ++ ++import ( ++ "context" ++ "encoding/json" ++ "errors" ++ "fmt" ++ "github.com/hashicorp/go-cleanhttp" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "io" ++ "net/http" ++ "net/url" ++ "time" ++) ++ ++func pathLoginJob(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `ci`, ++ Fields: map[string]*framework.FieldSchema{ ++ "token": {Type: framework.TypeString, Description: "Gitlab Job token"}, ++ }, ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathLoginByJob, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathLoginByJob(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config.CIToken == "" { ++ return nil, fmt.Errorf("config CI access disabled") ++ } ++ ++ // Get Job ++ job, err := b.getJobByToken(config, data.Get("token").(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ client, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ token, _, err := client.Users.CreateImpersonationToken(job.User.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Get rights ++ userClient, err := b.TokenClient(config.BaseURL, token.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := userClient.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, userClient, token.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = client.Users.RevokeImpersonationToken(job.User.ID, token.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Finalize connection ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ return b.pathLoginOk(verifyResponse, map[string]interface{}{"token": data.Get("token").(string)}), nil ++ } ++} ++ ++func (b *backend) getJobByToken(config *config, jobToken string) (job gitlab.Job, err error) { ++ // Verify that job is running, and user id. ++ u, err := url.Parse(fmt.Sprintf("%s/api/v4/job", config.BaseURL)) ++ if err != nil { ++ return ++ } ++ ++ headers := make(http.Header) ++ headers.Add("JOB-TOKEN", jobToken) ++ ++ resp, err := cleanhttp.DefaultClient().Do(&http.Request{ ++ Method: "GET", ++ URL: u, ++ Proto: "HTTP/1.1", ++ ProtoMajor: 1, ++ ProtoMinor: 1, ++ Header: headers, ++ Host: u.Host, ++ }) ++ if err != nil { ++ return ++ } ++ if resp.StatusCode != http.StatusOK { ++ err = errors.New(resp.Status) ++ return ++ } ++ ++ data2, _ := io.ReadAll(resp.Body) ++ _ = json.Unmarshal(data2, &job) ++ ++ if job.Status != "running" { ++ err = errors.New("job is not running anymore ; could not generate token") ++ return ++ } ++ ++ return ++} +diff --git a/builtin/credential/gitlab/path_login_oauth.go b/builtin/credential/gitlab/path_login_oauth.go +new file mode 100644 +index 0000000000..262d0f7872 +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_oauth.go +@@ -0,0 +1,209 @@ ++package gitlab ++ ++import ( ++ "context" ++ "crypto/aes" ++ "crypto/cipher" ++ "crypto/rand" ++ "encoding/base64" ++ "errors" ++ "fmt" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "golang.org/x/oauth2" ++ "io" ++ "net/http" ++ "net/url" ++ "strconv" ++ "time" ++) ++ ++func pathOauthLogin(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `oauth`, ++ Fields: map[string]*framework.FieldSchema{ ++ "code": {Type: framework.TypeString, Description: "Gitlab API code"}, ++ "state": {Type: framework.TypeString, Description: "Gitlab API state", Default: ""}, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathOauthLogin, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ logical.ReadOperation: &framework.PathOperation{ ++ Callback: b.pathOauthLogin, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathOauthLogin(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config.AppSecret == "" || config.AppID == "" || config.CallbackURL == "" { ++ return nil, fmt.Errorf("config OAuth disabled") ++ } ++ ++ baseURL, _ := url.Parse(config.BaseURL) ++ callbackURL, _ := url.Parse(config.CallbackURL) ++ ++ oauth2Conf := &oauth2.Config{ ++ ClientID: config.AppID, ++ ClientSecret: config.AppSecret, ++ Endpoint: oauth2.Endpoint{ ++ AuthURL: fmt.Sprintf("%s://%s/oauth/authorize", baseURL.Scheme, baseURL.Host), ++ TokenURL: fmt.Sprintf("%s://%s/oauth/token", baseURL.Scheme, baseURL.Host), ++ }, ++ Scopes: []string{"api", "read_user"}, ++ RedirectURL: fmt.Sprintf("%s://%s/v1/%s%s", callbackURL.Scheme, callbackURL.Host, req.MountPoint, req.Path), ++ } ++ ++ code, _ := data.GetOk("code") ++ ++ if code != nil { ++ state := data.Get("state") ++ err = b.CheckState(state.(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ token, err := oauth2Conf.Exchange(ctx, code.(string)) ++ if err != nil { ++ return nil, err ++ } ++ client, err := b.TokenClient(config.BaseURL, "OAuth-"+token.AccessToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := client.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ adminClient, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ userToken, _, err := adminClient.Users.CreateImpersonationToken(user.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, client, userToken.Token) ++ if err != nil { ++ b.Logger().Error("ERROR : ", err.Error()) ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = adminClient.Users.RevokeImpersonationToken(user.ID, userToken.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ response := b.pathLoginOk(verifyResponse, map[string]interface{}{ ++ "token": token.AccessToken, ++ }) ++ wrappedResponse, err := b.System().ResponseWrapData(ctx, map[string]interface{}{ ++ "authType": "gitlab", ++ "token": "OAuth-" + token.AccessToken, ++ }, time.Second*60, false) ++ if err != nil { ++ return nil, err ++ } ++ response.Redirect = "/ui/vault/auth?with=gitlab&wrapped_token=" + wrappedResponse.Token ++ return response, nil ++ } ++ } else { ++ state, err := b.State() ++ if err != nil { ++ return nil, err ++ } ++ return &logical.Response{ ++ Redirect: oauth2Conf.AuthCodeURL(state, oauth2.AccessTypeOffline), ++ }, nil ++ } ++} ++ ++func (b *backend) State() (encoded string, err error) { ++ plainText := []byte(strconv.FormatInt(time.Now().UnixNano(), 10)) ++ ++ block, err := aes.NewCipher(b.CipherKey) ++ if err != nil { ++ return ++ } ++ ++ //IV needs to be unique, but doesn't have to be secure. ++ //It's common to put it at the beginning of the ciphertext. ++ cipherText := make([]byte, aes.BlockSize+len(plainText)) ++ iv := cipherText[:aes.BlockSize] ++ if _, err = io.ReadFull(rand.Reader, iv); err != nil { ++ return ++ } ++ ++ stream := cipher.NewCFBEncrypter(block, iv) ++ stream.XORKeyStream(cipherText[aes.BlockSize:], plainText) ++ ++ //returns to base64 encoded string ++ encoded = base64.URLEncoding.EncodeToString(cipherText) ++ return ++} ++ ++func (b *backend) CheckState(secureState string) (err error) { ++ now := time.Now().UnixNano() ++ cipherText, err := base64.URLEncoding.DecodeString(secureState) ++ if err != nil { ++ return ++ } ++ ++ block, err := aes.NewCipher(b.CipherKey) ++ if err != nil { ++ return ++ } ++ ++ if len(cipherText) < aes.BlockSize { ++ err = errors.New("illegal State") ++ return ++ } ++ ++ iv := cipherText[:aes.BlockSize] ++ cipherText = cipherText[aes.BlockSize:] ++ ++ stream := cipher.NewCFBDecrypter(block, iv) ++ // XORKeyStream can work in-place if the two arguments are the same. ++ stream.XORKeyStream(cipherText, cipherText) ++ ++ decoded, err := strconv.ParseInt(string(cipherText), 10, 64) ++ if err == nil && (decoded > now || now-decoded > 60*int64(time.Second)) { ++ err = errors.New("illegal State") ++ } ++ return ++} +diff --git a/builtin/credential/gitlab/path_login_tokn.go b/builtin/credential/gitlab/path_login_tokn.go +new file mode 100644 +index 0000000000..b7c24d82be +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_tokn.go +@@ -0,0 +1,85 @@ ++package gitlab ++ ++import ( ++ "context" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "time" ++) ++ ++func pathLoginToken(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `login`, ++ Fields: map[string]*framework.FieldSchema{ ++ "token": {Type: framework.TypeString, Description: "Gitlab API token"}, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathLoginByToken, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathLoginByToken(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ client, err := b.TokenClient(config.BaseURL, data.Get("token").(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := client.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ adminClient, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ userToken, _, err := adminClient.Users.CreateImpersonationToken(user.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, client, userToken.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = adminClient.Users.RevokeImpersonationToken(user.ID, userToken.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ return b.pathLoginOk(verifyResponse, map[string]interface{}{ ++ "token": data.Get("token").(string), ++ }), nil ++ } ++} +diff --git a/command/base_predict.go b/command/base_predict.go +index 72ba402fe9..96cd3d73df 100644 +--- a/command/base_predict.go ++++ b/command/base_predict.go +@@ -110,6 +110,7 @@ func (b *BaseCommand) PredictVaultAvailableAuths() complete.Predictor { + "cert", + "gcp", + "github", ++ "gitlab", + "ldap", + "okta", + "plugin", +diff --git a/command/base_predict_test.go b/command/base_predict_test.go +index 2d752ed635..4257aef973 100644 +--- a/command/base_predict_test.go ++++ b/command/base_predict_test.go +@@ -359,6 +359,7 @@ func TestPredict_Plugins(t *testing.T) { + "gcp", + "gcpkms", + "github", ++ "gitlab", + "hana-database-plugin", + "influxdb-database-plugin", + "jwt", +diff --git a/command/commands.go b/command/commands.go +index 3579a70356..155a5c32af 100644 +--- a/command/commands.go ++++ b/command/commands.go +@@ -36,6 +36,7 @@ import ( + credAws "github.com/hashicorp/vault/builtin/credential/aws" + credCert "github.com/hashicorp/vault/builtin/credential/cert" + credGitHub "github.com/hashicorp/vault/builtin/credential/github" ++ credGitlab "github.com/hashicorp/vault/builtin/credential/gitlab" + credLdap "github.com/hashicorp/vault/builtin/credential/ldap" + credOkta "github.com/hashicorp/vault/builtin/credential/okta" + credToken "github.com/hashicorp/vault/builtin/credential/token" +@@ -228,6 +229,7 @@ var ( + "cf": &credCF.CLIHandler{}, + "gcp": &credGcp.CLIHandler{}, + "github": &credGitHub.CLIHandler{}, ++ "gitlab": &credGitlab.CLIHandler{}, + "kerberos": &credKerb.CLIHandler{}, + "ldap": &credLdap.CLIHandler{}, + "oci": &credOCI.CLIHandler{}, +diff --git a/go.mod b/go.mod +index c03d5e85de..c7fe42407a 100644 +--- a/go.mod ++++ b/go.mod +@@ -201,6 +201,7 @@ require ( + github.com/sethvargo/go-limiter v0.7.1 + github.com/shirou/gopsutil/v3 v3.22.6 + github.com/stretchr/testify v1.8.4 ++ github.com/xanzy/go-gitlab v0.93.1 + go.etcd.io/bbolt v1.3.7 + go.etcd.io/etcd/client/pkg/v3 v3.5.7 + go.etcd.io/etcd/client/v2 v2.305.5 +@@ -227,7 +228,8 @@ require ( + gopkg.in/ory-am/dockertest.v3 v3.3.4 + k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 + layeh.com/radius v0.0.0-20190322222518-890bc1058917 +- nhooyr.io/websocket v1.8.7 ++ nhooyr.io/websocket v1.8.10 ++ github.com/hasura/go-graphql-client v0.12.1 + ) + + require ( +diff --git a/go.sum b/go.sum +index f6f5422444..c4f325b154 100644 +--- a/go.sum ++++ b/go.sum +@@ -2207,6 +2207,8 @@ github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKe + github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= + github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= + github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= ++github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHwe5SSqqi6WI= ++github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE= + github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= + github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= + github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +@@ -3040,6 +3042,8 @@ github.com/vmware/govmomi v0.18.0/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59b + github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= + github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= ++github.com/xanzy/go-gitlab v0.93.1 h1:f7J33cw/P9b/8paIOoH0F3H+TFrswvWHs6yUgoTp9LY= ++github.com/xanzy/go-gitlab v0.93.1/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= + github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= + github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= + github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +@@ -4410,6 +4414,8 @@ modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= + mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48= + nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= + nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= ++nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= ++nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= + oras.land/oras-go v1.2.0/go.mod h1:pFNs7oHp2dYsYMSS82HaX5l4mpnGO7hbpPN6EWH2ltc= + rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= + rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +diff --git a/helper/builtinplugins/registry.go b/helper/builtinplugins/registry.go +index d4c1146995..aa71ea6994 100644 +--- a/helper/builtinplugins/registry.go ++++ b/helper/builtinplugins/registry.go +@@ -5,7 +5,6 @@ package builtinplugins + + import ( + "context" +- + credAliCloud "github.com/hashicorp/vault-plugin-auth-alicloud" + credAzure "github.com/hashicorp/vault-plugin-auth-azure" + credCentrify "github.com/hashicorp/vault-plugin-auth-centrify" +@@ -35,6 +34,7 @@ import ( + credAws "github.com/hashicorp/vault/builtin/credential/aws" + credCert "github.com/hashicorp/vault/builtin/credential/cert" + credGitHub "github.com/hashicorp/vault/builtin/credential/github" ++ credGitlab "github.com/hashicorp/vault/builtin/credential/gitlab" + credLdap "github.com/hashicorp/vault/builtin/credential/ldap" + credOkta "github.com/hashicorp/vault/builtin/credential/okta" + credRadius "github.com/hashicorp/vault/builtin/credential/radius" +@@ -115,6 +115,7 @@ func newRegistry() *registry { + "cf": {Factory: credCF.Factory}, + "gcp": {Factory: credGcp.Factory}, + "github": {Factory: credGitHub.Factory}, ++ "gitlab": {Factory: credGitlab.Factory}, + "jwt": {Factory: credJWT.Factory}, + "kerberos": {Factory: credKerb.Factory}, + "kubernetes": {Factory: credKube.Factory}, +diff --git a/ui/app/adapters/auth-config/gitlab.js b/ui/app/adapters/auth-config/gitlab.js +new file mode 100644 +index 0000000000..21f5624ac4 +--- /dev/null ++++ b/ui/app/adapters/auth-config/gitlab.js +@@ -0,0 +1,2 @@ ++import AuthConfig from './_base'; ++export default AuthConfig.extend(); +diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js +index 7469e062cd..fa6c055922 100644 +--- a/ui/app/adapters/cluster.js ++++ b/ui/app/adapters/cluster.js +@@ -175,6 +175,7 @@ export default ApplicationAdapter.extend({ + const authURLs = { + github: 'login', + jwt: 'login', ++ gitlab: username ? `login/${encodeURIComponent(username)}` : 'login', + oidc: 'login', + userpass: `login/${encodeURIComponent(username)}`, + ldap: `login/${encodeURIComponent(username)}`, +diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js +index cfb1d3456d..594149d966 100644 +--- a/ui/app/components/auth-form.js ++++ b/ui/app/components/auth-form.js +@@ -193,8 +193,14 @@ export default Component.extend(DEFAULTS, { + this.set('selectedAuth', 'token'); + const adapter = this.store.adapterFor('tools'); + try { +- const response = yield adapter.toolAction('unwrap', null, { clientToken: token }); +- this.set('token', response.auth.client_token); ++ const response = yield adapter.toolAction('unwrap', null, {clientToken: token}); ++ if (response.data.authType) { ++ this.set('selectedAuth', response.data.authType) ++ this.set('token', response.data.token); ++ } else { ++ this.set('selectedAuth', 'token'); ++ this.set('token', response.auth.client_token); ++ } + this.send('doSubmit'); + } catch (e) { + this.set('error', `Token unwrap failed: ${e.errors[0]}`); +diff --git a/ui/app/helpers/mountable-auth-methods.js b/ui/app/helpers/mountable-auth-methods.js +index 303e9baff4..932df2a012 100644 +--- a/ui/app/helpers/mountable-auth-methods.js ++++ b/ui/app/helpers/mountable-auth-methods.js +@@ -55,6 +55,13 @@ const MOUNTABLE_AUTH_METHODS = [ + category: 'cloud', + glyph: 'github-color', + }, ++ { ++ displayName: 'Gitlab', ++ value: 'gitlab', ++ type: 'gitlab', ++ glyph: 'auth', ++ category: 'cloud', ++ }, + { + displayName: 'JWT', + value: 'jwt', +diff --git a/ui/app/helpers/supported-auth-backends.js b/ui/app/helpers/supported-auth-backends.js +index e06cbd3387..a4e5b7658a 100644 +--- a/ui/app/helpers/supported-auth-backends.js ++++ b/ui/app/helpers/supported-auth-backends.js +@@ -70,6 +70,14 @@ const SUPPORTED_AUTH_BACKENDS = [ + displayNamePath: ['metadata.org', 'metadata.username'], + formAttributes: ['token'], + }, ++ { ++ type: 'gitlab', ++ typeDisplay: 'Gitlab', ++ description: 'Gitlab authentication.', ++ tokenPath: 'client_token', ++ displayNamePath: 'metadata.username', ++ formAttributes: ['token'], ++ }, + ]; + + const ENTERPRISE_AUTH_METHODS = [ +diff --git a/ui/app/helpers/tabs-for-auth-section.js b/ui/app/helpers/tabs-for-auth-section.js +index 46f95fe65a..ce32f2c4f8 100644 +--- a/ui/app/helpers/tabs-for-auth-section.js ++++ b/ui/app/helpers/tabs-for-auth-section.js +@@ -34,6 +34,12 @@ const TABS_FOR_SETTINGS = { + routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], + }, + ], ++ gitlab: [ ++ { ++ label: 'Configuration', ++ routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], ++ }, ++ ], + gcp: [ + { + label: 'Configuration', +diff --git a/ui/app/models/auth-config/gitlab.js b/ui/app/models/auth-config/gitlab.js +new file mode 100644 +index 0000000000..91d52cbcc3 +--- /dev/null ++++ b/ui/app/models/auth-config/gitlab.js +@@ -0,0 +1,39 @@ ++import { computed } from '@ember/object'; ++import DS from 'ember-data'; ++ ++import AuthConfig from '../auth-config'; ++import fieldToAttrs from 'vault/utils/field-to-attrs'; ++ ++const { attr } = DS; ++ ++export default AuthConfig.extend({ ++ baseURL: attr('string', { ++ label: 'Base URL', ++ }), ++ minAccessLevel: attr('string', { ++ label: 'Minimal Access Level', ++ defaultValue: 'developer', ++ possibleValues: ['none', 'guest', 'reporter', 'developer', 'maintainer', 'owner'] ++ }), ++ appID: attr('string', { ++ label: 'Oauth Application ID', ++ }), ++ appSecret: attr('string', { ++ label: 'Oauth Application Secret', ++ }), ++ callbackURL: attr('string', { ++ label: 'Oauth Callback URL', ++ }), ++ ciToken: attr('string', { ++ label: 'CI token', ++ }), ++ ++ fieldGroups: computed(function() { ++ const groups = [{ ++ 'Gitlab Options': ['baseURL', 'minAccessLevel', 'appID', 'appSecret', 'callbackURL', 'ciToken'], ++ }, ]; ++ ++ return fieldToAttrs(this, groups); ++ }), ++ ++}); +diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.js b/ui/app/routes/vault/cluster/settings/auth/configure/section.js +index 0189f28962..f8a22b84d4 100644 +--- a/ui/app/routes/vault/cluster/settings/auth/configure/section.js ++++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.js +@@ -22,6 +22,7 @@ export default Route.extend(UnloadModelRoute, { + 'aws-roletag-denylist': 'auth-config/aws/roletag-denylist', + 'azure-configuration': 'auth-config/azure', + 'github-configuration': 'auth-config/github', ++ 'gitlab-configuration': 'auth-config/gitlab', + 'gcp-configuration': 'auth-config/gcp', + 'jwt-configuration': 'auth-config/jwt', + 'oidc-configuration': 'auth-config/oidc', +diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs +index cb56335804..d1ff88ad74 100644 +--- a/ui/app/templates/components/auth-form.hbs ++++ b/ui/app/templates/components/auth-form.hbs +@@ -128,6 +128,19 @@ + /> + + ++ {{else if (eq this.providerName "gitlab")}} ++
++ ++ ++ ++
++ ++
++ ++
++
+ {{else if (eq this.providerName "token")}} +
+ +diff --git a/ui/app/templates/components/wizard/gitlab-method.hbs b/ui/app/templates/components/wizard/gitlab-method.hbs +new file mode 100644 +index 0000000000..f3d95a1762 +--- /dev/null ++++ b/ui/app/templates/components/wizard/gitlab-method.hbs +@@ -0,0 +1,10 @@ ++ ++

++ The Gitlab auth method can be used to authenticate with Vault using a Gitlab access token. ++

++
+diff --git a/ui/public/eco/gitlab.svg b/ui/public/eco/gitlab.svg +new file mode 100644 +index 0000000000..95a22f1017 +--- /dev/null ++++ b/ui/public/eco/gitlab.svg +@@ -0,0 +1 @@ ++ +\ No newline at end of file +diff --git a/ui/tests/acceptance/settings/auth/configure/section-test.js b/ui/tests/acceptance/settings/auth/configure/section-test.js +index e9c0539c3e..a2ea4f29af 100644 +--- a/ui/tests/acceptance/settings/auth/configure/section-test.js ++++ b/ui/tests/acceptance/settings/auth/configure/section-test.js +@@ -60,7 +60,7 @@ module('Acceptance | settings/auth/configure/section', function (hooks) { + assert.ok(keys.includes('description'), 'passes updated description on tune'); + }); + +- for (const type of ['aws', 'azure', 'gcp', 'github', 'kubernetes']) { ++ for (const type of ['aws', 'azure', 'gcp', 'github', 'gitlab', 'kubernetes']) { + test(`it shows tabs for auth method: ${type}`, async function (assert) { + const path = `${type}-showtab-${this.uid}`; + await cli.consoleInput(`write sys/auth/${path} type=${type}`);