Skip to content
Open
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
163 changes: 163 additions & 0 deletions pkg/connector/access_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package connector

import (
"context"
"fmt"
"time"

"github.com/conductorone/baton-gitlab/pkg/connector/client"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/pagination"
)

// accessTokenResource builds a K1 STATIC_SECRET resource for a project or group
// access token. The backing bot user (UserID) is set as the SecretTrait identity
// back-reference.
func accessTokenResource(token *client.AccessToken, resourceType *v2.ResourceType, detail string, parentResourceID *v2.ResourceId) (*v2.Resource, error) {
var createdAt, lastUsedAt, expiresAt time.Time
if token.CreatedAt != nil {
createdAt = *token.CreatedAt
}
if token.LastUsedAt != nil {
lastUsedAt = *token.LastUsedAt
}
if token.ExpiresAt != nil {
expiresAt = time.Time(*token.ExpiresAt)
}

return newStaticSecretResource(
token.Name,
resourceType,
token.ID,
detail,
createdAt,
lastUsedAt,
expiresAt,
token.UserID,
parentResourceID,
)
}

// projectAccessTokenBuilder syncs access tokens for each project (per-project
// fan-out via the project's ChildResourceType annotation).
type projectAccessTokenBuilder struct {
client *client.GitlabClient
}

func (o *projectAccessTokenBuilder) ResourceType(_ context.Context) *v2.ResourceType {
return projectAccessTokenResourceType
}

func (o *projectAccessTokenBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) {
if parentResourceID == nil {
return nil, "", nil, nil
}

var pageToken string
if pToken != nil {
pageToken = pToken.Token
}

outputAnnotations := annotations.New()
tokens, nextPageToken, rateLimitDesc, err := o.client.ListProjectAccessTokens(ctx, parentResourceID.Resource, pageToken)
if rateLimitDesc != nil {
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
isPermissionError, unhandledErr := handlePermissionError(ctx, err, "project", parentResourceID.Resource)
if unhandledErr != nil {
return nil, "", outputAnnotations, unhandledErr
}
if isPermissionError {
return nil, "", outputAnnotations, nil
}
}

resources := make([]*v2.Resource, 0, len(tokens))
for _, token := range tokens {
resource, err := accessTokenResource(token, projectAccessTokenResourceType, subtypeProjectAccess, parentResourceID)
if err != nil {
return nil, "", outputAnnotations, err
}
resources = append(resources, resource)
}

return resources, nextPageToken, outputAnnotations, nil
}

func (o *projectAccessTokenBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) {
return nil, "", nil, nil
}

func (o *projectAccessTokenBuilder) Grants(_ context.Context, _ *v2.Resource, _ *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
return nil, "", nil, nil
}

func newProjectAccessTokenBuilder(client *client.GitlabClient) *projectAccessTokenBuilder {
return &projectAccessTokenBuilder{client: client}
}

// groupAccessTokenBuilder syncs access tokens for each group (per-group fan-out
// via the group's ChildResourceType annotation).
type groupAccessTokenBuilder struct {
client *client.GitlabClient
}

func (o *groupAccessTokenBuilder) ResourceType(_ context.Context) *v2.ResourceType {
return groupAccessTokenResourceType
}

func (o *groupAccessTokenBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) {
if parentResourceID == nil {
return nil, "", nil, nil
}

groupID, err := fromGroupResourceId(parentResourceID.Resource)
if err != nil {
return nil, "", nil, fmt.Errorf("error parsing group resource id: %w", err)
}

var pageToken string
if pToken != nil {
pageToken = pToken.Token
}

outputAnnotations := annotations.New()
tokens, nextPageToken, rateLimitDesc, err := o.client.ListGroupAccessTokens(ctx, groupID, pageToken)
if rateLimitDesc != nil {
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
isPermissionError, unhandledErr := handlePermissionError(ctx, err, "group", groupID)
if unhandledErr != nil {
return nil, "", outputAnnotations, unhandledErr
}
if isPermissionError {
return nil, "", outputAnnotations, nil
}
}

resources := make([]*v2.Resource, 0, len(tokens))
for _, token := range tokens {
resource, err := accessTokenResource(token, groupAccessTokenResourceType, subtypeGroupAccess, parentResourceID)
if err != nil {
return nil, "", outputAnnotations, err
}
resources = append(resources, resource)
}

return resources, nextPageToken, outputAnnotations, nil
}

func (o *groupAccessTokenBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) {
return nil, "", nil, nil
}

func (o *groupAccessTokenBuilder) Grants(_ context.Context, _ *v2.Resource, _ *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
return nil, "", nil, nil
}

func newGroupAccessTokenBuilder(client *client.GitlabClient) *groupAccessTokenBuilder {
return &groupAccessTokenBuilder{client: client}
}
64 changes: 64 additions & 0 deletions pkg/connector/access_tokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package connector

import (
"testing"
"time"

"github.com/conductorone/baton-gitlab/pkg/connector/client"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
)

func TestProjectAccessTokenResource(t *testing.T) {
created := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
expires := client.ISOTime(time.Date(2027, 2, 2, 0, 0, 0, 0, time.UTC))

parent := &v2.ResourceId{ResourceType: projectResourceType.Id, Resource: "55"}
resource, err := accessTokenResource(&client.AccessToken{
ID: 3,
UserID: 900,
Name: "project-bot-token",
CreatedAt: &created,
ExpiresAt: &expires,
}, projectAccessTokenResourceType, subtypeProjectAccess, parent)
if err != nil {
t.Fatalf("build resource: %v", err)
}

if resource.GetId().GetResourceType() != projectAccessTokenResourceType.Id {
t.Errorf("resource type = %q, want %q", resource.GetId().GetResourceType(), projectAccessTokenResourceType.Id)
}
if resource.GetParentResourceId().GetResource() != "55" {
t.Errorf("parent = %v, want project/55", resource.GetParentResourceId())
}

st := pickSecretTrait(t, resource)
if st.GetCredentialType() != v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET {
t.Errorf("credential_type = %v, want STATIC_SECRET", st.GetCredentialType())
}
if st.GetCredentialDetail() != subtypeProjectAccess {
t.Errorf("credential_detail = %q, want %q", st.GetCredentialDetail(), subtypeProjectAccess)
}
// Backing bot user must be the SecretTrait identity back-reference.
if got := st.GetIdentityId(); got == nil || got.GetResource() != "900" || got.GetResourceType() != userResourceType.Id {
t.Errorf("identity_id = %v, want user/900", got)
}
}

func TestGroupAccessTokenResourceDetail(t *testing.T) {
resource, err := accessTokenResource(&client.AccessToken{
ID: 4,
UserID: 901,
Name: "group-bot-token",
}, groupAccessTokenResourceType, subtypeGroupAccess, nil)
if err != nil {
t.Fatalf("build resource: %v", err)
}

st := pickSecretTrait(t, resource)
if st.GetCredentialDetail() != subtypeGroupAccess {
t.Errorf("credential_detail = %q, want %q", st.GetCredentialDetail(), subtypeGroupAccess)
}
if st.GetCredentialType() != v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET {
t.Errorf("credential_type = %v, want STATIC_SECRET", st.GetCredentialType())
}
}
34 changes: 34 additions & 0 deletions pkg/connector/client/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,37 @@ func (c *GitlabClient) ListGroupServiceAccounts(ctx context.Context, groupID str

return accounts, headers.Get("X-Next-Page"), rateLimitDesc, nil
}

// ListProjectAccessTokens lists access token metadata for a single project.
// Each token is backed by a per-project bot user. The token value is never
// returned by the list endpoint.
// https://docs.gitlab.com/api/project_access_tokens/#list-all-project-access-tokens
func (c *GitlabClient) ListProjectAccessTokens(ctx context.Context, projectID string, nextPageToken string) ([]*AccessToken, string, *v2.RateLimitDescription, error) {
var tokens []*AccessToken

apiURL, _ := url.Parse(fmt.Sprintf("/api/v4/projects/%s/access_tokens", PathEscape(projectID)))
WithOffsetPagination(apiURL, nextPageToken)
headers, rateLimitDesc, err := c.doRequest(ctx, http.MethodGet, apiURL.String(), &tokens, nil)
if err != nil {
return nil, "", rateLimitDesc, err
}

return tokens, headers.Get("X-Next-Page"), rateLimitDesc, nil
}

// ListGroupAccessTokens lists access token metadata for a single group. Each
// token is backed by a per-group bot user. The token value is never returned by
// the list endpoint.
// https://docs.gitlab.com/api/group_access_tokens/#list-all-group-access-tokens
func (c *GitlabClient) ListGroupAccessTokens(ctx context.Context, groupID string, nextPageToken string) ([]*AccessToken, string, *v2.RateLimitDescription, error) {
var tokens []*AccessToken

apiURL, _ := url.Parse(fmt.Sprintf("/api/v4/groups/%s/access_tokens", PathEscape(groupID)))
WithOffsetPagination(apiURL, nextPageToken)
headers, rateLimitDesc, err := c.doRequest(ctx, http.MethodGet, apiURL.String(), &tokens, nil)
if err != nil {
return nil, "", rateLimitDesc, err
}

return tokens, headers.Get("X-Next-Page"), rateLimitDesc, nil
}
2 changes: 2 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
newServiceAccountBuilder(d.client),
newPersonalAccessTokenBuilder(d.client),
newDeployTokenBuilder(d.client),
newProjectAccessTokenBuilder(d.client),
newGroupAccessTokenBuilder(d.client),
}
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/connector/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,8 @@ func groupResource(group *client.Group, parentResourceID *v2.ResourceId, isOnPre
}

annos := make([]proto.Message, 0)
// Group access tokens exist on every group, including subgroups.
annos = append(annos, &v2.ChildResourceType{ResourceTypeId: groupAccessTokenResourceType.Id})
if parentResourceID == nil {
annos = append(annos, &v2.ChildResourceType{ResourceTypeId: projectResourceType.Id})
}
Expand Down
4 changes: 3 additions & 1 deletion pkg/connector/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ func (o *projectBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotatio
}

func projectResource(project *client.Project, parentResourceID *v2.ResourceId, isOnPremise bool) (*v2.Resource, error) {
var annotations []proto.Message
annotations := []proto.Message{
&v2.ChildResourceType{ResourceTypeId: projectAccessTokenResourceType.Id},
}

return resourceSdk.NewGroupResource(
project.NameWithNamespace,
Expand Down
18 changes: 18 additions & 0 deletions pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,21 @@ var deployTokenResourceType = &v2.ResourceType{
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}),
}

// projectAccessTokenResourceType is a GitLab project access token: an opaque
// static credential backed by a per-project bot user (NHI kind K1, STATIC_SECRET).
var projectAccessTokenResourceType = &v2.ResourceType{
Id: "project_access_token",
DisplayName: "Project Access Token",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}),
}

// groupAccessTokenResourceType is a GitLab group access token: an opaque static
// credential backed by a per-group bot user (NHI kind K1, STATIC_SECRET).
var groupAccessTokenResourceType = &v2.ResourceType{
Id: "group_access_token",
DisplayName: "Group Access Token",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}),
}
6 changes: 4 additions & 2 deletions pkg/connector/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import (
// <platform>.<object>.<purpose>). These refine, but do not replace, the
// indexed credential_type spine value.
const (
subtypePAT = "gitlab.token.pat"
subtypeDeploy = "gitlab.token.deploy"
subtypePAT = "gitlab.token.pat"
subtypeDeploy = "gitlab.token.deploy"
subtypeProjectAccess = "gitlab.token.project_access"
subtypeGroupAccess = "gitlab.token.group_access"
)

// newStaticSecretResource builds a TRAIT_SECRET resource carrying a SecretTrait
Expand Down
Loading