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) +)