Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(issue-102): add service account token type for gitlab.com accounts #105

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ through Vault.
- Gitlab Personal Access Tokens: [https://docs.gitlab.com/ee/api/personal_access_tokens.html]
- Gitlab Project Access Tokens: [https://docs.gitlab.com/ee/api/project_access_tokens.html]
- Gitlab Group Access Tokens: [https://docs.gitlab.com/ee/api/group_access_tokens.html]
- Gitlab Service Account Personal Access Tokens: [https://docs.gitlab.com/ee/api/groups.html#create-personal-access-token-for-service-account-user]

## Getting Started

Expand Down Expand Up @@ -81,6 +82,7 @@ For a list of available roles check https://docs.gitlab.com/ee/user/permissions.
Depending on the type of token you have different scopes:

* `Personal` - https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#personal-access-token-scopes
* `Service Account` - https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#personal-access-token-scopes
* `Project` - https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html#scopes-for-a-project-access-token
* `Group` - https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html#scopes-for-a-group-access-token

Expand All @@ -89,6 +91,7 @@ Depending on the type of token you have different scopes:
Can be

* personal
* service-account (gitlab.com / SaaS only)
* project
* group

Expand Down Expand Up @@ -160,6 +163,8 @@ This will create three roles, one of each type.
# personal access tokens can only be created by Gitlab Administrators (see https://docs.gitlab.com/ee/api/users.html#create-a-personal-access-token)
$ vault write gitlab/roles/personal name=personal-token-name path=username scopes="read_api" token_type=personal ttl=48h

$ vault write gitlab/roles/service-account name=service-account-personal-token-name path=group/username scopes="read_api" token_type=service-account ttl=48h

$ vault write gitlab/roles/project name=project-token-name path=group/project scopes="read_api" access_level=guest token_type=project ttl=48h

$ vault write gitlab/roles/group name=group-token-name path=group/subgroup scopes="read_api" access_level=developer token_type=group ttl=48h
Expand Down Expand Up @@ -187,7 +192,7 @@ token 7mbpSExz7ruyw1QgTjL-
$ vault lease revoke gitlab/token/personal/0FrzLFkRKaUNZSfa6WfFqjWK
All revocation operations queued successfully!
```
##### Service accounts
##### Service accounts (self hosted)
The service account users from Gitlab 16.1 are for all purposes users that don't use seats. So creating a service account and setting the path to the service account user would work the same as on a real user.
```shell
$ curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab/api/v4/service_accounts" | jq .
Expand Down Expand Up @@ -221,6 +226,41 @@ scopes [api read_api read_repository read_registry]
token -senkScjDo-SoGwST9PP
```

##### Service accounts (SaaS / gitlab.com)
The service account users for SaaS gitlab work slightly differently than that of self-hosted instances and are created under groups
```shell
$ curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.com/api/v4/groups/345/service_accounts" | jq .
{
"id": 2017,
"username": "service_account_00b069cb73a15d0a7ba8cd67a653599c",
"name": "Service account user"
}
```

In this case you would create a role like
```shell
$ vault write gitlab/roles/sa name=sa-name path=345/service_account_00b069cb73a15d0a7ba8cd67a653599c scopes="read_api" token_type=service-account token_ttl=24h
$ vault read gitlab/token/sa
vault read gitlab/token/sa

Key Value
--- -----
lease_id gitlab/token/sa/oFI2vpUdvykvMgNum6pZReYZ
lease_duration 20h1m37s
lease_renewable false
access_level n/a
created_at 2023-08-31T03:58:23.069Z
expires_at 2023-09-01T00:00:00Z
name vault-generated-service-account-access-token-f6417198
role_name sa-name
path 345/service_account_00b069cb73a15d0a7ba8cd67a653599c
scopes [read_api]
token -senkScjDo-SoGwST9PP

vault lease revoke gitlab/token/sa/oFI2vpUdvykvMgNum6pZReYZ
All revocation operations queued successfully!
```

#### Group
```shell
$ vault read gitlab/token/group
Expand Down Expand Up @@ -317,4 +357,4 @@ $ vault secrets list -detailed -format=json | jq '."gitlab/"'
```
## Info

Running the logging with `debug` level will shows sensitive information in the logs.
Running the logging with `debug` level will shows sensitive information in the logs.
1 change: 1 addition & 0 deletions entry_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (e EntryToken) SecretResponse() (map[string]any, map[string]any) {
map[string]any{
"path": e.Path,
"name": e.Name,
"token": e.Token,
"user_id": e.UserID,
"parent_id": e.ParentID,
"token_id": e.TokenID,
Expand Down
66 changes: 66 additions & 0 deletions gitlab_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
CurrentTokenInfo() (*EntryToken, error)
RotateCurrentToken() (newToken *EntryToken, oldToken *EntryToken, err error)
CreatePersonalAccessToken(username string, userId int, name string, expiresAt time.Time, scopes []string) (*EntryToken, error)
CreateServiceAccountPersonalAccessToken(path string, groupId 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
RevokeServiceAccountPersonalAccessToken(tokenId int, tokenValue string) error
RevokeProjectAccessToken(tokenId int, projectId string) error
RevokeGroupAccessToken(tokenId int, groupId string) error
GetUserIdByUsername(username string) (int, error)
GetRolePathParts(path string) (interface{}, interface{}, error)
}

type gitlabClient struct {
Expand Down Expand Up @@ -167,6 +170,46 @@
return et, nil
}

func (gc *gitlabClient) GetRolePathParts(path string) (interface{}, interface{}, error) {
parts := strings.Split(path, "/")
if len(parts) != 2 {
return nil, nil, errors.New("Too many arguments for service account path - eg: 1234/my-service-account")
}
groupId := parts[0]
username := parts[1]

return groupId, username, nil
}

func (gc *gitlabClient) CreateServiceAccountPersonalAccessToken(path string, groupId string, userId int, name string, expiresAt time.Time, scopes []string) (et *EntryToken, err error) {
var at *g.PersonalAccessToken
defer func() {
gc.logger.Debug("Create service account personal access token", "pat", at, "et", et, "groupId", groupId, "serviceAccountId", userId, "name", name, "expiresAt", expiresAt, "scopes", scopes, "error", err)
}()
at, _, err = gc.client.Groups.CreateServiceAccountPersonalAccessToken(groupId, userId, &g.CreateServiceAccountPersonalAccessTokenOptions{
Name: g.Ptr(name),
ExpiresAt: (*g.ISOTime)(&expiresAt),
Scopes: &scopes,
})
if err != nil {
return nil, err
}
et = &EntryToken{
TokenID: at.ID,
UserID: userId,
ParentID: groupId,
Path: path,
Name: name,
Token: at.Token,
TokenType: TokenTypeServiceAccount,
CreatedAt: at.CreatedAt,
ExpiresAt: (*time.Time)(at.ExpiresAt),
Scopes: scopes,
AccessLevel: AccessLevelUnknown,
}
return et, nil
}

func (gc *gitlabClient) CreateGroupAccessToken(groupId string, name string, expiresAt time.Time, scopes []string, accessLevel AccessLevel) (et *EntryToken, err error) {
var at *g.GroupAccessToken
defer func() {
Expand Down Expand Up @@ -241,6 +284,29 @@
return nil
}

func (gc *gitlabClient) RevokeServiceAccountPersonalAccessToken(tokenId int, tokenValue string) (err error) {
defer func() {
gc.logger.Debug("Revoke personal access token", "tokenId", tokenId, "error", err)
}()

u := "personal_access_tokens/self"
req, err := gc.client.NewRequest(http.MethodDelete, u, nil, nil)
if err != nil {
return fmt.Errorf("service account personal: %w", ErrAccessTokenNotFound)

Check warning on line 295 in gitlab_client.go

View check run for this annotation

Codecov / codecov/patch

gitlab_client.go#L295

Added line #L295 was not covered by tests
}
req.Header.Set("PRIVATE-TOKEN", tokenValue)

var resp *g.Response
resp, err = gc.client.Do(req, nil)
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("service account personal: %w", ErrAccessTokenNotFound)
}
if err != nil {
return err

Check warning on line 305 in gitlab_client.go

View check run for this annotation

Codecov / codecov/patch

gitlab_client.go#L305

Added line #L305 was not covered by tests
}
return nil
}

func (gc *gitlabClient) RevokeProjectAccessToken(tokenId int, projectId string) (err error) {
defer func() {
gc.logger.Debug("Revoke project access token", "tokenId", tokenId, "error", err)
Expand Down
27 changes: 27 additions & 0 deletions gitlab_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ func TestGitlabClient_InvalidToken(t *testing.T) {
entryToken, err = client.CreatePersonalAccessToken("username", 0, "name", time.Now(), []string{"scope"})
require.Error(t, err)
require.Nil(t, entryToken)

groupId, username, _ := client.GetRolePathParts("123/service-account-user")
serviceAccountId, _ := client.GetUserIdByUsername(username.(string))
entryToken, err = client.CreateServiceAccountPersonalAccessToken("123/username", groupId.(string), serviceAccountId, "name", time.Now(), []string{"scope"})
require.Error(t, err)
require.Nil(t, entryToken)
}

func TestGitlabClient_RevokeToken_NotFound(t *testing.T) {
Expand Down Expand Up @@ -226,6 +232,27 @@ func TestGitlabClient_CreateAccessToken_And_Revoke(t *testing.T) {
require.EqualValues(t, gitlab.TokenTypePersonal, entryToken.TokenType)
require.NotEmpty(t, entryToken.Token)
require.NoError(t, client.RevokePersonalAccessToken(entryToken.TokenID))

// Test incorrect path fails
_, _, err = client.GetRolePathParts("service-account-user")
require.Error(t, err)

groupId, username, _ := client.GetRolePathParts("123/service-account-user")
serviceAccountId, _ := client.GetUserIdByUsername(username.(string))
entryToken, err = client.CreateServiceAccountPersonalAccessToken(
"123/service-account-user",
groupId.(string),
serviceAccountId,
"name",
time.Now(),
[]string{gitlab.TokenScopeReadApi.String()},
)
require.NoError(t, err)
require.NotNil(t, entryToken)
require.EqualValues(t, gitlab.TokenTypeServiceAccount, entryToken.TokenType)
require.NotEmpty(t, entryToken.Token)
require.NoError(t, client.RevokeServiceAccountPersonalAccessToken(entryToken.TokenID, entryToken.Token))
require.Error(t, client.RevokeServiceAccountPersonalAccessToken(entryToken.TokenID, "invalid-token"))
}

func TestGitlabClient_RotateCurrentToken(t *testing.T) {
Expand Down
61 changes: 55 additions & 6 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,14 @@ type inMemoryClient struct {
muLock sync.Mutex
valid bool

personalAccessTokenRevokeError bool
groupAccessTokenRevokeError bool
projectAccessTokenRevokeError bool
personalAccessTokenCreateError bool
groupAccessTokenCreateError bool
projectAccessTokenCreateError bool
personalAccessTokenRevokeError bool
serviceAccountPersonalAccessTokenRevokeError bool
groupAccessTokenRevokeError bool
projectAccessTokenRevokeError bool
personalAccessTokenCreateError bool
serviceAccountPersonalAccessTokenCreateError bool
groupAccessTokenCreateError bool
projectAccessTokenCreateError bool

calledMainToken int
calledRotateMainToken int
Expand Down Expand Up @@ -235,6 +237,32 @@ func (i *inMemoryClient) CreateProjectAccessToken(projectId string, name string,
return &entryToken, nil
}

func (i *inMemoryClient) CreateServiceAccountPersonalAccessToken(path string, groupId string, userId int, name string, expiresAt time.Time, scopes []string) (*gitlab.EntryToken, error) {
i.muLock.Lock()
defer i.muLock.Unlock()
if i.serviceAccountPersonalAccessTokenCreateError {
return nil, fmt.Errorf("CreateServiceAccountPersonalAccessToken")
}
i.internalCounter++

var tokenId = i.internalCounter
var entryToken = gitlab.EntryToken{
TokenID: tokenId,
UserID: userId,
ParentID: groupId,
Path: path,
Name: name,
Token: "",
TokenType: gitlab.TokenTypeServiceAccount,
CreatedAt: g.Ptr(time.Now()),
ExpiresAt: &expiresAt,
Scopes: scopes,
AccessLevel: gitlab.AccessLevelUnknown,
}
i.accessTokens[fmt.Sprintf("%s_%v", gitlab.TokenTypeServiceAccount.String(), tokenId)] = entryToken
return &entryToken, nil
}

func (i *inMemoryClient) RevokePersonalAccessToken(tokenId int) error {
i.muLock.Lock()
defer i.muLock.Unlock()
Expand Down Expand Up @@ -265,6 +293,16 @@ func (i *inMemoryClient) RevokeGroupAccessToken(tokenId int, groupId string) err
return nil
}

func (i *inMemoryClient) RevokeServiceAccountPersonalAccessToken(tokenId int, tokenValue string) error {
i.muLock.Lock()
defer i.muLock.Unlock()
if i.serviceAccountPersonalAccessTokenRevokeError {
return fmt.Errorf("RevokeServiceAccountPersonalAccessToken")
}
delete(i.accessTokens, fmt.Sprintf("%s_%v", gitlab.TokenTypeServiceAccount.String(), tokenId))
return nil
}

func (i *inMemoryClient) GetUserIdByUsername(username string) (int, error) {
idx := slices.Index(i.users, username)
if idx == -1 {
Expand All @@ -274,6 +312,17 @@ func (i *inMemoryClient) GetUserIdByUsername(username string) (int, error) {
return idx, nil
}

func (i *inMemoryClient) GetRolePathParts(path string) (interface{}, interface{}, error) {
parts := strings.Split(path, "/")
if len(parts) != 2 {
return nil, 0, errors.New("Too many arguments for service account path - eg: 1234/my-service-account")
}
groupId := parts[0]
username := parts[1]

return groupId, username, nil
}

var _ gitlab.Client = new(inMemoryClient)

func sanitizePath(path string) string {
Expand Down
4 changes: 3 additions & 1 deletion path_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ func (b *Backend) pathRolesWrite(ctx context.Context, req *logical.Request, data
switch tokenType {
case TokenTypePersonal:
validAccessLevels = ValidPersonalAccessLevels
case TokenTypeServiceAccount:
validAccessLevels = ValidPersonalAccessLevels
case TokenTypeGroup:
validAccessLevels = ValidGroupAccessLevels
case TokenTypeProject:
Expand All @@ -273,7 +275,7 @@ func (b *Backend) pathRolesWrite(ctx context.Context, req *logical.Request, data
// validate scopes
var invalidScopes []string
var validScopes = validTokenScopes
if tokenType == TokenTypePersonal {
if tokenType == TokenTypePersonal || tokenType == TokenTypeServiceAccount {
validScopes = append(validScopes, ValidPersonalTokenScopes...)
}
for _, scope := range role.Scopes {
Expand Down
Loading