diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..03c48ef
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "daily"
\ No newline at end of file
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..0ff4e84
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,43 @@
+name: "CodeQL Analysis"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '43 9 * * 5'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'go' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: ${{ matrix.language }}
+
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
+ with:
+ category: "/language:${{matrix.language}}"
\ No newline at end of file
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
new file mode 100644
index 0000000..750b798
--- /dev/null
+++ b/.github/workflows/golangci-lint.yml
@@ -0,0 +1,36 @@
+name: golangci-lint
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "**.go"
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "**.go"
+
+permissions:
+ contents: read
+
+jobs:
+ golangci:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ go: [1.19 1.20 1.21 1.22]
+ os: [ubuntu-latest]
+ steps:
+ - uses: actions/checkout@v3
+ - name: Setup Go ${{ matrix.go }}
+ uses: actions/setup-go@v4
+ with:
+ go-version: ${{ matrix.go }}
+ - name: Display Go version
+ run: go version
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: latest
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6b28bc0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,40 @@
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+.idea/**/aws.xml
+.idea/**/contentModel.xml
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+.idea/**/gradle.xml
+.idea/**/libraries
+cmake-build-*/
+.idea/**/mongoSettings.xml
+*.iws
+out/
+.idea_modules/
+atlassian-ide-plugin.xml
+.idea/replstate.xml
+.idea/sonarlint/
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+.idea/httpRequests
+.idea/caches/build_file_checksums.ser
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+go.work
+tmp/
+bin/
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..909befb
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vault-plugin-secrets-gitlab.iml b/.idea/vault-plugin-secrets-gitlab.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/vault-plugin-secrets-gitlab.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..04c9210
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+Vault Plugin for Gitlab Access Token
+------------------------------------
+
+This is a standalone backend plugin for use with Hashicorp Vault. This plugin allows for Gitlab to generate access tokens both personal and group.
+
+## Quick Links
+
+- Vault Website: [https://www.vaultproject.io]
+- Gitlab Private Access Tokens: [https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html]
+- Gitlab Group Access Tokens: [https://docs.gitlab.com/ee/api/group_access_tokens.html]
+
+## Getting Started
+
+This is a [Vault plugin](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalogs)
+and is meant to work with Vault. This guide assumes you have already installed Vault
+and have a basic understanding of how Vault works.
+
+Otherwise, first read this guide on how to [get started with Vault](https://www.vaultproject.io/intro/getting-started/install.html).
+
+To learn specifically about how plugins work, see documentation on [Vault plugins](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalog).
+
+### Setup
+
+Before we can use this plugin we need to create an access token that will have rights to do what we need to.
+
+## Security Model
+
+The current authentication model requires providing Vault with a Gitlab Token.
+
+## TODO
+
+[ ] Implement autorotation of the main token
+[ ] Add tests against real Gitlab instance
\ No newline at end of file
diff --git a/backend.go b/backend.go
new file mode 100644
index 0000000..9ae611c
--- /dev/null
+++ b/backend.go
@@ -0,0 +1,113 @@
+package gitlab
+
+import (
+ "context"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/helper/locksutil"
+ "github.com/hashicorp/vault/sdk/logical"
+ "strings"
+ "sync"
+)
+
+const (
+ // operationPrefixGitlabAccessTokens is used as expected prefix for OpenAPI operation id's.
+ operationPrefixGitlabAccessTokens = "gitlab"
+
+ backendHelp = `
+The Gitlab Access token auth Backend dynamically generates private
+and group tokens.
+
+After mounting this Backend, credentials to manage Gitlab tokens must be configured
+with the "config/" endpoints.
+`
+)
+
+// Factory returns expected new Backend as logical.Backend
+func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
+ var b = &Backend{
+ roleLocks: locksutil.CreateLocks(),
+ }
+
+ b.Backend = &framework.Backend{
+ BackendType: logical.TypeLogical,
+ Help: strings.TrimSpace(backendHelp),
+ Invalidate: b.Invalidate,
+
+ PathsSpecial: &logical.Paths{
+ LocalStorage: []string{
+ framework.WALPrefix,
+ },
+ SealWrapStorage: []string{
+ PathConfigStorage,
+ },
+ },
+
+ Secrets: []*framework.Secret{
+ secretAccessTokens(b),
+ },
+
+ Paths: framework.PathAppend(
+ []*framework.Path{
+ pathConfig(b),
+ pathListRoles(b),
+ pathRoles(b),
+ pathTokenRoles(b),
+ },
+ ),
+ }
+
+ b.SetClient(nil)
+ if err := b.Setup(ctx, conf); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+type Backend struct {
+ *framework.Backend
+
+ // The client that we can use to create and revoke the access tokens
+ client Client
+
+ // Mutex to protect access to gitlab clients and client configs, a change to the gitlab client config
+ // would invalidate the gitlab client, so it will need to be reinitialized
+ lockClientMutex sync.RWMutex
+
+ // roleLocks to protect access for roles, during modifications, deletion
+ roleLocks []*locksutil.LockEntry
+}
+
+// Invalidate invalidates the key if required
+func (b *Backend) Invalidate(ctx context.Context, key string) {
+ b.Logger().Debug("Backend invalidate", "key", key)
+ if key == PathConfigStorage {
+ b.Logger().Warn("gitlab config changed, reinitializing the gitlab client")
+ b.lockClientMutex.Lock()
+ defer b.lockClientMutex.Unlock()
+ b.client = nil
+ }
+}
+
+func (b *Backend) SetClient(client Client) {
+ b.client = client
+}
+
+func (b *Backend) getClient(ctx context.Context, s logical.Storage) (Client, error) {
+ if b.client != nil && b.client.Valid() {
+ return b.client, nil
+ }
+
+ b.lockClientMutex.RLock()
+ defer b.lockClientMutex.RUnlock()
+ config, err := getConfig(ctx, s)
+ if err != nil {
+ return nil, err
+ }
+
+ client, err := NewGitlabClient(config)
+ if err == nil {
+ b.SetClient(client)
+ }
+ return client, err
+}
diff --git a/backend_test.go b/backend_test.go
new file mode 100644
index 0000000..3f12ad3
--- /dev/null
+++ b/backend_test.go
@@ -0,0 +1,257 @@
+package gitlab_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ log "github.com/hashicorp/go-hclog"
+ "github.com/hashicorp/go-multierror"
+ "github.com/hashicorp/vault/sdk/helper/logging"
+ "github.com/hashicorp/vault/sdk/logical"
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/require"
+ g "github.com/xanzy/go-gitlab"
+ "golang.org/x/exp/slices"
+ "sync"
+ "testing"
+ "time"
+)
+
+type expectedEvent struct {
+ eventType string
+}
+
+type mockEventsSender struct {
+ eventsProcessed []*logical.EventReceived
+}
+
+func (m *mockEventsSender) Send(ctx context.Context, eventType logical.EventType, event *logical.EventData) error {
+ if m == nil {
+ return nil
+ }
+ m.eventsProcessed = append(m.eventsProcessed, &logical.EventReceived{
+ EventType: string(eventType),
+ Event: event,
+ })
+ return nil
+}
+
+func (m *mockEventsSender) expectEvents(t *testing.T, expectedEvents []expectedEvent) {
+ t.Helper()
+ require.EqualValuesf(t, len(m.eventsProcessed), len(expectedEvents), "Expected events: %v\nEvents processed: %v", expectedEvents, m.eventsProcessed)
+ //if len(m.eventsProcessed) != len(expectedEvents) {
+ // t.Fatalf("Expected events: %v\nEvents processed: %v", expectedEvents, m.eventsProcessed)
+ //}
+ for i, expected := range expectedEvents {
+ actual := m.eventsProcessed[i]
+ require.EqualValuesf(t, expected.eventType, actual.EventType, "Mismatched event type at index %d. Expected %s, got %s\n%v", i, expected.eventType, actual.EventType, m.eventsProcessed)
+ //if expected.eventType != actual.EventType {
+ // t.Fatalf("Mismatched event type at index %d. Expected %s, got %s\n%v", i, expected.eventType, actual.EventType, m.eventsProcessed)
+ //}
+ //actualPath := actual.Event.Metadata.Fields["path"].GetStringValue()
+ //require.EqualValuesf(t, expected.path, actualPath, "Mismatched path at index %d. Expected %s, got %s\n%v", i, expected.path, actualPath, m.eventsProcessed)
+ //if expected.path != actualPath {
+ // t.Fatalf("Mismatched path at index %d. Expected %s, got %s\n%v", i, expected.path, actualPath, m.eventsProcessed)
+ //}
+ }
+}
+
+func getBackendWithEvents() (*gitlab.Backend, logical.Storage, *mockEventsSender, error) {
+ events := &mockEventsSender{}
+ config := &logical.BackendConfig{
+ Logger: logging.NewVaultLogger(log.Trace),
+ System: &logical.StaticSystemView{},
+ StorageView: &logical.InmemStorage{},
+ BackendUUID: "test",
+ EventsSender: events,
+ }
+
+ b, err := gitlab.Factory(context.Background(), config)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("unable to create Backend: %w", err)
+ }
+
+ return b.(*gitlab.Backend), config.StorageView, events, nil
+}
+
+func writeBackendConfig(b *gitlab.Backend, l logical.Storage, config map[string]any) error {
+ var _, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: config,
+ })
+ return err
+}
+
+func getBackendWithConfig(config map[string]any) (*gitlab.Backend, logical.Storage, error) {
+ var b, storage, _, _ = getBackendWithEvents()
+ return b, storage, writeBackendConfig(b, storage, config)
+}
+
+func getBackend() (*gitlab.Backend, logical.Storage, error) {
+ b, storage, _, err := getBackendWithEvents()
+ return b, storage, err
+}
+
+func TestBackend(t *testing.T) {
+ var err error
+ _, _, err = getBackend()
+ require.NoError(t, err)
+}
+
+func countErrByName(err *multierror.Error) map[string]int {
+ var data = make(map[string]int)
+
+ for _, e := range err.Errors {
+ name := errors.Unwrap(e).Error()
+ if _, ok := data[name]; !ok {
+ data[name] = 0
+ }
+ data[name]++
+ }
+
+ return data
+}
+
+func newInMemoryClient(valid bool) *inMemoryClient {
+ return &inMemoryClient{
+ users: make([]string, 0),
+ valid: valid,
+ accessTokens: make(map[string]gitlab.EntryToken),
+ }
+}
+
+type inMemoryClient struct {
+ internalCounter int
+ users []string
+ muLock sync.Mutex
+ valid bool
+
+ personalAccessTokenRevokeError bool
+ groupAccessTokenRevokeError bool
+ projectAccessTokenRevokeError bool
+ personalAccessTokenCreateError bool
+ groupAccessTokenCreateError bool
+ projectAccessTokenCreateError bool
+
+ accessTokens map[string]gitlab.EntryToken
+}
+
+func (i *inMemoryClient) Valid() bool {
+ return i.valid
+}
+
+func (i *inMemoryClient) CreatePersonalAccessToken(username string, userId int, name string, expiresAt time.Time, scopes []string) (*gitlab.EntryToken, error) {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.personalAccessTokenCreateError {
+ return nil, fmt.Errorf("CreatePersonalAccessToken")
+ }
+ i.internalCounter++
+ var tokenId = i.internalCounter
+ var entryToken = gitlab.EntryToken{
+ TokenID: tokenId,
+ UserID: userId,
+ ParentID: "",
+ Path: username,
+ Name: name,
+ Token: "",
+ TokenType: gitlab.TokenTypePersonal,
+ CreatedAt: g.Time(time.Now()),
+ ExpiresAt: &expiresAt,
+ Scopes: scopes,
+ }
+ i.accessTokens[fmt.Sprintf("%s_%v", gitlab.TokenTypePersonal.String(), tokenId)] = entryToken
+ return &entryToken, nil
+}
+
+func (i *inMemoryClient) CreateGroupAccessToken(groupId string, name string, expiresAt time.Time, scopes []string, accessLevel gitlab.AccessLevel) (*gitlab.EntryToken, error) {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.groupAccessTokenCreateError {
+ return nil, fmt.Errorf("CreateGroupAccessToken")
+ }
+ i.internalCounter++
+ var tokenId = i.internalCounter
+ var entryToken = gitlab.EntryToken{
+ TokenID: tokenId,
+ UserID: 0,
+ ParentID: groupId,
+ Path: groupId,
+ Name: name,
+ Token: "",
+ TokenType: gitlab.TokenTypeGroup,
+ CreatedAt: g.Time(time.Now()),
+ ExpiresAt: &expiresAt,
+ Scopes: scopes,
+ AccessLevel: accessLevel,
+ }
+ i.accessTokens[fmt.Sprintf("%s_%v", gitlab.TokenTypeGroup.String(), tokenId)] = entryToken
+ return &entryToken, nil
+}
+
+func (i *inMemoryClient) CreateProjectAccessToken(projectId string, name string, expiresAt time.Time, scopes []string, accessLevel gitlab.AccessLevel) (*gitlab.EntryToken, error) {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.projectAccessTokenCreateError {
+ return nil, fmt.Errorf("CreateProjectAccessToken")
+ }
+ i.internalCounter++
+ var tokenId = i.internalCounter
+ var entryToken = gitlab.EntryToken{
+ TokenID: tokenId,
+ UserID: 0,
+ ParentID: projectId,
+ Path: projectId,
+ Name: name,
+ Token: "",
+ TokenType: gitlab.TokenTypeProject,
+ CreatedAt: g.Time(time.Now()),
+ ExpiresAt: &expiresAt,
+ Scopes: scopes,
+ AccessLevel: accessLevel,
+ }
+ i.accessTokens[fmt.Sprintf("%s_%v", gitlab.TokenTypeProject.String(), tokenId)] = entryToken
+ return &entryToken, nil
+}
+
+func (i *inMemoryClient) RevokePersonalAccessToken(tokenId int) error {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.personalAccessTokenRevokeError {
+ return fmt.Errorf("RevokePersonalAccessToken")
+ }
+ delete(i.accessTokens, fmt.Sprintf("%s_%v", gitlab.TokenTypePersonal.String(), tokenId))
+ return nil
+}
+
+func (i *inMemoryClient) RevokeProjectAccessToken(tokenId int, projectId string) error {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.projectAccessTokenRevokeError {
+ return fmt.Errorf("RevokeProjectAccessToken")
+ }
+ delete(i.accessTokens, fmt.Sprintf("%s_%v", gitlab.TokenTypeProject.String(), tokenId))
+ return nil
+}
+
+func (i *inMemoryClient) RevokeGroupAccessToken(tokenId int, groupId string) error {
+ i.muLock.Lock()
+ defer i.muLock.Unlock()
+ if i.groupAccessTokenRevokeError {
+ return fmt.Errorf("RevokeGroupAccessToken")
+ }
+ delete(i.accessTokens, fmt.Sprintf("%s_%v", gitlab.TokenTypeGroup.String(), tokenId))
+ return nil
+}
+
+func (i *inMemoryClient) GetUserIdByUsername(username string) (int, error) {
+ idx := slices.Index(i.users, username)
+ if idx == -1 {
+ i.users = append(i.users, username)
+ idx = slices.Index(i.users, username)
+ }
+ return idx, nil
+}
+
+var _ gitlab.Client = new(inMemoryClient)
diff --git a/cmd/vault-plugin-auth-gitlab-access-tokens/main.go b/cmd/vault-plugin-auth-gitlab-access-tokens/main.go
new file mode 100644
index 0000000..9872f22
--- /dev/null
+++ b/cmd/vault-plugin-auth-gitlab-access-tokens/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "github.com/hashicorp/go-hclog"
+ "github.com/hashicorp/vault/api"
+ "github.com/hashicorp/vault/sdk/plugin"
+ gat "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "os"
+)
+
+var (
+ logger = hclog.New(&hclog.LoggerOptions{})
+)
+
+func main() {
+ apiClientMeta := &api.PluginAPIClientMeta{}
+ flags := apiClientMeta.FlagSet()
+
+ fatalIfError(flags.Parse(os.Args[1:]))
+
+ tlsConfig := apiClientMeta.GetTLSConfig()
+ tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
+
+ fatalIfError(plugin.ServeMultiplex(&plugin.ServeOpts{
+ BackendFactoryFunc: gat.Factory,
+ TLSProviderFunc: tlsProviderFunc,
+ }))
+}
+
+func fatalIfError(err error) {
+ if err != nil {
+ logger.Error("plugin shutting down", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/defs.go b/defs.go
new file mode 100644
index 0000000..84e03ec
--- /dev/null
+++ b/defs.go
@@ -0,0 +1,21 @@
+package gitlab
+
+import (
+ "errors"
+ "time"
+)
+
+var (
+ ErrNilValue = errors.New("nil value")
+ ErrInvalidValue = errors.New("invalid value")
+ ErrFieldRequired = errors.New("required field")
+ ErrFieldInvalidValue = errors.New("invalid value for field")
+ ErrBackendNotConfigured = errors.New("backend not configured")
+)
+
+const (
+ DefaultConfigFieldAccessTokenMaxTTL = time.Duration(0)
+ DefaultRoleFieldAccessTokenMaxTTL = 24 * time.Hour
+ DefaultAccessTokenMinTTL = 24 * time.Hour
+ DefaultAccessTokenMaxPossibleTTL = 365 * 24 * time.Hour
+)
diff --git a/entry_config.go b/entry_config.go
new file mode 100644
index 0000000..9654a82
--- /dev/null
+++ b/entry_config.go
@@ -0,0 +1,38 @@
+package gitlab
+
+import (
+ "context"
+ "github.com/hashicorp/vault/sdk/logical"
+ "time"
+)
+
+type entryConfig struct {
+ BaseURL string `json:"base_url" structs:"base_url" mapstructure:"base_url"`
+ Token string `json:"token" structs:"token" mapstructure:"token"`
+ MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl" mapstructure:"max_ttl"`
+}
+
+func (e entryConfig) LogicalResponseData() map[string]interface{} {
+ return map[string]interface{}{
+ "max_ttl": int64(e.MaxTTL / time.Second),
+ "base_url": e.BaseURL,
+ "token": e.Token,
+ }
+}
+
+func getConfig(ctx context.Context, s logical.Storage) (*entryConfig, error) {
+ entry, err := s.Get(ctx, PathConfigStorage)
+ if err != nil {
+ return nil, err
+ }
+
+ if entry == nil {
+ return nil, nil
+ }
+
+ cfg := new(entryConfig)
+ if err := entry.DecodeJSON(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
diff --git a/entry_role.go b/entry_role.go
new file mode 100644
index 0000000..db9c330
--- /dev/null
+++ b/entry_role.go
@@ -0,0 +1,47 @@
+package gitlab
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/vault/sdk/logical"
+ "time"
+)
+
+type entryRole struct {
+ RoleName string `json:"role_name" structs:"role_name" mapstructure:"role_name"`
+ TokenTTL time.Duration `json:"token_ttl" structs:"token_ttl" mapstructure:"token_ttl"`
+ Path string `json:"path" structs:"path" mapstructure:"path"`
+ Name string `json:"name" structs:"name" mapstructure:"name"`
+ Scopes []string `json:"scopes" structs:"scopes" mapstructure:"scopes"`
+ AccessLevel AccessLevel `json:"access_level" structs:"access_level" mapstructure:"access_level,omitempty"`
+ TokenType TokenType `json:"token_type" structs:"token_type" mapstructure:"token_type"`
+}
+
+func (e entryRole) LogicalResponseData() map[string]interface{} {
+ return map[string]interface{}{
+ "role_name": e.RoleName,
+ "path": e.Path,
+ "name": e.Name,
+ "scopes": e.Scopes,
+ "access_level": e.AccessLevel.String(),
+ "token_ttl": int64(e.TokenTTL / time.Second),
+ "token_type": e.TokenType.String(),
+ }
+}
+
+func getRole(ctx context.Context, name string, s logical.Storage) (*entryRole, error) {
+ entry, err := s.Get(ctx, fmt.Sprintf("%s/%s", PathRoleStorage, name))
+ if err != nil {
+ return nil, err
+ }
+
+ if entry == nil {
+ return nil, nil
+ }
+
+ role := new(entryRole)
+ if err := entry.DecodeJSON(role); err != nil {
+ return nil, err
+ }
+ return role, nil
+}
diff --git a/entry_token.go b/entry_token.go
new file mode 100644
index 0000000..319ea6a
--- /dev/null
+++ b/entry_token.go
@@ -0,0 +1,39 @@
+package gitlab
+
+import "time"
+
+type EntryToken struct {
+ TokenID int `json:"token_id"`
+ UserID int `json:"user_id"`
+ ParentID string `json:"parent_id"`
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Token string `json:"token"`
+ TokenType TokenType `json:"token_type"`
+ CreatedAt *time.Time `json:"created_at"`
+ ExpiresAt *time.Time `json:"expires_at"`
+ Scopes []string `json:"scopes"`
+ AccessLevel AccessLevel `json:"access_level"` // not used for personal access tokens
+}
+
+func (e EntryToken) SecretResponse() (map[string]interface{}, map[string]interface{}) {
+ return map[string]interface{}{
+ "name": e.Name,
+ "token": e.Token,
+ "path": e.Path,
+ "scopes": e.Scopes,
+ "access_level": e.AccessLevel.String(),
+ "created_at": e.CreatedAt,
+ "expires_at": e.ExpiresAt,
+ },
+ map[string]interface{}{
+ "path": e.Path,
+ "name": e.Name,
+ "user_id": e.UserID,
+ "parent_id": e.ParentID,
+ "token_id": e.TokenID,
+ "token_type": e.TokenType.String(),
+ "scopes": e.Scopes,
+ "access_level": e.AccessLevel.String(),
+ }
+}
diff --git a/events.go b/events.go
new file mode 100644
index 0000000..20a4755
--- /dev/null
+++ b/events.go
@@ -0,0 +1,37 @@
+package gitlab
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/logical"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+func event(ctx context.Context, b *framework.Backend, eventType string, metadata map[string]string) {
+ ev, err := logical.NewEvent()
+ if err != nil {
+ b.Logger().Warn("Error creating event", "error", err)
+ return
+ }
+ metadataBytes, err := json.Marshal(metadata)
+ if err != nil {
+ b.Logger().Warn("Error marshaling metadata", "error", err)
+ return
+ }
+ ev.Metadata = &structpb.Struct{}
+ if err := ev.Metadata.UnmarshalJSON(metadataBytes); err != nil {
+ b.Logger().Warn("Error unmarshalling metadata into proto", "error", err)
+ return
+ }
+ err = b.SendEvent(ctx, logical.EventType(fmt.Sprintf("%s/%s", operationPrefixGitlabAccessTokens, eventType)), ev)
+ // ignore events are disabled error
+ if errors.Is(err, framework.ErrNoEvents) {
+ return
+ } else if err != nil {
+ b.Logger().Warn("Error sending event", "error", err)
+ return
+ }
+}
diff --git a/gitlab_client.go b/gitlab_client.go
new file mode 100644
index 0000000..69dadd4
--- /dev/null
+++ b/gitlab_client.go
@@ -0,0 +1,181 @@
+package gitlab
+
+import (
+ "errors"
+ "fmt"
+ g "github.com/xanzy/go-gitlab"
+ "golang.org/x/time/rate"
+ "net/http"
+ "time"
+)
+
+var (
+ ErrAccessTokenNotFound = errors.New("access token not found")
+ ErrRoleNotFound = errors.New("role not found")
+)
+
+type Client interface {
+ Valid() bool
+
+ CreatePersonalAccessToken(username string, userId int, name string, expiresAt time.Time, scopes []string) (*EntryToken, error)
+ CreateGroupAccessToken(groupId string, name string, expiresAt time.Time, scopes []string, accessLevel AccessLevel) (*EntryToken, error)
+ CreateProjectAccessToken(projectId string, name string, expiresAt time.Time, scopes []string, accessLevel AccessLevel) (*EntryToken, error)
+ RevokePersonalAccessToken(tokenId int) error
+ RevokeProjectAccessToken(tokenId int, projectId string) error
+ RevokeGroupAccessToken(tokenId int, groupId string) error
+ GetUserIdByUsername(username string) (int, error)
+}
+
+type gitlabClient struct {
+ client *g.Client
+ config *entryConfig
+}
+
+func (gc *gitlabClient) GetUserIdByUsername(username string) (int, error) {
+ l := &g.ListUsersOptions{
+ Username: g.String(username),
+ }
+
+ u, _, err := gc.client.Users.ListUsers(l)
+ if err != nil {
+ return fmt.Printf("%v", err)
+ }
+ if username != u[0].Username {
+ return fmt.Printf("%v", username)
+ }
+
+ return u[0].ID, nil
+}
+
+func (gc *gitlabClient) CreatePersonalAccessToken(username string, userId int, name string, expiresAt time.Time, scopes []string) (*EntryToken, error) {
+ at, _, err := gc.client.Users.CreatePersonalAccessToken(userId, &g.CreatePersonalAccessTokenOptions{
+ Name: g.String(name),
+ ExpiresAt: (*g.ISOTime)(&expiresAt),
+ Scopes: &scopes,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &EntryToken{
+ TokenID: at.ID,
+ UserID: userId,
+ ParentID: "",
+ Path: username,
+ Name: name,
+ Token: at.Token,
+ TokenType: TokenTypePersonal,
+ CreatedAt: at.CreatedAt,
+ ExpiresAt: (*time.Time)(at.ExpiresAt),
+ Scopes: scopes,
+ AccessLevel: AccessLevelUnknown,
+ }, nil
+}
+
+func (gc *gitlabClient) CreateGroupAccessToken(groupId string, name string, expiresAt time.Time, scopes []string, accessLevel AccessLevel) (*EntryToken, error) {
+ var al = new(g.AccessLevelValue)
+ *al = g.AccessLevelValue(accessLevel.Value())
+ at, _, err := gc.client.GroupAccessTokens.CreateGroupAccessToken(groupId, &g.CreateGroupAccessTokenOptions{
+ Name: g.String(name),
+ Scopes: &scopes,
+ ExpiresAt: (*g.ISOTime)(&expiresAt),
+ AccessLevel: al,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &EntryToken{
+ TokenID: at.ID,
+ UserID: 0,
+ ParentID: groupId,
+ Path: groupId,
+ Name: name,
+ Token: at.Token,
+ TokenType: TokenTypeGroup,
+ CreatedAt: at.CreatedAt,
+ ExpiresAt: (*time.Time)(at.ExpiresAt),
+ Scopes: scopes,
+ AccessLevel: accessLevel,
+ }, nil
+}
+
+func (gc *gitlabClient) CreateProjectAccessToken(projectId string, name string, expiresAt time.Time, scopes []string, accessLevel AccessLevel) (*EntryToken, error) {
+ var al = new(g.AccessLevelValue)
+ *al = g.AccessLevelValue(accessLevel.Value())
+ at, _, err := gc.client.ProjectAccessTokens.CreateProjectAccessToken(projectId, &g.CreateProjectAccessTokenOptions{
+ Name: g.String(name),
+ Scopes: &scopes,
+ ExpiresAt: (*g.ISOTime)(&expiresAt),
+ AccessLevel: al,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &EntryToken{
+ TokenID: at.ID,
+ UserID: 0,
+ ParentID: projectId,
+ Path: projectId,
+ Name: name,
+ Token: at.Token,
+ TokenType: TokenTypeProject,
+ CreatedAt: at.CreatedAt,
+ ExpiresAt: (*time.Time)(at.ExpiresAt),
+ Scopes: scopes,
+ AccessLevel: accessLevel,
+ }, nil
+}
+
+func (gc *gitlabClient) RevokePersonalAccessToken(tokenId int) error {
+ var resp, err = gc.client.PersonalAccessTokens.RevokePersonalAccessToken(tokenId)
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("personal: %w", ErrAccessTokenNotFound)
+ }
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (gc *gitlabClient) RevokeProjectAccessToken(tokenId int, projectId string) error {
+ var resp, err = gc.client.ProjectAccessTokens.RevokeProjectAccessToken(projectId, tokenId)
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("project: %w", ErrAccessTokenNotFound)
+ }
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (gc *gitlabClient) RevokeGroupAccessToken(tokenId int, groupId string) error {
+ var resp, err = gc.client.GroupAccessTokens.RevokeGroupAccessToken(groupId, tokenId)
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("group: %w", ErrAccessTokenNotFound)
+ }
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (gc *gitlabClient) Valid() bool {
+ return gc.client != nil && gc.config != nil
+}
+
+var _ Client = new(gitlabClient)
+
+func NewGitlabClient(config *entryConfig) (client Client, err error) {
+ if config == nil {
+ return nil, fmt.Errorf("config: %w", ErrNilValue)
+ }
+
+ var gc *g.Client
+ if gc, err = g.NewClient(config.Token,
+ g.WithBaseURL(fmt.Sprintf("%s/api/v4", config.BaseURL)),
+ g.WithCustomLimiter(rate.NewLimiter(rate.Inf, 0)),
+ ); err != nil {
+ return nil, err
+ }
+
+ return &gitlabClient{client: gc, config: config}, nil
+}
diff --git a/gitlab_client_test.go b/gitlab_client_test.go
new file mode 100644
index 0000000..c1baf55
--- /dev/null
+++ b/gitlab_client_test.go
@@ -0,0 +1,15 @@
+package gitlab_test
+
+import (
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/require"
+ "testing"
+)
+
+func TestGitlabClient(t *testing.T) {
+ t.Run("nil config", func(t *testing.T) {
+ client, err := gitlab.NewGitlabClient(nil)
+ require.Nil(t, client)
+ require.ErrorIs(t, err, gitlab.ErrNilValue)
+ })
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..fd2c9ea
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,68 @@
+module github.com/ilijamt/vault-plugin-secrets-gitlab
+
+go 1.20
+
+require (
+ github.com/hashicorp/go-hclog v1.5.0
+ github.com/hashicorp/go-multierror v1.1.1
+ github.com/hashicorp/vault/api v1.9.2
+ github.com/hashicorp/vault/sdk v0.9.2
+ github.com/stretchr/testify v1.8.4
+ github.com/xanzy/go-gitlab v0.89.0
+ golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
+ google.golang.org/protobuf v1.31.0
+)
+
+require (
+ github.com/armon/go-metrics v0.4.1 // indirect
+ github.com/armon/go-radix v1.0.0 // indirect
+ github.com/cenkalti/backoff/v3 v3.2.2 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/evanphx/json-patch/v5 v5.6.0 // indirect
+ github.com/fatih/color v1.15.0 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.0 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/golang/snappy v0.0.4 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
+ github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect
+ github.com/hashicorp/go-kms-wrapping/v2 v2.0.12 // indirect
+ github.com/hashicorp/go-plugin v1.4.10 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.4 // indirect
+ github.com/hashicorp/go-rootcerts v1.0.2 // indirect
+ github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 // indirect
+ github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect
+ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+ github.com/hashicorp/go-sockaddr v1.0.2 // indirect
+ github.com/hashicorp/go-uuid v1.0.3 // indirect
+ github.com/hashicorp/go-version v1.6.0 // indirect
+ github.com/hashicorp/golang-lru v0.5.4 // indirect
+ github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
+ github.com/hashicorp/yamux v0.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/mitchellh/copystructure v1.2.0 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/go-testing-interface v1.14.1 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
+ github.com/oklog/run v1.1.0 // indirect
+ github.com/pierrec/lz4 v2.6.1+incompatible // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/ryanuber/go-glob v1.0.0 // indirect
+ github.com/stretchr/objx v0.5.1 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ golang.org/x/crypto v0.11.0 // indirect
+ golang.org/x/net v0.13.0 // indirect
+ golang.org/x/oauth2 v0.10.0 // indirect
+ golang.org/x/sys v0.11.0 // indirect
+ golang.org/x/text v0.11.0 // indirect
+ golang.org/x/time v0.3.0 // indirect
+ google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect
+ google.golang.org/grpc v1.57.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..88269bf
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,268 @@
+github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
+github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
+github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M=
+github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
+github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
+github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
+github.com/frankban/quicktest v1.11.3 h1:8sXhOn0uLys67V8EsXLc6eszDs8VXWxL3iRvebPhedY=
+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
+github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8=
+github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk=
+github.com/hashicorp/go-kms-wrapping/v2 v2.0.12 h1:zunij049cre7QOfXVAPGUMItPPKVHuvahC7s4p8IB8o=
+github.com/hashicorp/go-kms-wrapping/v2 v2.0.12/go.mod h1:NtMaPhqSlfQ72XWDD2g80o8HI8RKkowIB8/WZHMyPY4=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-plugin v1.4.10 h1:xUbmA4jC6Dq163/fWcp8P3JuHilrHHMLNRxzGQJ9hNk=
+github.com/hashicorp/go-plugin v1.4.10/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0=
+github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
+github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
+github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 h1:kH3Rhiht36xhAfhuHyWJDgdXXEx9IIZhDGRk24CDhzg=
+github.com/hashicorp/go-secure-stdlib/mlock v0.1.3/go.mod h1:ov1Q0oEDjC3+A4BwsG2YdKltrmEw8sf9Pau4V9JQ4Vo=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
+github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM=
+github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
+github.com/hashicorp/vault/api v1.9.2 h1:YjkZLJ7K3inKgMZ0wzCU9OHqc+UqMQyXsPXnf3Cl2as=
+github.com/hashicorp/vault/api v1.9.2/go.mod h1:jo5Y/ET+hNyz+JnKDt8XLAdKs+AM0G5W0Vp1IrFI8N8=
+github.com/hashicorp/vault/sdk v0.9.2 h1:H1kitfl1rG2SHbeGEyvhEqmIjVKE3E6c2q3ViKOs6HA=
+github.com/hashicorp/vault/sdk v0.9.2/go.mod h1:gG0lA7P++KefplzvcD3vrfCmgxVAM7Z/SqX5NeOL/98=
+github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
+github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
+github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
+github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
+github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/xanzy/go-gitlab v0.89.0 h1:yJuy1Pw+to/NqHzVIiopt/VApoHvGDB5SEGuRs3EJpI=
+github.com/xanzy/go-gitlab v0.89.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
+golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
+golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
+golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY=
+golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
+golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
+golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
+golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf h1:guOdSPaeFgN+jEJwTo1dQ71hdBm+yKSCCKuTRkJzcVo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I=
+google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
+google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/golangci.yml b/golangci.yml
new file mode 100644
index 0000000..025f471
--- /dev/null
+++ b/golangci.yml
@@ -0,0 +1,3 @@
+run:
+ deadline: 3m
+
diff --git a/path_config.go b/path_config.go
new file mode 100644
index 0000000..c5b3787
--- /dev/null
+++ b/path_config.go
@@ -0,0 +1,171 @@
+package gitlab
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/go-multierror"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/logical"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const (
+ PathConfigStorage = "config"
+)
+
+var (
+ fieldSchemaConfig = map[string]*framework.FieldSchema{
+ "token": {
+ Type: framework.TypeString,
+ Description: "The token to access Gitlab API",
+ Required: true,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Token",
+ Sensitive: true,
+ },
+ },
+ "base_url": {
+ Type: framework.TypeString,
+ Description: `The address to access Gitlab. Default is "https://gitlab.com".`,
+ Default: "https://gitlab.com",
+ },
+ "max_ttl": {
+ Type: framework.TypeDurationSecond,
+ Description: `Maximum lifetime expected generated token will be valid for. If set to 0 it will be set for maximum 8670 hours`,
+ Default: DefaultConfigFieldAccessTokenMaxTTL,
+ },
+ }
+)
+
+func (b *Backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ b.lockClientMutex.RLock()
+ defer b.lockClientMutex.RUnlock()
+
+ config, err := getConfig(ctx, req.Storage)
+ if err != nil {
+ return nil, err
+ }
+
+ if config == nil {
+ return logical.ErrorResponse(ErrBackendNotConfigured.Error()), nil
+ }
+
+ return &logical.Response{
+ Data: config.LogicalResponseData(),
+ }, nil
+}
+
+func (b *Backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ var warnings []string
+ var maxTtlRaw, maxTtlOk = data.GetOk("max_ttl")
+ var token, tokenOk = data.GetOk("token")
+ var err error
+
+ if !tokenOk {
+ err = multierror.Append(err, fmt.Errorf("token: %w", ErrFieldRequired))
+ }
+
+ var config = entryConfig{
+ BaseURL: data.Get("base_url").(string),
+ }
+
+ if maxTtlOk {
+ maxTtl := maxTtlRaw.(int)
+ switch {
+ case maxTtl > 0 && maxTtl < int(DefaultAccessTokenMinTTL.Seconds()):
+ warnings = append(warnings, "max_ttl is set with less than 24 hours. With current token expiry limitation, this max_ttl is ignored, it's set to 24 hours")
+ config.MaxTTL = DefaultAccessTokenMinTTL
+ case maxTtl <= 0:
+ config.MaxTTL = DefaultAccessTokenMaxPossibleTTL
+ warnings = append(warnings, "max_ttl is not set. Token wil be generated with expiration date of '8760 hours'")
+ case maxTtl > int(DefaultAccessTokenMaxPossibleTTL.Seconds()):
+ warnings = append(warnings, "max_ttl is set to more than '8760 hours'. Token wil be generated with expiration date of '8760 hours'")
+ config.MaxTTL = DefaultAccessTokenMaxPossibleTTL
+ default:
+ config.MaxTTL = time.Duration(maxTtl) * time.Second
+ }
+ } else if config.MaxTTL == 0 {
+ config.MaxTTL = DefaultAccessTokenMaxPossibleTTL
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ config.Token = token.(string)
+
+ b.lockClientMutex.Lock()
+ defer b.lockClientMutex.Unlock()
+
+ entry, err := logical.StorageEntryJSON(PathConfigStorage, config)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := req.Storage.Put(ctx, entry); err != nil {
+ return nil, err
+ }
+
+ event(ctx, b.Backend, "config-write", map[string]string{
+ "path": "config",
+ "max_ttl": config.MaxTTL.String(),
+ "base_url": config.BaseURL,
+ })
+
+ b.Logger().Debug("Wrote new config", "base_url", config.BaseURL, "max_ttl", config.MaxTTL)
+ return &logical.Response{
+ Data: config.LogicalResponseData(),
+ Warnings: warnings,
+ }, nil
+
+}
+
+func pathConfig(b *Backend) *framework.Path {
+ return &framework.Path{
+ HelpSynopsis: strings.TrimSpace(pathConfigHelpSynopsis),
+ HelpDescription: strings.TrimSpace(pathConfigHelpDescription),
+ Pattern: fmt.Sprintf("%s$", PathConfigStorage),
+ Fields: fieldSchemaConfig,
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationPrefix: operationPrefixGitlabAccessTokens,
+ },
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.UpdateOperation: &framework.PathOperation{
+ Callback: b.pathConfigWrite,
+ DisplayAttrs: &framework.DisplayAttributes{OperationVerb: "configure"},
+ Summary: "Configure Backend level settings that are applied to all credentials.",
+ Responses: map[int][]framework.Response{
+ http.StatusNoContent: {{
+ Description: http.StatusText(http.StatusNoContent),
+ }},
+ },
+ },
+ logical.ReadOperation: &framework.PathOperation{
+ Callback: b.pathConfigRead,
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationVerb: "read",
+ OperationSuffix: "configuration",
+ },
+ Summary: "Read the Backend level settings.",
+ Responses: map[int][]framework.Response{
+ http.StatusOK: {{
+ Description: http.StatusText(http.StatusOK),
+ Fields: fieldSchemaConfig,
+ }},
+ },
+ },
+ },
+ }
+}
+
+const pathConfigHelpSynopsis = `Configure the Gitlab Access Tokens Backend.`
+
+const pathConfigHelpDescription = `
+The Gitlab Access Tokens auth Backend requires credentials for managing
+private and group access tokens for Gitlab. This endpoint
+is used to configure those credentials and the default values for the Backend input general.
+
+You must specify expected Gitlab token with access to allow Vault to create tokens.
+`
diff --git a/path_config_test.go b/path_config_test.go
new file mode 100644
index 0000000..df5fb6f
--- /dev/null
+++ b/path_config_test.go
@@ -0,0 +1,183 @@
+package gitlab_test
+
+import (
+ "context"
+ "github.com/hashicorp/go-multierror"
+ "github.com/hashicorp/vault/sdk/logical"
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "testing"
+ "time"
+)
+
+func TestPathConfig(t *testing.T) {
+ t.Run("initial config should be empty", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ require.EqualValues(t, resp.Error(), gitlab.ErrBackendNotConfigured)
+ })
+
+ t.Run("write and then read config", func(t *testing.T) {
+ b, l, events, err := getBackendWithEvents()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": int((32 * time.Hour).Seconds()),
+ },
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ assert.EqualValues(t, "token", resp.Data["token"])
+ assert.NotEmpty(t, resp.Data["base_url"])
+ assert.EqualValues(t, int((32 * time.Hour).Seconds()), resp.Data["max_ttl"])
+ require.Len(t, events.eventsProcessed, 1)
+
+ events.expectEvents(t, []expectedEvent{
+ {
+ eventType: "gitlab/config-write",
+ },
+ })
+ })
+
+ t.Run("missing token from the request", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{},
+ })
+
+ require.Error(t, err)
+ require.Nil(t, resp)
+
+ var errorMap = countErrByName(err.(*multierror.Error))
+ assert.EqualValues(t, 1, errorMap[gitlab.ErrFieldRequired.Error()])
+ require.Len(t, errorMap, 1)
+ })
+
+ t.Run("if max_ttl is less than 24h, should be set to 24h", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": time.Hour * 23,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+ require.NoError(t, resp.Error())
+
+ assert.EqualValues(t, (time.Hour * 24).Seconds(), resp.Data["max_ttl"])
+ })
+
+ t.Run("if max_ttl is 0 or less than 0 set max_ttl to 8670 hours", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": 0,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+ require.NoError(t, resp.Error())
+
+ assert.EqualValues(t, (365 * 24 * time.Hour).Seconds(), resp.Data["max_ttl"])
+ })
+
+ t.Run("if max_ttl is 0 or less than 0 set max_ttl to 8670 hours", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": 0,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+ require.NoError(t, resp.Error())
+
+ assert.EqualValues(t, (365 * 24 * time.Hour).Seconds(), resp.Data["max_ttl"])
+ })
+
+ t.Run("if max_ttl is more than 8670 hours set max_ttl to 8670 hours", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": 366 * 24 * time.Hour,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+ require.NoError(t, resp.Error())
+
+ assert.EqualValues(t, (365 * 24 * time.Hour).Seconds(), resp.Data["max_ttl"])
+ })
+
+ t.Run("if max_ttl is between 24h and 8670h", func(t *testing.T) {
+ b, l, err := getBackend()
+ require.NoError(t, err)
+
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "token": "token",
+ "max_ttl": 14 * 24 * time.Hour,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Empty(t, resp.Warnings)
+
+ assert.EqualValues(t, (14 * 24 * time.Hour).Seconds(), resp.Data["max_ttl"])
+ })
+
+}
diff --git a/path_role.go b/path_role.go
new file mode 100644
index 0000000..b0f567c
--- /dev/null
+++ b/path_role.go
@@ -0,0 +1,383 @@
+package gitlab
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/go-multierror"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/helper/locksutil"
+ "github.com/hashicorp/vault/sdk/logical"
+ "golang.org/x/exp/slices"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const (
+ PathRoleStorage = "roles"
+)
+
+var (
+ fieldSchemaRoles = map[string]*framework.FieldSchema{
+ "role_name": {
+ Type: framework.TypeString,
+ Description: "Role name",
+ Required: true,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Role Name",
+ },
+ },
+ "path": {
+ Type: framework.TypeString,
+ Description: "Project/Group path to create an access token for. If the token type is set to personal then write the username here.",
+ Required: true,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "path",
+ },
+ },
+ "name": {
+ Type: framework.TypeString,
+ Description: "The name of the access token",
+ Required: true,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Name",
+ },
+ },
+ "scopes": {
+ Type: framework.TypeCommaStringSlice,
+ Description: "List of scopes",
+ Required: false,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Scopes",
+ },
+ AllowedValues: allowedValues(append(validTokenScopes, ValidPersonalTokenScopes...)...),
+ },
+ "token_ttl": {
+ Type: framework.TypeDurationSecond,
+ Description: "The TTL of the token",
+ Required: false,
+ Default: DefaultRoleFieldAccessTokenMaxTTL,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Token TTL",
+ },
+ },
+ "access_level": {
+ Type: framework.TypeString,
+ Description: "access level of access token (only required for Group and Project access tokens)",
+ Required: false,
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Access Level",
+ },
+ AllowedValues: allowedValues(ValidAccessLevels...),
+ },
+ "token_type": {
+ Type: framework.TypeString,
+ Description: "access token type",
+ Required: true,
+ AllowedValues: allowedValues(validTokenTypes...),
+ DisplayAttrs: &framework.DisplayAttributes{
+ Name: "Token Type",
+ },
+ },
+ }
+)
+
+func (b *Backend) pathRolesList(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ roles, err := req.Storage.List(ctx, fmt.Sprintf("%s/", PathRoleStorage))
+ if err != nil {
+ return logical.ErrorResponse("Error listing roles"), err
+ }
+ b.Logger().Debug("Available roles input the system", "roles", roles)
+ return logical.ListResponse(roles), nil
+}
+
+func pathListRoles(b *Backend) *framework.Path {
+ return &framework.Path{
+ HelpSynopsis: strings.TrimSpace(pathListRolesHelpSyn),
+ HelpDescription: strings.TrimSpace(pathListRolesHelpDesc),
+ Pattern: fmt.Sprintf("%s?/?$", PathRoleStorage),
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationPrefix: operationPrefixGitlabAccessTokens,
+ OperationSuffix: "roles",
+ },
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.ListOperation: &framework.PathOperation{
+ Callback: b.pathRolesList,
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationVerb: "list",
+ },
+ Responses: map[int][]framework.Response{
+ http.StatusOK: {{
+ Description: http.StatusText(http.StatusOK),
+ Fields: map[string]*framework.FieldSchema{
+ "role_name": fieldSchemaRoles["role_name"],
+ },
+ }},
+ },
+ },
+ },
+ }
+}
+
+func (b *Backend) pathRolesDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ var resp *logical.Response
+ var err error
+ var roleName string
+
+ if roleName = data.Get("role_name").(string); roleName == "" {
+ return logical.ErrorResponse("Unable to delete, missing role name"), nil
+ }
+
+ lock := locksutil.LockForKey(b.roleLocks, roleName)
+ lock.RLock()
+ defer lock.RUnlock()
+
+ _, err = getRole(ctx, roleName, req.Storage)
+ if err != nil {
+ return nil, fmt.Errorf("error getting role: %w", err)
+ }
+
+ err = req.Storage.Delete(ctx, fmt.Sprintf("%s/%s", PathRoleStorage, roleName))
+ if err != nil {
+ return nil, fmt.Errorf("error deleting role: %w", err)
+ }
+
+ event(ctx, b.Backend, "role-delete", map[string]string{
+ "path": "roles",
+ "role_name": roleName,
+ })
+
+ b.Logger().Debug("Role deleted", "role", roleName)
+
+ return resp, nil
+}
+
+func (b *Backend) pathRolesRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ var roleName string
+ if roleName = data.Get("role_name").(string); roleName == "" {
+ return logical.ErrorResponse("Unable to read, missing role name"), nil
+ }
+
+ lock := locksutil.LockForKey(b.roleLocks, roleName)
+ lock.RLock()
+ defer lock.RUnlock()
+
+ role, err := getRole(ctx, roleName, req.Storage)
+ if err != nil {
+ return logical.ErrorResponse("error reading role"), err
+ }
+
+ if role == nil {
+ return nil, nil
+ }
+
+ b.Logger().Debug("Role read", "role", roleName)
+
+ return &logical.Response{
+ Data: role.LogicalResponseData(),
+ }, nil
+}
+
+func (b *Backend) pathRolesWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ var roleName string
+ if roleName = data.Get("role_name").(string); roleName == "" {
+ return logical.ErrorResponse("Unable to write, missing role name"), nil
+ }
+
+ var config *entryConfig
+ var err error
+ var warnings []string
+ var tokenType TokenType
+ var accessLevel AccessLevel
+
+ b.lockClientMutex.RLock()
+ defer b.lockClientMutex.RUnlock()
+ config, err = getConfig(ctx, req.Storage)
+ if err != nil {
+ return logical.ErrorResponse("missing configuration for gitlab"), err
+ }
+
+ if config == nil {
+ return logical.ErrorResponse(ErrBackendNotConfigured.Error()), nil
+ }
+
+ tokenType, _ = TokenTypeParse(data.Get("token_type").(string))
+ accessLevel, _ = AccessLevelParse(data.Get("access_level").(string))
+
+ var role = entryRole{
+ RoleName: roleName,
+ TokenTTL: time.Duration(data.Get("token_ttl").(int)) * time.Second,
+ Path: data.Get("path").(string),
+ Name: data.Get("name").(string),
+ Scopes: data.Get("scopes").([]string),
+ AccessLevel: accessLevel,
+ TokenType: tokenType,
+ }
+
+ // validate token type
+ if !slices.Contains(validTokenTypes, tokenType.String()) {
+ err = multierror.Append(err, fmt.Errorf("token_type='%s', should be one of %v: %w", data.Get("token_type").(string), validTokenTypes, ErrFieldInvalidValue))
+ }
+
+ // check if all required fields are set
+ for name, field := range fieldSchemaRoles {
+ val, ok, _ := data.GetOkErr(name)
+ if tokenType == TokenTypePersonal && name == "access_level" {
+ continue
+ }
+ if field.Required && !ok {
+ err = multierror.Append(err, fmt.Errorf("%s: %w", name, ErrFieldRequired))
+ } else if !field.Required && val == nil {
+ warnings = append(warnings, fmt.Sprintf("field '%s' is using expected default value of %v", name, val))
+ }
+ }
+
+ // validate ttl to make sure it's less than max ttl in config, and above 24h
+ if role.TokenTTL > 0 && role.TokenTTL < DefaultAccessTokenMinTTL {
+ warnings = append(warnings, "token_ttl is set with less than 24 hours. With current token expiry limitation, this token_ttl is ignored, it will be set to 24 hours")
+ role.TokenTTL = DefaultAccessTokenMinTTL
+ } else if role.TokenTTL > config.MaxTTL && config.MaxTTL > 0 {
+ warnings = append(warnings, fmt.Sprintf("token_ttl needs to be less than %s, setting 'token_ttl' to %s", config.MaxTTL, config.MaxTTL))
+ role.TokenTTL = config.MaxTTL
+ } else if role.TokenTTL <= 0 {
+ role.TokenTTL = config.MaxTTL
+ warnings = append(warnings, fmt.Sprintf("token_ttl is set to 0. Tokens without ttls are not supported since Gitlab 16.0 setting to %d based on config max_ttl", config.MaxTTL))
+ }
+
+ // validate access level
+ var validAccessLevels []string
+ switch tokenType {
+ case TokenTypePersonal:
+ validAccessLevels = ValidPersonalAccessLevels
+ case TokenTypeGroup:
+ validAccessLevels = ValidGroupAccessLevels
+ case TokenTypeProject:
+ validAccessLevels = ValidProjectAccessLevels
+ }
+
+ if !slices.Contains(validAccessLevels, accessLevel.String()) {
+ err = multierror.Append(err, fmt.Errorf("access_level='%s', should be one of %v: %w", data.Get("access_level").(string), validAccessLevels, ErrFieldInvalidValue))
+ }
+
+ // validate scopes
+ var invalidScopes []string
+ var validScopes = validTokenScopes
+ if tokenType == TokenTypePersonal {
+ validScopes = append(validScopes, ValidPersonalTokenScopes...)
+ }
+ for _, scope := range role.Scopes {
+ if !slices.Contains(validScopes, scope) {
+ invalidScopes = append(invalidScopes, scope)
+ }
+ }
+
+ if len(invalidScopes) > 0 {
+ err = multierror.Append(err, fmt.Errorf("scopes='%v', should be one or more of '%v': %w", invalidScopes, validScopes, ErrFieldInvalidValue))
+ }
+
+ if err != nil {
+ return logical.ErrorResponse(err.Error()), err
+ }
+
+ lock := locksutil.LockForKey(b.roleLocks, roleName)
+ lock.Lock()
+ defer lock.Unlock()
+
+ entry, err := logical.StorageEntryJSON(fmt.Sprintf("%s/%s", PathRoleStorage, role.RoleName), role)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := req.Storage.Put(ctx, entry); err != nil {
+ return nil, err
+ }
+
+ event(ctx, b.Backend, "role-write", map[string]string{
+ "path": "roles",
+ "role_name": roleName,
+ })
+
+ b.Logger().Debug("Role written", "role", roleName)
+
+ return &logical.Response{
+ Data: role.LogicalResponseData(),
+ Warnings: warnings,
+ }, nil
+}
+
+func (b *Backend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
+ name := data.Get("role_name").(string)
+
+ role, err := getRole(ctx, name, req.Storage)
+ if err != nil {
+ if strings.Contains(err.Error(), logical.ErrReadOnly.Error()) {
+ return false, nil
+ }
+
+ return false, fmt.Errorf("error reading role: %w", err)
+ }
+
+ return role != nil, nil
+}
+
+func pathRoles(b *Backend) *framework.Path {
+ return &framework.Path{
+ HelpSynopsis: strings.TrimSpace(pathRolesHelpSyn),
+ HelpDescription: strings.TrimSpace(pathRolesHelpDesc),
+ Pattern: fmt.Sprintf("%s/%s", PathRoleStorage, framework.GenericNameWithAtRegex("role_name")),
+ Fields: fieldSchemaRoles,
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationPrefix: operationPrefixGitlabAccessTokens,
+ OperationSuffix: "role",
+ },
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.DeleteOperation: &framework.PathOperation{
+ Callback: b.pathRolesDelete,
+ Summary: "Deletes a role",
+ Responses: map[int][]framework.Response{
+ http.StatusNoContent: {{
+ Description: http.StatusText(http.StatusNoContent),
+ }},
+ },
+ },
+ logical.CreateOperation: &framework.PathOperation{
+ Callback: b.pathRolesWrite,
+ Summary: "Creates a new role",
+ Responses: map[int][]framework.Response{
+ http.StatusNoContent: {{
+ Description: http.StatusText(http.StatusNoContent),
+ }},
+ },
+ },
+ logical.UpdateOperation: &framework.PathOperation{
+ Callback: b.pathRolesWrite,
+ Summary: "Updates an existing role",
+ Responses: map[int][]framework.Response{
+ http.StatusNoContent: {{
+ Description: http.StatusText(http.StatusNoContent),
+ }},
+ },
+ },
+ logical.ReadOperation: &framework.PathOperation{
+ Callback: b.pathRolesRead,
+ Summary: "Reads an existing role",
+ Responses: map[int][]framework.Response{
+ http.StatusNotFound: {{
+ Description: http.StatusText(http.StatusNotFound),
+ }},
+ http.StatusOK: {{
+ Fields: fieldSchemaRoles,
+ }},
+ },
+ },
+ },
+ ExistenceCheck: b.pathRoleExistenceCheck,
+ }
+}
+
+const (
+ pathRolesHelpSyn = `Create a role with parameters that are used to generate a project, group or personal access token.`
+ pathRolesHelpDesc = `This path allows you to create a role whose parameters will be used to generate a project, group or personal access access token.`
+ pathListRolesHelpSyn = `Lists existing roles`
+ pathListRolesHelpDesc = `This path allows you to list all available roles.`
+)
diff --git a/path_role_test.go b/path_role_test.go
new file mode 100644
index 0000000..8009918
--- /dev/null
+++ b/path_role_test.go
@@ -0,0 +1,586 @@
+package gitlab_test
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/go-multierror"
+ "github.com/hashicorp/vault/sdk/logical"
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "testing"
+ "time"
+)
+
+func TestPathRolesList(t *testing.T) {
+ t.Run("empty list", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ListOperation,
+ Path: gitlab.PathRoleStorage, Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ assert.Empty(t, resp.Data)
+ })
+}
+
+func TestPathRoles(t *testing.T) {
+ var defaultConfig = map[string]interface{}{"token": "random-token"}
+ t.Run("delete non existing role", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.DeleteOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.Nil(t, resp)
+ })
+
+ t.Run("we get error if Backend is not set up during role write", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ require.EqualValues(t, gitlab.ErrBackendNotConfigured, resp.Error())
+ })
+
+ t.Run("access level", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+
+ t.Run(gitlab.TokenTypePersonal.String(), func(t *testing.T) {
+ t.Run("no access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypePersonal.String(),
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidPersonalTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Empty(t, resp.Warnings)
+ })
+ t.Run("with access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypePersonal.String(),
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidPersonalTokenScopes,
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ })
+ })
+
+ t.Run(gitlab.TokenTypeProject.String(), func(t *testing.T) {
+ t.Run("no access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypeProject.String(),
+ "token_type": gitlab.TokenTypeProject.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidProjectTokenScopes,
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ })
+ t.Run("with access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypeProject.String(),
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeProject.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidProjectTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Empty(t, resp.Warnings)
+ })
+ })
+
+ t.Run(gitlab.TokenTypeGroup.String(), func(t *testing.T) {
+ t.Run("no access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypeGroup.String(),
+ "token_type": gitlab.TokenTypeGroup.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidGroupTokenScopes,
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ })
+ t.Run("with access level defined", func(t *testing.T) {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": gitlab.TokenTypeGroup.String(),
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeGroup.String(),
+ "token_ttl": gitlab.DefaultAccessTokenMinTTL,
+ "scopes": gitlab.ValidGroupTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Empty(t, resp.Warnings)
+ })
+ })
+
+ })
+
+ t.Run("create with missing parameters", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{},
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ require.Error(t, resp.Error())
+ var errorMap = countErrByName(err.(*multierror.Error))
+ assert.EqualValues(t, 3, errorMap[gitlab.ErrFieldRequired.Error()])
+ assert.EqualValues(t, 2, errorMap[gitlab.ErrFieldInvalidValue.Error()])
+ })
+
+ t.Run("Project token scopes", func(t *testing.T) {
+ t.Run("valid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeProject.String(),
+ "scopes": gitlab.ValidProjectTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ })
+
+ t.Run("invalid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example project personal token",
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeProject.String(),
+ "scopes": gitlab.ValidPersonalTokenScopes,
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ var errorMap = countErrByName(err.(*multierror.Error))
+ assert.EqualValues(t, 1, errorMap[gitlab.ErrFieldInvalidValue.Error()])
+ })
+ })
+
+ t.Run("Personal token scopes", func(t *testing.T) {
+ t.Run("valid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "scopes": gitlab.ValidPersonalTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ })
+
+ t.Run("invalid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "scopes": []string{
+ "invalid_scope",
+ },
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ var errorMap = countErrByName(err.(*multierror.Error))
+ assert.EqualValues(t, 1, errorMap[gitlab.ErrFieldInvalidValue.Error()])
+ })
+ })
+
+ t.Run("Group token scopes", func(t *testing.T) {
+ t.Run("valid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeGroup.String(),
+ "scopes": gitlab.ValidProjectTokenScopes,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ })
+
+ t.Run("invalid scopes", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/%d", gitlab.PathRoleStorage, time.Now().UnixNano()), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "access_level": gitlab.AccessLevelOwnerPermissions.String(),
+ "token_type": gitlab.TokenTypeGroup.String(),
+ "scopes": gitlab.ValidPersonalTokenScopes,
+ },
+ })
+ require.Error(t, err)
+ require.NotNil(t, resp)
+ var errorMap = countErrByName(err.(*multierror.Error))
+ assert.EqualValues(t, 1, errorMap[gitlab.ErrFieldInvalidValue.Error()])
+ })
+ })
+
+ t.Run("24h > TokenTTL > MaxTTL (10 days)", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+
+ // create a configuration with max ttl set to 10 days
+ func() {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "max_ttl": (10 * 24 * time.Hour).Seconds(),
+ "token": "token",
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ }()
+
+ var roleData = map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": int64((12 * 24 * time.Hour).Seconds()),
+ "scopes": []string{
+ gitlab.TokenScopeApi.String(),
+ gitlab.TokenScopeReadRegistry.String(),
+ },
+ }
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.EqualValues(t, (10 * 24 * time.Hour).Seconds(), resp.Data["token_ttl"])
+ })
+
+ t.Run("0 > TokenTTL > 24h", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+
+ var roleData = map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": (12 * time.Hour).Seconds(),
+ "scopes": []string{
+ gitlab.TokenScopeApi.String(),
+ gitlab.TokenScopeReadRegistry.String(),
+ },
+ }
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.EqualValues(t, (24 * time.Hour).Seconds(), resp.Data["token_ttl"])
+ })
+
+ t.Run("not set token_ttl should default to 24h", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+
+ var roleData = map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "scopes": []string{
+ gitlab.TokenScopeApi.String(),
+ gitlab.TokenScopeReadRegistry.String(),
+ },
+ }
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.EqualValues(t, int64((24 * time.Hour).Seconds()), resp.Data["token_ttl"])
+ })
+
+ t.Run("token_ttl set to 0 should default to config max_ttl", func(t *testing.T) {
+ var b, l, err = getBackendWithConfig(defaultConfig)
+ require.NoError(t, err)
+
+ var roleData = map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": 0,
+ "scopes": []string{
+ gitlab.TokenScopeApi.String(),
+ gitlab.TokenScopeReadRegistry.String(),
+ },
+ }
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotEmpty(t, resp.Warnings)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.EqualValues(t, int64((365 * 24 * time.Hour).Seconds()), resp.Data["token_ttl"])
+ })
+
+ t.Run("update handler existence check", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+ hasExistenceCheck, exists, err := b.HandleExistenceCheck(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+
+ require.True(t, hasExistenceCheck)
+ require.False(t, exists)
+ require.NoError(t, err)
+ })
+
+ t.Run("full flow check roles", func(t *testing.T) {
+ var b, l, events, err = getBackendWithEvents()
+ require.NoError(t, err)
+
+ // create a configuration with max ttl set to 10 days
+ func() {
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: gitlab.PathConfigStorage, Storage: l,
+ Data: map[string]interface{}{
+ "max_ttl": (10 * 24 * time.Hour).Seconds(),
+ "token": "token",
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ }()
+
+ var roleData = map[string]interface{}{
+ "path": "user",
+ "name": "Example user personal token",
+ "token_type": gitlab.TokenTypePersonal.String(),
+ "token_ttl": int64((5 * 24 * time.Hour).Seconds()),
+ "scopes": []string{
+ gitlab.TokenScopeApi.String(),
+ gitlab.TokenScopeReadRegistry.String(),
+ },
+ }
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Empty(t, resp.Warnings)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.EqualValues(t, "test", resp.Data["role_name"])
+ require.Equal(t, int64((5 * 24 * time.Hour).Seconds()), resp.Data["token_ttl"])
+ require.Subset(t, resp.Data, roleData)
+
+ // update a role
+ roleData["name"] = "Example user personal token - updated"
+ roleData["path"] = "user2"
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: roleData,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Subset(t, resp.Data, roleData)
+ require.EqualValues(t, "test", resp.Data["role_name"])
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+ require.Subset(t, resp.Data, roleData)
+ require.EqualValues(t, "test", resp.Data["role_name"])
+ require.EqualValues(t, "user2", resp.Data["path"])
+ require.EqualValues(t, "Example user personal token - updated", resp.Data["name"])
+
+ // delete a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.DeleteOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.Nil(t, resp)
+
+ // read a role
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.Nil(t, resp)
+
+ // check the events
+ require.NotEmpty(t, events)
+
+ events.expectEvents(t, []expectedEvent{
+ {eventType: "gitlab/config-write"},
+ {eventType: "gitlab/role-write"},
+ {eventType: "gitlab/role-write"},
+ {eventType: "gitlab/role-delete"},
+ })
+
+ })
+
+}
diff --git a/path_token_role.go b/path_token_role.go
new file mode 100644
index 0000000..0e5bdb6
--- /dev/null
+++ b/path_token_role.go
@@ -0,0 +1,148 @@
+package gitlab
+
+import (
+ "context"
+ "crypto/rand"
+ "fmt"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/helper/locksutil"
+ "github.com/hashicorp/vault/sdk/logical"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ pathTokenRolesHelpSyn = ``
+ pathTokenRolesHelpDesc = ``
+
+ PathTokenRoleStorage = "token"
+)
+
+var (
+ fieldSchemaTokenRole = map[string]*framework.FieldSchema{
+ "role_name": {
+ Type: framework.TypeString,
+ Description: "Role name",
+ Required: true,
+ },
+ }
+)
+
+func (b *Backend) pathTokenRoleCreate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ var resp *logical.Response
+ var err error
+ var role *entryRole
+ var roleName string
+
+ if roleName = data.Get("role_name").(string); roleName == "" {
+ return logical.ErrorResponse("missing role name"), nil
+ }
+
+ lock := locksutil.LockForKey(b.roleLocks, roleName)
+ lock.RLock()
+ defer lock.RUnlock()
+
+ role, err = getRole(ctx, roleName, req.Storage)
+ if err != nil {
+ return nil, fmt.Errorf("error getting role: %w", err)
+ }
+ if role == nil {
+ return nil, fmt.Errorf("%s: %w", roleName, ErrRoleNotFound)
+ }
+
+ buf := make([]byte, 4)
+ _, _ = rand.Read(buf)
+ var token *EntryToken
+ var name = strings.ToLower(fmt.Sprintf("vault-generated-%s-access-token-%x", role.TokenType.String(), buf))
+ var expiresAt = time.Now().Add(role.TokenTTL)
+ var client Client
+
+ client, err = b.getClient(ctx, req.Storage)
+ if err != nil {
+ return nil, err
+ }
+
+ switch role.TokenType {
+ case TokenTypeGroup:
+ if token, err = client.CreateGroupAccessToken(role.Path, name, expiresAt, role.Scopes, role.AccessLevel); err != nil {
+ return nil, err
+ }
+ case TokenTypeProject:
+ if token, err = client.CreateProjectAccessToken(role.Path, name, expiresAt, role.Scopes, role.AccessLevel); err != nil {
+ return nil, err
+ }
+ case TokenTypePersonal:
+ var userId int
+ userId, err = client.GetUserIdByUsername(role.Path)
+ if err != nil {
+ return nil, err
+ }
+ if token, err = client.CreatePersonalAccessToken(role.Path, userId, name, expiresAt, role.Scopes); err != nil {
+ return nil, err
+ }
+ default:
+ return logical.ErrorResponse("invalid token type"), fmt.Errorf("%s: %w", role.TokenType.String(), ErrUnknownTokenType)
+ }
+
+ var secretData, secretInternal = token.SecretResponse()
+ resp = b.Secret(secretAccessTokenType).Response(secretData, secretInternal)
+ resp.Secret.MaxTTL = role.TokenTTL
+ resp.Secret.TTL = token.ExpiresAt.Sub(*token.CreatedAt)
+
+ event(ctx, b.Backend, "token-write", map[string]string{
+ "path": fmt.Sprintf("%s/%s", PathRoleStorage, roleName),
+ "name": name,
+ "parent_id": role.Path,
+ "role_name": roleName,
+ "token_id": strconv.Itoa(token.TokenID),
+ "token_type": role.TokenType.String(),
+ "scopes": strings.Join(role.Scopes, ","),
+ "access_level": role.AccessLevel.String(),
+ })
+ return resp, nil
+}
+
+func pathTokenRoles(b *Backend) *framework.Path {
+ return &framework.Path{
+ HelpSynopsis: strings.TrimSpace(pathTokenRolesHelpSyn),
+ HelpDescription: strings.TrimSpace(pathTokenRolesHelpDesc),
+ Pattern: fmt.Sprintf("%s/%s", PathTokenRoleStorage, framework.GenericNameWithAtRegex("role_name")),
+ Fields: fieldSchemaTokenRole,
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationPrefix: operationPrefixGitlabAccessTokens,
+ OperationSuffix: "generate",
+ },
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.ReadOperation: &framework.PathOperation{
+ Callback: b.pathTokenRoleCreate,
+ Summary: "Create an access token based on a predefined role",
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationVerb: "generate",
+ OperationSuffix: "credentials",
+ },
+ Responses: map[int][]framework.Response{
+ http.StatusOK: {{
+ Description: http.StatusText(http.StatusOK),
+ Fields: fieldSchemaAccessTokens,
+ }},
+ },
+ },
+ logical.UpdateOperation: &framework.PathOperation{
+ Callback: b.pathTokenRoleCreate,
+ Summary: "Create an access token based on a predefined role",
+ DisplayAttrs: &framework.DisplayAttributes{
+ OperationSuffix: "credentials-with-parameters",
+ OperationVerb: "generate-with-parameters",
+ },
+ Responses: map[int][]framework.Response{
+ http.StatusOK: {{
+ Description: http.StatusText(http.StatusOK),
+ Fields: fieldSchemaAccessTokens,
+ }},
+ },
+ },
+ },
+ }
+}
diff --git a/path_token_role_test.go b/path_token_role_test.go
new file mode 100644
index 0000000..8f79652
--- /dev/null
+++ b/path_token_role_test.go
@@ -0,0 +1,125 @@
+package gitlab_test
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/vault/sdk/logical"
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/require"
+ "testing"
+)
+
+func TestPathTokenRoles(t *testing.T) {
+ var defaultConfig = map[string]interface{}{"token": "random-token"}
+
+ t.Run("role not found", func(t *testing.T) {
+ var b, l, err = getBackend()
+ require.NoError(t, err)
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathTokenRoleStorage), Storage: l,
+ })
+ require.Error(t, err)
+ require.Nil(t, resp)
+ require.ErrorIs(t, err, gitlab.ErrRoleNotFound)
+ })
+
+ var generalTokenCreation = func(t *testing.T, tokenType gitlab.TokenType, level gitlab.AccessLevel) {
+ var b, l, events, err = getBackendWithEvents()
+ require.NoError(t, err)
+ require.NoError(t, writeBackendConfig(b, l, defaultConfig))
+ require.NoError(t, err)
+
+ events.expectEvents(t, []expectedEvent{
+ {eventType: "gitlab/config-write"},
+ })
+
+ client := newInMemoryClient(true)
+ b.SetClient(client)
+
+ // create a role
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.CreateOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathRoleStorage), Storage: l,
+ Data: map[string]interface{}{
+ "path": "user",
+ "name": tokenType.String(),
+ "token_type": tokenType.String(),
+ "access_level": level,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NoError(t, resp.Error())
+
+ // read an access token
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.ReadOperation,
+ Path: fmt.Sprintf("%s/test", gitlab.PathTokenRoleStorage), Storage: l,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.NotNil(t, resp.Secret)
+ require.NoError(t, resp.Error())
+
+ var tokenId = resp.Secret.InternalData["token_id"].(int)
+ var leaseId = resp.Secret.LeaseID
+ var secret = resp.Secret
+
+ require.Contains(t, client.accessTokens, fmt.Sprintf("%s_%v", tokenType.String(), tokenId))
+
+ // revoke the access token
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.RevokeOperation,
+ Path: fmt.Sprintf("%s/%s", gitlab.PathTokenRoleStorage, leaseId), Storage: l,
+ Secret: secret,
+ })
+ require.NoError(t, err)
+ require.Nil(t, resp)
+ require.NotContains(t, client.accessTokens, fmt.Sprintf("%s_%v", tokenType.String(), tokenId))
+
+ // calling revoke with nil secret
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.RevokeOperation,
+ Path: fmt.Sprintf("%s/%s", gitlab.PathTokenRoleStorage, leaseId), Storage: l,
+ })
+ require.Error(t, err)
+ require.Nil(t, resp)
+
+ // calling revoke again would return a token not found in internal error
+ switch tokenType {
+ case gitlab.TokenTypeProject:
+ client.projectAccessTokenRevokeError = true
+ case gitlab.TokenTypePersonal:
+ client.personalAccessTokenRevokeError = true
+ case gitlab.TokenTypeGroup:
+ client.groupAccessTokenRevokeError = true
+ }
+ resp, err = b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.RevokeOperation,
+ Path: fmt.Sprintf("%s/%s", gitlab.PathTokenRoleStorage, leaseId), Storage: l,
+ Secret: secret,
+ })
+ require.Error(t, err)
+ require.Error(t, resp.Error())
+
+ events.expectEvents(t, []expectedEvent{
+ {eventType: "gitlab/config-write"},
+ {eventType: "gitlab/role-write"},
+ {eventType: "gitlab/token-write"},
+ {eventType: "gitlab/token-revoke"},
+ })
+ }
+
+ t.Run("personal access token", func(t *testing.T) {
+ generalTokenCreation(t, gitlab.TokenTypePersonal, gitlab.AccessLevelUnknown)
+ })
+
+ t.Run("project access token", func(t *testing.T) {
+ generalTokenCreation(t, gitlab.TokenTypeProject, gitlab.AccessLevelGuestPermissions)
+ })
+
+ t.Run("group access token", func(t *testing.T) {
+ generalTokenCreation(t, gitlab.TokenTypeGroup, gitlab.AccessLevelGuestPermissions)
+ })
+}
diff --git a/secret_access_tokens.go b/secret_access_tokens.go
new file mode 100644
index 0000000..1c808b7
--- /dev/null
+++ b/secret_access_tokens.go
@@ -0,0 +1,113 @@
+package gitlab
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/logical"
+ "strconv"
+)
+
+const (
+ secretAccessTokenType = "access_tokens"
+)
+
+var (
+ fieldSchemaAccessTokens = map[string]*framework.FieldSchema{
+ "name": {
+ Type: framework.TypeString,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Token name"},
+ },
+ "token": {
+ Type: framework.TypeString,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Token"},
+ },
+ "path": {
+ Type: framework.TypeString,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Path"},
+ },
+ "scopes": {
+ Type: framework.TypeStringSlice,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Scopes"},
+ },
+ "access_level": {
+ Type: framework.TypeString,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Access Level"},
+ },
+ "expires_at": {
+ Type: framework.TypeTime,
+ DisplayAttrs: &framework.DisplayAttributes{Name: "Expires At"},
+ },
+ }
+)
+
+func secretAccessTokens(b *Backend) *framework.Secret {
+ return &framework.Secret{
+ Type: secretAccessTokenType,
+ Fields: fieldSchemaAccessTokens,
+ Revoke: b.secretAccessTokenRevoke,
+ }
+}
+
+func (b *Backend) secretAccessTokenRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
+ var config *entryConfig
+ var err error
+ config, err = getConfig(ctx, req.Storage)
+ if err != nil {
+ return nil, err
+ }
+ if config == nil {
+ return logical.ErrorResponse(ErrBackendNotConfigured.Error()), nil
+ }
+
+ var secret = req.Secret
+ if secret == nil {
+ return nil, fmt.Errorf("secret: %w", ErrNilValue)
+ }
+
+ var client Client
+ client, err = b.getClient(ctx, req.Storage)
+ if err != nil {
+ return nil, fmt.Errorf("revoke token: %w", err)
+ }
+
+ var tokenId int
+ tokenId, err = convertToInt(req.Secret.InternalData["token_id"])
+ if err != nil {
+ return nil, fmt.Errorf("token_id: %w", err)
+ }
+
+ var parentId = req.Secret.InternalData["parent_id"].(string)
+ var tokenType TokenType
+ var tokenTypeValue = req.Secret.InternalData["token_type"].(string)
+
+ tokenType, err = TokenTypeParse(tokenTypeValue)
+ if err != nil {
+ // shouldn't be possible to hit due to the guards in the creation of the roles
+ return nil, fmt.Errorf("%s: %w", tokenTypeValue, ErrUnknownTokenType)
+ }
+
+ switch tokenType {
+ case TokenTypePersonal:
+ err = client.RevokePersonalAccessToken(tokenId)
+ case TokenTypeProject:
+ err = client.RevokeProjectAccessToken(tokenId, parentId)
+ case TokenTypeGroup:
+ err = client.RevokeGroupAccessToken(tokenId, parentId)
+ }
+
+ if err != nil && !errors.Is(err, ErrAccessTokenNotFound) {
+ return logical.ErrorResponse("failed to revoke token"), fmt.Errorf("revoke token: %w", err)
+ }
+
+ event(ctx, b.Backend, "token-revoke", map[string]string{
+ "lease_id": secret.LeaseID,
+ "path": req.Secret.InternalData["path"].(string),
+ "name": req.Secret.InternalData["name"].(string),
+ "token_id": strconv.Itoa(tokenId),
+ "token_type": tokenTypeValue,
+ })
+
+ return nil, nil
+}
diff --git a/type_access_level.go b/type_access_level.go
new file mode 100644
index 0000000..103cc8d
--- /dev/null
+++ b/type_access_level.go
@@ -0,0 +1,86 @@
+package gitlab
+
+import (
+ "errors"
+ "fmt"
+ "github.com/xanzy/go-gitlab"
+ "golang.org/x/exp/slices"
+)
+
+type AccessLevel string
+
+const (
+ AccessLevelNoPermissions = AccessLevel("no_permissions")
+ AccessLevelMinimalAccessPermissions = AccessLevel("minimal_access")
+ AccessLevelGuestPermissions = AccessLevel("guest")
+ AccessLevelReporterPermissions = AccessLevel("reporter")
+ AccessLevelDeveloperPermissions = AccessLevel("developer")
+ AccessLevelMaintainerPermissions = AccessLevel("maintainer")
+ AccessLevelOwnerPermissions = AccessLevel("owner")
+
+ AccessLevelUnknown = AccessLevel("")
+)
+
+var (
+ ErrUnknownAccessLevel = errors.New("unknown access level")
+
+ ValidAccessLevels = []string{
+ AccessLevelNoPermissions.String(),
+ AccessLevelMinimalAccessPermissions.String(),
+ AccessLevelGuestPermissions.String(),
+ AccessLevelReporterPermissions.String(),
+ AccessLevelDeveloperPermissions.String(),
+ AccessLevelMaintainerPermissions.String(),
+ AccessLevelOwnerPermissions.String(),
+ }
+
+ ValidPersonalAccessLevels = []string{
+ AccessLevelUnknown.String(),
+ }
+ ValidProjectAccessLevels = []string{
+ AccessLevelGuestPermissions.String(),
+ AccessLevelReporterPermissions.String(),
+ AccessLevelDeveloperPermissions.String(),
+ AccessLevelMaintainerPermissions.String(),
+ AccessLevelOwnerPermissions.String(),
+ }
+ ValidGroupAccessLevels = []string{
+ AccessLevelGuestPermissions.String(),
+ AccessLevelReporterPermissions.String(),
+ AccessLevelDeveloperPermissions.String(),
+ AccessLevelMaintainerPermissions.String(),
+ AccessLevelOwnerPermissions.String(),
+ }
+)
+
+func (i AccessLevel) String() string {
+ return string(i)
+}
+
+func (i AccessLevel) Value() int {
+ switch i {
+ case AccessLevelNoPermissions:
+ return int(gitlab.NoPermissions)
+ case AccessLevelMinimalAccessPermissions:
+ return int(gitlab.MinimalAccessPermissions)
+ case AccessLevelGuestPermissions:
+ return int(gitlab.GuestPermissions)
+ case AccessLevelReporterPermissions:
+ return int(gitlab.ReporterPermissions)
+ case AccessLevelDeveloperPermissions:
+ return int(gitlab.DeveloperPermissions)
+ case AccessLevelMaintainerPermissions:
+ return int(gitlab.MaintainerPermissions)
+ case AccessLevelOwnerPermissions:
+ return int(gitlab.OwnerPermissions)
+ }
+
+ return -1
+}
+
+func AccessLevelParse(value string) (AccessLevel, error) {
+ if slices.Contains(ValidAccessLevels, value) {
+ return AccessLevel(value), nil
+ }
+ return AccessLevelUnknown, fmt.Errorf("failed to parse '%s': %w", value, ErrUnknownAccessLevel)
+}
diff --git a/type_access_level_test.go b/type_access_level_test.go
new file mode 100644
index 0000000..921d9f6
--- /dev/null
+++ b/type_access_level_test.go
@@ -0,0 +1,62 @@
+package gitlab_test
+
+import (
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestAccessLevel(t *testing.T) {
+ var tests = []struct {
+ expected gitlab.AccessLevel
+ input string
+ err bool
+ }{
+ {
+ expected: gitlab.AccessLevelOwnerPermissions,
+ input: gitlab.AccessLevelOwnerPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelReporterPermissions,
+ input: gitlab.AccessLevelReporterPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelMaintainerPermissions,
+ input: gitlab.AccessLevelMaintainerPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelDeveloperPermissions,
+ input: gitlab.AccessLevelDeveloperPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelGuestPermissions,
+ input: gitlab.AccessLevelGuestPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelNoPermissions,
+ input: gitlab.AccessLevelNoPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelMinimalAccessPermissions,
+ input: gitlab.AccessLevelMinimalAccessPermissions.String(),
+ },
+ {
+ expected: gitlab.AccessLevelUnknown,
+ input: "unknown",
+ err: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Logf("assert parse(%s) = %s (err: %v)", test.input, test.expected, test.err)
+ val, err := gitlab.AccessLevelParse(test.input)
+ assert.EqualValues(t, test.expected, val)
+ if test.err {
+ assert.ErrorIs(t, err, gitlab.ErrUnknownAccessLevel)
+ assert.Less(t, val.Value(), 0)
+ } else {
+ assert.NoError(t, err)
+ assert.GreaterOrEqual(t, val.Value(), 0)
+ }
+ }
+}
diff --git a/type_token_scope.go b/type_token_scope.go
new file mode 100644
index 0000000..b6145f4
--- /dev/null
+++ b/type_token_scope.go
@@ -0,0 +1,75 @@
+package gitlab
+
+import (
+ "errors"
+ "fmt"
+ "golang.org/x/exp/slices"
+)
+
+type TokenScope string
+
+const (
+ // TokenScopeApi grants complete read and write access to the scoped group and related project API, including the Package Registry
+ TokenScopeApi = TokenScope("api")
+ // TokenScopeReadApi grants read access to the scoped group and related project API, including the Package Registry
+ TokenScopeReadApi = TokenScope("read_api")
+ // TokenScopeReadRegistry grants read access (pull) to the Container Registry images if any project within expected group is private and authorization is required.
+ TokenScopeReadRegistry = TokenScope("read_registry")
+ // TokenScopeWriteRegistry grants write access (push) to the Container Registry.
+ TokenScopeWriteRegistry = TokenScope("write_registry")
+ // TokenScopeReadRepository grants read access (pull) to the Container Registry images if any project within expected group is private and authorization is required
+ TokenScopeReadRepository = TokenScope("read_repository")
+ // TokenScopeWriteRepository grants read and write access (pull and push) to all repositories within expected group
+ TokenScopeWriteRepository = TokenScope("write_repository")
+ // TokenScopeCreateRunner grants permission to create runners in expected group
+ TokenScopeCreateRunner = TokenScope("create_runner")
+
+ // TokenScopeReadUser grants read-only access to the authenticated user’s profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users.
+ TokenScopeReadUser = TokenScope("read_user")
+ // TokenScopeSudo grants permission to perform API actions as any user in the system, when authenticated as an administrator.
+ TokenScopeSudo = TokenScope("sudo")
+ // TokenScopeAdminMode grants permission to perform API actions as an administrator, when Admin Mode is enabled.
+ TokenScopeAdminMode = TokenScope("admin_mode")
+
+ TokenScopeUnknown = TokenScope("")
+)
+
+var (
+ ErrUnknownTokenScope = errors.New("unknown token scope")
+
+ validTokenScopes = []string{
+ TokenScopeApi.String(),
+ TokenScopeReadApi.String(),
+ TokenScopeReadRegistry.String(),
+ TokenScopeWriteRegistry.String(),
+ TokenScopeReadRepository.String(),
+ TokenScopeWriteRepository.String(),
+ TokenScopeCreateRunner.String(),
+ }
+
+ ValidGroupTokenScopes = validTokenScopes
+ ValidProjectTokenScopes = validTokenScopes
+
+ ValidPersonalTokenScopes = []string{
+ TokenScopeReadUser.String(),
+ TokenScopeSudo.String(),
+ TokenScopeAdminMode.String(),
+ }
+)
+
+func (i TokenScope) String() string {
+ return string(i)
+}
+
+func (i TokenScope) Value() string {
+ return i.String()
+}
+
+func TokenScopeParse(value string) (TokenScope, error) {
+ if slices.Contains(ValidGroupTokenScopes, value) ||
+ slices.Contains(ValidPersonalTokenScopes, value) ||
+ slices.Contains(ValidProjectTokenScopes, value) {
+ return TokenScope(value), nil
+ }
+ return TokenScopeUnknown, fmt.Errorf("failed to parse '%s': %w", value, ErrUnknownTokenScope)
+}
diff --git a/type_token_scope_test.go b/type_token_scope_test.go
new file mode 100644
index 0000000..e10af48
--- /dev/null
+++ b/type_token_scope_test.go
@@ -0,0 +1,78 @@
+package gitlab_test
+
+import (
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestTokenScope(t *testing.T) {
+ var tests = []struct {
+ expected gitlab.TokenScope
+ input string
+ err bool
+ }{
+ {
+ expected: gitlab.TokenScopeApi,
+ input: gitlab.TokenScopeApi.String(),
+ },
+ {
+ expected: gitlab.TokenScopeReadApi,
+ input: gitlab.TokenScopeReadApi.String(),
+ },
+ {
+ expected: gitlab.TokenScopeReadRegistry,
+ input: gitlab.TokenScopeReadRegistry.String(),
+ },
+ {
+ expected: gitlab.TokenScopeWriteRegistry,
+ input: gitlab.TokenScopeWriteRegistry.String(),
+ },
+ {
+ expected: gitlab.TokenScopeReadRepository,
+ input: gitlab.TokenScopeReadRepository.String(),
+ },
+ {
+ expected: gitlab.TokenScopeWriteRepository,
+ input: gitlab.TokenScopeWriteRepository.String(),
+ },
+ {
+ expected: gitlab.TokenScopeCreateRunner,
+ input: gitlab.TokenScopeCreateRunner.String(),
+ },
+ {
+ expected: gitlab.TokenScopeReadUser,
+ input: gitlab.TokenScopeReadUser.String(),
+ },
+ {
+ expected: gitlab.TokenScopeSudo,
+ input: gitlab.TokenScopeSudo.String(),
+ },
+ {
+ expected: gitlab.TokenScopeAdminMode,
+ input: gitlab.TokenScopeAdminMode.String(),
+ },
+ {
+ expected: gitlab.TokenScopeUnknown,
+ input: "what",
+ err: true,
+ },
+ {
+ expected: gitlab.TokenScopeUnknown,
+ input: "unknown",
+ err: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Logf("assert parse(%s) = %s (err: %v)", test.input, test.expected, test.err)
+ val, err := gitlab.TokenScopeParse(test.input)
+ assert.EqualValues(t, test.expected, val)
+ assert.EqualValues(t, test.expected.Value(), test.expected.String())
+ if test.err {
+ assert.ErrorIs(t, err, gitlab.ErrUnknownTokenScope)
+ } else {
+ assert.NoError(t, err)
+ }
+ }
+}
diff --git a/type_token_type.go b/type_token_type.go
new file mode 100644
index 0000000..bb2aa5f
--- /dev/null
+++ b/type_token_type.go
@@ -0,0 +1,42 @@
+package gitlab
+
+import (
+ "errors"
+ "fmt"
+ "golang.org/x/exp/slices"
+)
+
+type TokenType string
+
+const (
+ TokenTypePersonal = TokenType("personal")
+ TokenTypeProject = TokenType("project")
+ TokenTypeGroup = TokenType("group")
+
+ TokenTypeUnknown = TokenType("")
+)
+
+var (
+ ErrUnknownTokenType = errors.New("unknown token type")
+
+ validTokenTypes = []string{
+ TokenTypePersonal.String(),
+ TokenTypeProject.String(),
+ TokenTypeGroup.String(),
+ }
+)
+
+func (i TokenType) String() string {
+ return string(i)
+}
+
+func (i TokenType) Value() string {
+ return i.String()
+}
+
+func TokenTypeParse(value string) (TokenType, error) {
+ if slices.Contains(validTokenTypes, value) {
+ return TokenType(value), nil
+ }
+ return TokenTypeUnknown, fmt.Errorf("failed to parse '%s': %w", value, ErrUnknownTokenType)
+}
diff --git a/type_token_type_test.go b/type_token_type_test.go
new file mode 100644
index 0000000..675c6ee
--- /dev/null
+++ b/type_token_type_test.go
@@ -0,0 +1,50 @@
+package gitlab_test
+
+import (
+ gitlab "github.com/ilijamt/vault-plugin-secrets-gitlab"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestTokenType(t *testing.T) {
+ var tests = []struct {
+ expected gitlab.TokenType
+ input string
+ err bool
+ }{
+ {
+ expected: gitlab.TokenTypePersonal,
+ input: gitlab.TokenTypePersonal.String(),
+ },
+ {
+ expected: gitlab.TokenTypeGroup,
+ input: gitlab.TokenTypeGroup.String(),
+ },
+ {
+ expected: gitlab.TokenTypeProject,
+ input: gitlab.TokenTypeProject.String(),
+ },
+ {
+ expected: gitlab.TokenTypeUnknown,
+ input: "unknown",
+ err: true,
+ },
+ {
+ expected: gitlab.TokenTypeUnknown,
+ input: "unknown",
+ err: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Logf("assert parse(%s) = %s (err: %v)", test.input, test.expected, test.err)
+ val, err := gitlab.TokenTypeParse(test.input)
+ assert.EqualValues(t, test.expected, val)
+ assert.EqualValues(t, test.expected.Value(), test.expected.String())
+ if test.err {
+ assert.ErrorIs(t, err, gitlab.ErrUnknownTokenType)
+ } else {
+ assert.NoError(t, err)
+ }
+ }
+}
diff --git a/utils.go b/utils.go
new file mode 100644
index 0000000..892ae94
--- /dev/null
+++ b/utils.go
@@ -0,0 +1,20 @@
+package gitlab
+
+import "fmt"
+
+func allowedValues(values ...string) (ret []interface{}) {
+ for _, value := range values {
+ ret = append(ret, value)
+ }
+ return ret
+}
+
+func convertToInt(num any) (int, error) {
+ switch val := num.(type) {
+ case int:
+ return val, nil
+ case float64:
+ return int(val), nil
+ }
+ return 0, fmt.Errorf("%v: %w", num, ErrInvalidValue)
+}
diff --git a/version/version.go b/version/version.go
new file mode 100644
index 0000000..12d1f49
--- /dev/null
+++ b/version/version.go
@@ -0,0 +1,12 @@
+package version
+
+import "fmt"
+
+const Version = "0.2.0"
+
+var (
+ Name string
+ GitCommit string
+
+ HumanVersion = fmt.Sprintf("%s v%s (%s)", Name, Version, GitCommit)
+)