diff --git a/README.md b/README.md index 8bf98c1..9e936ff 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![codecov-badge]][codecov] ![go-version-badge] -This is a backend plugin to be used with Vault. This plugin generates [Gitlab Project Access Tokens][pat] +This is a backend plugin to be used with Vault. This plugin generates [Project Access Tokens][pat] and [Group Access Tokens][gat] - [Requirements](#requirements) - [Getting Started](#getting-started) @@ -17,12 +17,12 @@ This is a backend plugin to be used with Vault. This plugin generates [Gitlab Pr ## Requirements -- Gitlab instance with **13.10** or later for API compatibility +- Gitlab instance with **13.10** or later for API compatibility for [Project Access Tokens][pat] and **14.7** or later for [Group Access Tokens][gat] - You need **14.1** or later to have access level - Self-managed instances on Free and above. Or, GitLab SaaS Premium and above -- a token of a user with maintainer or higher permission in a project +- a token of a user with maintainer or higher permission in a project or group -- Lifting API rate limit for the user whose token will be used in this plugin to generate/revoke project access tokens. Admin of self-hosted can check [this doc][lift rate limit] to allow specific users to bypass authenticated request rate limiting. For SaaS Gitlab, I have not confirmed how to lift API limit yet. +- Lifting API rate limit for the user whose token will be used in this plugin to generate/revoke project/group access tokens. Admin of self-hosted can check [this doc][lift rate limit] to allow specific users to bypass authenticated request rate limiting. For SaaS Gitlab, I have not confirmed how to lift API limit yet. ## Getting Started @@ -143,6 +143,7 @@ Please refer [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF [Apache Software License version 2.0](LICENSE) [pat]: https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html +[gat]: https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html [lift rate limit]: https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#allow-specific-users-to-bypass-authenticated-request-rate-limiting [vault-plugin-secrets-artifactory]: https://github.com/splunk/vault-plugin-secrets-artifactory [vault plugin]:https://www.vaultproject.io/docs/internals/plugins.html diff --git a/docs/backlogs.md b/docs/backlogs.md index 6cb33f6..81d9882 100644 --- a/docs/backlogs.md +++ b/docs/backlogs.md @@ -31,6 +31,6 @@ For comprehensive CI, ## Acceptance Testing -Running test against real servers doesn't seem good idea. Create an isolated environment by spinning up vault and gitlab in docker in CI. Then, run full suite of testing there. *Self-hosted GitLab has project access token available from free version* +Running test against real servers doesn't seem good idea. Create an isolated environment by spinning up vault and gitlab in docker in CI. Then, run full suite of testing there. *Self-hosted GitLab has project/group access token available from free version* [granular control on token expiry]: https://gitlab.com/gitlab-org/gitlab/-/issues/335535 diff --git a/docs/design-principles.md b/docs/design-principles.md index fd668ca..84b6a23 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -9,11 +9,11 @@ This plugin supports two ways to generate a token in `/token` path 1. At root of `/token` path, a user requests a token by passing parameters. 2. (WIP): A user predefines roles with parameters. Then, a user can request a role's token at `/token/:` -Parameters are same from Gitlab's [Project Access Token API] +Parameters are same from Gitlab's [Project Access Token API] and [Group Access Token API], make sure to pass `type` field with project/group path `/token` -- Create/Update: generate a project access token with given parameters +- Create/Update: generate a project/group access token with given parameters path `/roles/:` @@ -24,7 +24,7 @@ path `/roles/:` path `/token/:` -- Create/Update: generate a project access token with stored parameters for the role +- Create/Update: generate a project/group access token with stored parameters for the role ## Things to Note @@ -35,8 +35,9 @@ There are 2 kinds of access control in this plugins. 1. permissions attaches to the configured token 1. Vault resource access control - path access and capabilities -Root `/token` path can be used to request a project access token for any projects and any scopes as long as the configured token to generate access tokens have necessary permissions in these projects. 2nd kind of access token can't limit parameters to pass. +Root `/token` path can be used to request a project/group access token for any projects/groups and any scopes as long as the configured token to generate access tokens have necessary permissions in these projects/groups. 2nd kind of access token can't limit parameters to pass. -With that being said, it's better to use **roles**, which predefines a project and scopes; then, requesting a project access token for a role. You can further limit access to path via 2nd kind of access control imposed by Vault +With that being said, it's better to use **roles**, which predefines a project/groups and scopes; then, requesting a project/group access token for a role. You can further limit access to path via 2nd kind of access control imposed by Vault [Project Access Token API]: https://docs.gitlab.com/ee/api/resource_access_tokens.html +[Group Access Token API]: https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html diff --git a/plugin/const.go b/plugin/const.go index 8285556..8204113 100644 --- a/plugin/const.go +++ b/plugin/const.go @@ -18,6 +18,8 @@ import "github.com/xanzy/go-gitlab" type PAT = gitlab.ProjectAccessToken +type GAT = gitlab.GroupAccessToken + const ( pathPatternConfig = "config" pathPatternToken = "token" diff --git a/plugin/gitlab_client.go b/plugin/gitlab_client.go index b61ab8e..03c27ee 100644 --- a/plugin/gitlab_client.go +++ b/plugin/gitlab_client.go @@ -29,6 +29,7 @@ type Client interface { // ListProjectAccessToken(int) ([]*PAT, error) CreateProjectAccessToken(*BaseTokenStorageEntry, *time.Time) (*PAT, error) // RevokeProjectAccessToken(*BaseTokenStorageEntry) error + CreateGroupAccessToken(*BaseTokenStorageEntry, *time.Time) (*GAT, error) Valid() bool } @@ -90,3 +91,22 @@ func (gc *gitlabClient) CreateProjectAccessToken(tokenStorage *BaseTokenStorageE // func (gc *gitlabClient) RevokeProjectAccessToken(tokenStorage *BaseTokenStorageEntry) error { // return nil // } + +func (gc *gitlabClient) CreateGroupAccessToken(tokenStorage *BaseTokenStorageEntry, expiresAt *time.Time) (*GAT, error) { + opt := gitlab.CreateGroupAccessTokenOptions{ + Name: &tokenStorage.Name, + Scopes: &tokenStorage.Scopes, + } + if expiresAt != nil { + expiration := gitlab.ISOTime(*expiresAt) + opt.ExpiresAt = &expiration + } + if tokenStorage.AccessLevel != 0 { + opt.AccessLevel = (*gitlab.AccessLevelValue)(&tokenStorage.AccessLevel) + } + gat, _, err := gc.client.GroupAccessTokens.CreateGroupAccessToken(tokenStorage.ID, &opt) + if err != nil { + return nil, err + } + return gat, nil +} diff --git a/plugin/gitlab_client_test.go b/plugin/gitlab_client_test.go index 8c5a09a..2027af5 100644 --- a/plugin/gitlab_client_test.go +++ b/plugin/gitlab_client_test.go @@ -87,3 +87,7 @@ func (ac *mockGitlabClient) CreateProjectAccessToken(tokenStorage *BaseTokenStor // func (ac *mockGitlabClient) RevokeProjectAccessToken(tokenStorage *BaseTokenStorageEntry) error { // return nil // } + +func (ac *mockGitlabClient) CreateGroupAccessToken(tokenStorage *BaseTokenStorageEntry, expiresAt *time.Time) (*GAT, error) { + return nil, nil +} diff --git a/plugin/path_config.go b/plugin/path_config.go index 2ae97f8..d2803c5 100644 --- a/plugin/path_config.go +++ b/plugin/path_config.go @@ -159,7 +159,7 @@ Configure the Gitlab backend. ` const pathConfigHelpDesc = ` -The Gitlab backend requires credentials for creating a project access token. +The Gitlab backend requires credentials for creating an access token. This endpoint is used to configure those credentials as well as default values for the backend in general. ` diff --git a/plugin/path_role.go b/plugin/path_role.go index 046765b..29e428f 100644 --- a/plugin/path_role.go +++ b/plugin/path_role.go @@ -32,11 +32,11 @@ var roleSchema = map[string]*framework.FieldSchema{ }, "id": { Type: framework.TypeInt, - Description: "Project ID to create a project access token for", + Description: "Project/Group ID to create an access token for", }, "name": { Type: framework.TypeString, - Description: "The name of the project access token", + Description: "The name of the access token", }, "scopes": { Type: framework.TypeCommaStringSlice, @@ -49,7 +49,11 @@ var roleSchema = map[string]*framework.FieldSchema{ }, "access_level": { Type: framework.TypeInt, - Description: "access level of project access token", + Description: "access level of access token", + }, + "token_type": { + Type: framework.TypeString, + Description: "access token type", }, } @@ -61,6 +65,7 @@ func roleDetail(role *RoleStorageEntry) map[string]interface{} { "scopes": role.BaseTokenStorage.Scopes, "access_level": role.BaseTokenStorage.AccessLevel, "token_ttl": int64(role.TokenTTL / time.Second), + "token_type": role.BaseTokenStorage.TokenType, } } @@ -89,10 +94,10 @@ func (b *GitlabBackend) pathRoleCreateUpdate(ctx context.Context, req *logical.R role.retrieve(data) config, err := getConfig(ctx, req.Storage) if err != nil { - return logical.ErrorResponse("failed to obtain artifactory config - %s", err.Error()), nil + return logical.ErrorResponse("failed to obtain gitlab config - %s", err.Error()), nil } if config == nil { - return logical.ErrorResponse("artifactory backend configuration has not been set up"), nil + return logical.ErrorResponse("gitlab backend configuration has not been set up"), nil } err = role.assertValid(config.MaxTTL) if err != nil { @@ -106,7 +111,7 @@ func (b *GitlabBackend) pathRoleCreateUpdate(ctx context.Context, req *logical.R return logical.ErrorResponse(err.Error()), nil } b.Logger().Debug("successfully create role", "role_name", roleName, "id", role.BaseTokenStorage.ID, - "name", role.BaseTokenStorage.Name, "scopes", role.BaseTokenStorage.Scopes) + "name", role.BaseTokenStorage.Name, "scopes", role.BaseTokenStorage.Scopes, "token_type", role.BaseTokenStorage.TokenType) return &logical.Response{ Data: roleDetail(role), @@ -209,10 +214,10 @@ func pathRoleList(b *GitlabBackend) []*framework.Path { return paths } -const pathRoleHelpSyn = `Create a role with parameters that are used to generate a project access token.` +const pathRoleHelpSyn = `Create a role with parameters that are used to generate an access token.` const pathRoleHelpDesc = ` -This path allows you to create a role whose parameters will be used to generate a project access token. -You must supply a project id to generate a token for, a name, which will be used as a name field in Gitlab, +This path allows you to create a role whose parameters will be used to generate an access token. +You must supply a project/group id to generate a token for, a name, which will be used as a name field in Gitlab, and scopes for the generated project access token. ` diff --git a/plugin/path_role_test.go b/plugin/path_role_test.go index 2a83701..67157d9 100644 --- a/plugin/path_role_test.go +++ b/plugin/path_role_test.go @@ -40,6 +40,7 @@ func TestPathRole(t *testing.T) { "name": "role-test", "scopes": []string{"api", "read_repository"}, "access_level": 30, + "token_type": "project", } t.Run("successful", func(t *testing.T) { roleName := "successful" @@ -92,6 +93,7 @@ func TestPathRole(t *testing.T) { "id": -1, "token_ttl": fmt.Sprintf("%dh", 30*24), "access_level": 31, + "token_type": "foo", } resp, err := testRoleCreate(t, backend, storage, roleName, d) require.NoError(t, err) @@ -102,6 +104,7 @@ func TestPathRole(t *testing.T) { require.Contains(t, resp.Data["error"], "scopes are empty") require.Contains(t, resp.Data["error"], "exceeds configured maximum ttl") require.Contains(t, resp.Data["error"], "invalid access level") + require.Contains(t, resp.Data["error"], "token_type must be either") }) } @@ -116,9 +119,10 @@ func TestPathRoleList(t *testing.T) { } testConfigUpdate(t, backend, storage, conf) data := map[string]interface{}{ - "id": 1, - "name": "role-test", - "scopes": []string{"api", "read_repository"}, + "id": 1, + "name": "role-test", + "scopes": []string{"api", "read_repository"}, + "token_type": "project", } var listResp map[string]interface{} diff --git a/plugin/path_token.go b/plugin/path_token.go index cb59704..c762731 100644 --- a/plugin/path_token.go +++ b/plugin/path_token.go @@ -27,11 +27,11 @@ import ( var accessTokenSchema = map[string]*framework.FieldSchema{ "id": { Type: framework.TypeInt, - Description: "Project ID to create a project access token for", + Description: "Project/Group ID to create an access token for", }, "name": { Type: framework.TypeString, - Description: "The name of the project access token", + Description: "The name of the access token", }, "scopes": { Type: framework.TypeCommaStringSlice, @@ -43,11 +43,15 @@ var accessTokenSchema = map[string]*framework.FieldSchema{ }, "access_level": { Type: framework.TypeInt, - Description: "access level of project access token", + Description: "access level of access token", + }, + "token_type": { + Type: framework.TypeString, + Description: "access token type", }, } -func tokenDetails(pat *PAT) map[string]interface{} { +func projectTokenDetails(pat *PAT) map[string]interface{} { d := map[string]interface{}{ "token": pat.Token, "id": pat.ID, @@ -60,6 +64,19 @@ func tokenDetails(pat *PAT) map[string]interface{} { } return d } +func groupTokenDetails(gat *GAT) map[string]interface{} { + d := map[string]interface{}{ + "token": gat.Token, + "id": gat.ID, + "name": gat.Name, + "scopes": gat.Scopes, + "access_level": gat.AccessLevel, + } + if gat.ExpiresAt != nil { + d["expires_at"] = time.Time(*gat.ExpiresAt) + } + return d +} func (b *GitlabBackend) pathTokenCreate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { gc, err := b.getClient(ctx, req.Storage) @@ -72,10 +89,10 @@ func (b *GitlabBackend) pathTokenCreate(ctx context.Context, req *logical.Reques config, err := getConfig(ctx, req.Storage) if err != nil { - return logical.ErrorResponse("failed to obtain artifactory config - %s", err.Error()), nil + return logical.ErrorResponse("failed to obtain gitlab config - %s", err.Error()), nil } if config == nil { - return logical.ErrorResponse("artifactory backend configuration has not been set up"), nil + return logical.ErrorResponse("gitlab backend configuration has not been set up"), nil } err = tokenStorage.assertValid(config.MaxTTL) if err != nil { @@ -84,11 +101,12 @@ func (b *GitlabBackend) pathTokenCreate(ctx context.Context, req *logical.Reques b.Logger().Debug("generating access token", "id", tokenStorage.BaseTokenStorage.ID, "name", tokenStorage.BaseTokenStorage.Name, "scopes", tokenStorage.BaseTokenStorage.Scopes) - pat, err := gc.CreateProjectAccessToken(&tokenStorage.BaseTokenStorage, tokenStorage.ExpiresAt) + + d, err := tokenStorage.BaseTokenStorage.createAccessToken(gc, *tokenStorage.ExpiresAt) if err != nil { return logical.ErrorResponse("Failed to create a token - " + err.Error()), nil } - return &logical.Response{Data: tokenDetails(pat)}, nil + return &logical.Response{Data: d}, nil } // set up the paths for the roles within vault @@ -101,7 +119,7 @@ func pathToken(b *GitlabBackend) []*framework.Path { logical.CreateOperation: &framework.PathOperation{ Callback: b.pathTokenCreate, - Summary: "Create a project access token", + Summary: "Create an access token", Examples: tokenExamples, }, logical.UpdateOperation: &framework.PathOperation{ @@ -116,18 +134,18 @@ func pathToken(b *GitlabBackend) []*framework.Path { return paths } -const pathTokenHelpSyn = `Generate a project access token for a given project with token name, scopes.` +const pathTokenHelpSyn = `Generate an access token for a given project/group with token name, scopes.` const pathTokenHelpDesc = ` -This path allows you to generate a project access token. You must supply a project id to generate a token for, a name, which +This path allows you to generate an access token. You must supply a project/group id to generate a token for, a name, which will be used as a name field in Gitlab, and scopes for the generated project access token. ` var tokenExamples = []framework.RequestExample{ { - Description: "Create a project access token", + Description: "Create an access token", Data: map[string]interface{}{ "id": 1, - "name": "MyProjectAccessToken", + "name": "MyAccessToken", "scopes": []string{"read_api", "read_repository"}, }, }, diff --git a/plugin/path_token_role.go b/plugin/path_token_role.go index 2ba689d..4fbef12 100644 --- a/plugin/path_token_role.go +++ b/plugin/path_token_role.go @@ -45,12 +45,11 @@ func (b *GitlabBackend) pathRoleTokenCreate(ctx context.Context, req *logical.Re expiresAt := time.Now().UTC().Add(role.TokenTTL) b.Logger().Debug("generating access token for a role", "role_name", role.RoleName, "expires_at", expiresAt) - pat, err := gc.CreateProjectAccessToken(&role.BaseTokenStorage, &expiresAt) + d, err := role.BaseTokenStorage.createAccessToken(gc, expiresAt) if err != nil { return logical.ErrorResponse("Failed to create a token - " + err.Error()), nil } - - return &logical.Response{Data: tokenDetails(pat)}, nil + return &logical.Response{Data: d}, nil } // set up the paths for the roles within vault @@ -63,7 +62,7 @@ func pathRoleToken(b *GitlabBackend) []*framework.Path { logical.CreateOperation: &framework.PathOperation{ Callback: b.pathRoleTokenCreate, - Summary: "Create a project access token based on a predefined role", + Summary: "Create an access token based on a predefined role", Examples: roleTokenExamples, }, logical.UpdateOperation: &framework.PathOperation{ @@ -78,15 +77,15 @@ func pathRoleToken(b *GitlabBackend) []*framework.Path { return paths } -const pathRoleTokenHelpSyn = `Generate a project access token for a given project based on a predefined role` +const pathRoleTokenHelpSyn = `Generate an access token for a given project/group based on a predefined role` const pathRoleTokenHelpDesc = ` -This path allows you to generate a project access token based on a predefined role. You must create a role beforehand in /roles/ path, -whose parameters are used to generate a project access token. +This path allows you to generate an access token based on a predefined role. You must create a role beforehand in /roles/ path, +whose parameters are used to generate an access token. ` var roleTokenExamples = []framework.RequestExample{ { - Description: "Create a project access token based on a predefined role", + Description: "Create an access token based on a predefined role", Data: map[string]interface{}{ "role_name": "MyRole", }, diff --git a/plugin/path_token_role_test.go b/plugin/path_token_role_test.go index f72c2d0..7867507 100644 --- a/plugin/path_token_role_test.go +++ b/plugin/path_token_role_test.go @@ -36,9 +36,10 @@ func TestAccRoleToken(t *testing.T) { t.Run("successfully create", func(t *testing.T) { data := map[string]interface{}{ - "id": ID, - "name": "vault-role-test", - "scopes": []string{"read_api"}, + "id": ID, + "name": "vault-role-test", + "scopes": []string{"read_api"}, + "token_type": "project", } roleName := "successful" mustRoleCreate(t, backend, req.Storage, roleName, data) @@ -61,6 +62,7 @@ func TestAccRoleToken(t *testing.T) { "name": "vault-role-test-access-level", "access_level": 30, "scopes": []string{"read_api"}, + "token_type": "project", } roleName := "successful-access-level" mustRoleCreate(t, backend, req.Storage, roleName, data) diff --git a/plugin/path_token_test.go b/plugin/path_token_test.go index 2eabd8d..e5a7132 100644 --- a/plugin/path_token_test.go +++ b/plugin/path_token_test.go @@ -36,9 +36,10 @@ func TestAccToken(t *testing.T) { t.Run("successfully create", func(t *testing.T) { d := map[string]interface{}{ - "id": ID, - "name": "vault-test", - "scopes": []string{"read_api"}, + "id": ID, + "name": "vault-test", + "scopes": []string{"read_api"}, + "token_type": "project", } resp, err := testIssueToken(t, backend, req, d) require.NoError(t, err) @@ -57,6 +58,7 @@ func TestAccToken(t *testing.T) { "name": "vault-test-expires", "scopes": []string{"read_api"}, "expires_at": e.Unix(), + "token_type": "project", } resp, err := testIssueToken(t, backend, req, d) require.NoError(t, err) @@ -75,6 +77,7 @@ func TestAccToken(t *testing.T) { "scopes": []string{"read_api"}, "access_level": 30, "expires_at": e.Unix(), + "token_type": "project", } resp, err := testIssueToken(t, backend, req, d) require.NoError(t, err) @@ -101,6 +104,7 @@ func TestAccToken(t *testing.T) { require.Contains(t, resp.Data["error"], "id is empty or invalid") require.Contains(t, resp.Data["error"], "name is empty") require.Contains(t, resp.Data["error"], "scopes are empty") + require.Contains(t, resp.Data["error"], "token_type must be either") }) t.Run("exceeding max token lifetime", func(t *testing.T) { @@ -118,6 +122,7 @@ func TestAccToken(t *testing.T) { "name": "vault-test-exceeding-lifetime", "scopes": []string{"read_api"}, "expires_at": e.Unix(), + "token_type": "project", } resp, err := testIssueToken(t, backend, req, d) require.NoError(t, err) diff --git a/plugin/token.go b/plugin/token.go index ea2ef36..62ef922 100644 --- a/plugin/token.go +++ b/plugin/token.go @@ -25,6 +25,11 @@ import ( var errInvalidAccessLevel = errors.New("invalid access level") +const ( + tokenTypeProject string = "project" + tokenTypeGroup string = "group" +) + type TokenStorageEntry struct { BaseTokenStorage BaseTokenStorageEntry ExpiresAt *time.Time `json:"expires_at" structs:"expires_at" mapstructure:"expires_at,omitempty"` @@ -36,6 +41,7 @@ type BaseTokenStorageEntry struct { Name string `json:"name" structs:"name" mapstructure:"name"` Scopes []string `json:"scopes" structs:"scopes" mapstructure:"scopes"` AccessLevel int `json:"access_level" structs:"access_level" mapstructure:"access_level,omitempty"` + TokenType string `json:"token_type" struct:"token_type" mapstructure:"token_type,omitempty"` } func (tokenStorage *TokenStorageEntry) assertValid(maxTTL time.Duration) error { @@ -77,9 +83,23 @@ func (baseTokenStorage *BaseTokenStorageEntry) assertValid() error { err = multierror.Append(err, errInvalidAccessLevel) } + // no default type for access token + if e := validateTokenType(baseTokenStorage.TokenType); err != nil { + err = multierror.Append(err, e) + } + return err.ErrorOrNil() } +func validateTokenType(t string) error { + switch t { + case tokenTypeGroup, tokenTypeProject: + return nil + default: + return fmt.Errorf("token_type must be either %s or %s", tokenTypeProject, tokenTypeGroup) + } +} + func (tokenStorage *TokenStorageEntry) retrieve(data *framework.FieldData) { tokenStorage.BaseTokenStorage.retrieve(data) if expiresAtRaw, ok := data.GetOk("expires_at"); ok { @@ -101,4 +121,27 @@ func (baseTokenStorage *BaseTokenStorageEntry) retrieve(data *framework.FieldDat if accessLevelRaw, ok := data.GetOk("access_level"); ok { baseTokenStorage.AccessLevel = accessLevelRaw.(int) } + if tokenType, ok := data.GetOk("token_type"); ok { + baseTokenStorage.TokenType = tokenType.(string) + } +} + +// not right way to do this. use generic introduced in 1.18 +func (baseTokenStorage *BaseTokenStorageEntry) createAccessToken(gc Client, expiresAt time.Time) (data map[string]interface{}, err error) { + switch baseTokenStorage.TokenType { + case tokenTypeGroup: + gat, err := gc.CreateGroupAccessToken(baseTokenStorage, &expiresAt) + if err != nil { + err = fmt.Errorf("Failed to create a group token - " + err.Error()) + } + data = groupTokenDetails(gat) + case tokenTypeProject: + pat, err := gc.CreateProjectAccessToken(baseTokenStorage, &expiresAt) + if err != nil { + err = fmt.Errorf("Failed to create a project token - " + err.Error()) + } + data = projectTokenDetails(pat) + } + + return }