diff --git a/pkg/connector/access_tokens.go b/pkg/connector/access_tokens.go new file mode 100644 index 00000000..2eb31cc5 --- /dev/null +++ b/pkg/connector/access_tokens.go @@ -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} +} diff --git a/pkg/connector/access_tokens_test.go b/pkg/connector/access_tokens_test.go new file mode 100644 index 00000000..c1e0cbf4 --- /dev/null +++ b/pkg/connector/access_tokens_test.go @@ -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()) + } +} diff --git a/pkg/connector/client/tokens.go b/pkg/connector/client/tokens.go index a9131065..4bfa6a3d 100644 --- a/pkg/connector/client/tokens.go +++ b/pkg/connector/client/tokens.go @@ -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 +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 4c672bbe..f9bc7018 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -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), } } diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index a3a5cc3a..5ae89bcd 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -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}) } diff --git a/pkg/connector/projects.go b/pkg/connector/projects.go index 3b0b11d5..dde6835b 100644 --- a/pkg/connector/projects.go +++ b/pkg/connector/projects.go @@ -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, diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 1a4df2d4..e8245bdc 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -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{}), +} diff --git a/pkg/connector/secrets.go b/pkg/connector/secrets.go index 0fa25b89..19160556 100644 --- a/pkg/connector/secrets.go +++ b/pkg/connector/secrets.go @@ -12,8 +12,10 @@ import ( // ..). 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