diff --git a/README.md b/README.md
index c9d2bf5f..7b456984 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,18 @@ To use this connector, you will need different things depending on which version
because admin permissions are needed and in the cloud version they do not exist.
https://docs.gitlab.com/api/users/
+- Optional access-path labeling (`--sync-access-paths`, disabled by default):
+ When enabled, grants are labeled by HOW access was obtained instead of a single
+ flattened list. Direct membership, inheritance from a parent/top-level group, and
+ access via an invited (shared) group each become a distinct, reviewable path
+ (expandable grants). The default output is unchanged — identical grant shape and
+ counts — so existing deployments are unaffected until they opt in. It reuses the
+ existing per-access-level entitlements (no new entitlements) and emits paths only
+ for access levels that actually have members (no empty expansions).
+ `--sync-direct-members-only` restricts the sync to direct members only and
+ suppresses the inherited/invited paths. See `docs/doc-info.md` for the full model
+ and its one known limitation.
+
## Where can I find my API Key?
1- Log in gitlab.com o in your base url, then go to the top left, click on the user emoticon, a popup menu will open, click on edit profile.
2- In the dashboard to the left of User settings, click on access tokens, and in the list of tokens that appears, click on add new token.
@@ -130,6 +142,8 @@ Flags:
--otel-collector-endpoint string The endpoint of the OpenTelemetry collector to send observability data to ($BATON_OTEL_COLLECTOR_ENDPOINT)
-p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING)
--skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC)
+ --sync-access-paths Label grants by access path (direct, inherited, or via an invited group). Disabled by default. ($BATON_SYNC_ACCESS_PATHS)
+ --sync-direct-members-only When enabled, only sync direct members of groups and projects. ($BATON_SYNC_DIRECT_MEMBERS_ONLY)
--ticketing This must be set to enable ticketing support ($BATON_TICKETING)
-v, --version version for baton-gitlab
diff --git a/cmd/baton-gitlab/main.go b/cmd/baton-gitlab/main.go
index 638a2190..2c1e50db 100644
--- a/cmd/baton-gitlab/main.go
+++ b/cmd/baton-gitlab/main.go
@@ -54,6 +54,7 @@ func getConnector(ctx context.Context, glc *cfg.Gitlab) (types.ConnectorServer,
glc.BaseUrl,
glc.AccountCreationGroup,
glc.SyncDirectMembersOnly,
+ glc.SyncAccessPaths,
)
if err != nil {
diff --git a/config_schema.json b/config_schema.json
index 4814afb9..6686b319 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -126,6 +126,12 @@
"displayName": "Sync direct members only",
"description": "When enabled, only sync direct members of groups and projects. Inherited members from parent groups will be excluded from membership grants.",
"boolField": {}
+ },
+ {
+ "name": "sync-access-paths",
+ "displayName": "Sync access paths",
+ "description": "Label grants by access path (direct, inherited, or via an invited group). Disabled (default) keeps the previous flattened effective-membership grants.",
+ "boolField": {}
}
],
"displayName": "GitLab",
diff --git a/docs/connector.mdx b/docs/connector.mdx
index 998a4183..ad65dfcf 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -22,6 +22,10 @@ The GitLab connector supports [automatic account provisioning and deprovisioning
Information on last login is synced from self-hosted GitLab instances; this capability is not supported on GitLab.com due to permissions limitations.
+
+**Access path visibility (optional).** By default, C1 shows effective membership as a single flattened list of grants. Enable the **Sync access paths** setting to instead label each grant by how the access was obtained — direct membership, inheritance from a parent or top-level group, or access through an invited (shared) group — so reviewers can see and act on each access path separately. This setting is off by default and does not change existing grants until you enable it.
+
+
## Gather GitLab credentials
Configuring the connector requires you to pass in credentials generated in GitLab. Gather these credentials before you move on.
diff --git a/docs/doc-info.md b/docs/doc-info.md
index 8089cd64..2eead33d 100644
--- a/docs/doc-info.md
+++ b/docs/doc-info.md
@@ -37,6 +37,56 @@ While developing the connector, please fill out this form. This information is n
— Group entitlements for Users
— Project entitlements for Users
+## Access-path labeling (--sync-access-paths, opt-in)
+
+By default the connector emits effective membership as flattened direct grants: a
+user who can access a group/project shows up as a single grant, regardless of
+whether that access is direct, inherited from a parent group, or granted through an
+invited (shared) group. All paths look the same.
+
+When `--sync-access-paths` is enabled, each way a principal obtained access is
+surfaced as its own expandable grant, so the access path is visible and reviewable:
+
+ - Direct membership — a plain user grant on the resource's access-level entitlement.
+ - Inheritance from a parent/top-level group — one expandable grant per ancestor
+ group and per access level that ancestor actually has members at, with the
+ ancestor group as principal, expanding into that group's matching per-level
+ entitlement. Each ancestor (immediate subgroup up to the top-level group) is a
+ distinct path, so a top-level group member is shown as having access to nested
+ projects through the top-level group specifically.
+ - Access via an invited (shared) group. For a group target, the invited group is the
+ principal on the target, expanding into the invited group's per-level entitlements —
+ group→group sharing confers access to the invited group's direct members only. For a
+ project target, group→project sharing also confers access to the invited group's
+ inherited members, so each effective member (direct or inherited) is emitted as a
+ user-principal grant. Either way GitLab caps a shared member's effective access at
+ min(their level in the invited group, the share's access level); the connector
+ applies that cap, so no member is shown at a higher level than GitLab actually grants.
+
+Design notes:
+ - Expandable grants use Shallow=true: each grant is a single, crisp hop, and the
+ ConductorOne platform resolves deeper nesting by walking the connected edges.
+ - No new entitlements are added — the model reuses the existing per-access-level
+ entitlements, and only emits paths for access levels that actually have members,
+ so no grant expands to an empty set.
+ - Inherited and invited (shared) paths are attribution-only: their grants are marked
+ immutable, so the platform never offers a direct revoke of access that
+ is really held elsewhere. Revoking such access at the target is rejected
+ (InvalidArgument) and points at its source group, since removing a non-existent
+ direct membership is a no-op that would reappear on the next sync. Direct
+ memberships revoke normally.
+ - The default (flag off) output is unchanged (identical grant shape and counts),
+ so existing deployments are unaffected until they opt in.
+ - `--sync-direct-members-only` still applies: with it set, only direct members are
+ synced and the inheritance/invited paths are not emitted.
+
+Known limitation: an inheritance/invited access path is expandable into the ancestor or
+invited group's per-level entitlements. If that group is not itself in the connector's
+sync scope (for example, the token can see the share but the group is not returned as a
+top-level synced resource), the expandable grant has nothing to resolve into. This is
+non-destructive — the path simply does not expand — but the members reached only through
+that group will not surface until the group is in scope.
+
## Connector credentials
1. What credentials or information are needed to set up the connector? (For example, API key, client ID and secret, domain, etc.)
diff --git a/go.mod b/go.mod
index 6f91922a..900798e7 100644
--- a/go.mod
+++ b/go.mod
@@ -65,6 +65,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect
+ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -123,6 +124,7 @@ require (
go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
+ go.uber.org/goleak v1.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
@@ -138,10 +140,9 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/fileutil v1.4.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)
-
-replace gitlab.com/gitlab-org/api/client-go => gitlab.com/jirwin/client-go v0.123.1-0.20250228021302-c3f0af7d3169
diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go
index b0fa6667..2c082647 100644
--- a/pkg/config/conf.gen.go
+++ b/pkg/config/conf.gen.go
@@ -8,6 +8,7 @@ type Gitlab struct {
BaseUrl string `mapstructure:"base-url"`
AccountCreationGroup string `mapstructure:"account-creation-group"`
SyncDirectMembersOnly bool `mapstructure:"sync-direct-members-only"`
+ SyncAccessPaths bool `mapstructure:"sync-access-paths"`
}
func (c* Gitlab) findFieldByTag(tagValue string) (any, bool) {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 7bdb4436..85cd40e2 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -30,6 +30,11 @@ var (
field.WithDisplayName("Sync direct members only"),
field.WithDescription("When enabled, only sync direct members of groups and projects. Inherited members from parent groups will be excluded from membership grants."),
)
+ SyncAccessPaths = field.BoolField(
+ "sync-access-paths",
+ field.WithDisplayName("Sync access paths"),
+ field.WithDescription("Label grants by access path (direct, inherited, or via an invited group). Disabled (default) keeps the previous flattened effective-membership grants."),
+ )
// ConfigurationFields defines the external configuration required for the
// connector to run. Note: these fields can be marked as optional or
@@ -39,6 +44,7 @@ var (
BaseURL,
AccountCreationGroup,
SyncDirectMembersOnly,
+ SyncAccessPaths,
}
// FieldRelationships defines relationships between the fields listed in
diff --git a/pkg/connector/client/client.go b/pkg/connector/client/client.go
index 803b2a51..bf478f17 100644
--- a/pkg/connector/client/client.go
+++ b/pkg/connector/client/client.go
@@ -26,9 +26,10 @@ type GitlabClient struct {
IsOnPremise bool
accessToken string
SyncDirectMembersOnly bool
+ SyncAccessPaths bool
}
-func New(ctx context.Context, accessToken, baseURL, accountCreationGroup string, syncDirectMembersOnly bool) (*GitlabClient, error) {
+func New(ctx context.Context, accessToken, baseURL, accountCreationGroup string, syncDirectMembersOnly, syncAccessPaths bool) (*GitlabClient, error) {
options := []uhttp.Option{uhttp.WithLogger(true, ctxzap.Extract(ctx)), uhttp.WithUserAgent("baton-gitlab/1.0")}
client, err := uhttp.NewClient(ctx, options...)
@@ -50,6 +51,7 @@ func New(ctx context.Context, accessToken, baseURL, accountCreationGroup string,
IsOnPremise: baseURLTrimmed != "https://gitlab.com",
accessToken: accessToken,
SyncDirectMembersOnly: syncDirectMembersOnly,
+ SyncAccessPaths: syncAccessPaths,
}, nil
}
@@ -190,6 +192,23 @@ func (c *GitlabClient) ListAllGroupMembers(ctx context.Context, groupID string,
return members, nextToken, rateLimitDesc, nil
}
+// GetGroupMemberAll retrieves a single member of a group including inherited and
+// invited (shared) members (GET /groups/:id/members/all/:user_id). Returns a gRPC
+// NotFound status (not the ErrNotFound sentinel — doRequest surfaces uhttp's 4xx
+// error before CheckResponse runs) when the user has no effective access to the
+// group. Used to tell a direct membership apart from inherited/invited access on revoke.
+func (c *GitlabClient) GetGroupMemberAll(ctx context.Context, groupID, userID string) (*GroupMember, *v2.RateLimitDescription, error) {
+ var member GroupMember
+
+ path := fmt.Sprintf("/api/v4/groups/%s/members/all/%s", PathEscape(groupID), PathEscape(userID))
+ _, rateLimitDesc, err := c.doRequest(ctx, http.MethodGet, path, &member, nil)
+ if err != nil {
+ return nil, rateLimitDesc, err
+ }
+
+ return &member, rateLimitDesc, nil
+}
+
// ListGroupMembers retrieves members of a specific group.
func (c *GitlabClient) ListGroupMembers(ctx context.Context, groupID string, nextPageToken string) ([]*GroupMember, string, *v2.RateLimitDescription, error) {
var members []*GroupMember
@@ -331,6 +350,23 @@ func (c *GitlabClient) ListAllProjectMembers(ctx context.Context, projectID stri
return members, nextToken, rateLimitDesc, nil
}
+// GetProjectMemberAll retrieves a single member of a project including inherited and
+// invited (shared) members (GET /projects/:id/members/all/:user_id). Returns a gRPC
+// NotFound status (not the ErrNotFound sentinel — doRequest surfaces uhttp's 4xx
+// error before CheckResponse runs) when the user has no effective access to the
+// project. Used to tell a direct membership apart from inherited/invited access on revoke.
+func (c *GitlabClient) GetProjectMemberAll(ctx context.Context, projectID, userID string) (*ProjectMember, *v2.RateLimitDescription, error) {
+ var member ProjectMember
+
+ path := fmt.Sprintf("/api/v4/projects/%s/members/all/%s", PathEscape(projectID), PathEscape(userID))
+ _, rateLimitDesc, err := c.doRequest(ctx, http.MethodGet, path, &member, nil)
+ if err != nil {
+ return nil, rateLimitDesc, err
+ }
+
+ return &member, rateLimitDesc, nil
+}
+
// AddProjectMember adds a member to a project.
func (c *GitlabClient) AddProjectMember(ctx context.Context, projectID string, memberRequest *AddProjectMemberRequest) (*ProjectMember, *v2.RateLimitDescription, error) {
var member ProjectMember
diff --git a/pkg/connector/client/models.go b/pkg/connector/client/models.go
index 7f3ab78e..adc018da 100644
--- a/pkg/connector/client/models.go
+++ b/pkg/connector/client/models.go
@@ -20,14 +20,25 @@ type PendingInviteUser struct {
}
type Group struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- FullName string `json:"full_name"`
- ParentID int `json:"parent_id"`
- Archived bool `json:"archived"`
- Visibility string `json:"visibility"`
- MarkedForDeletion *ISOTime `json:"marked_for_deletion"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ FullName string `json:"full_name"`
+ ParentID int `json:"parent_id"`
+ Archived bool `json:"archived"`
+ Visibility string `json:"visibility"`
+ MarkedForDeletion *ISOTime `json:"marked_for_deletion"`
+ SharedWithGroups []SharedGroup `json:"shared_with_groups"`
+}
+
+// SharedGroup is a group invited (shared) into another group or project. GitLab
+// reports it under shared_with_groups on the group/project object. GroupAccessLevel
+// is the ceiling access the invited group's members receive on the target.
+type SharedGroup struct {
+ GroupID int `json:"group_id"`
+ GroupName string `json:"group_name"`
+ GroupFullPath string `json:"group_full_path"`
+ GroupAccessLevel int `json:"group_access_level"`
}
type Namespace struct {
@@ -40,11 +51,12 @@ type Namespace struct {
}
type Project struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- NameWithNamespace string `json:"name_with_namespace"`
- Namespace *Namespace `json:"namespace"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ NameWithNamespace string `json:"name_with_namespace"`
+ Namespace *Namespace `json:"namespace"`
+ SharedWithGroups []SharedGroup `json:"shared_with_groups"`
}
type GroupMember struct {
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 198f33dd..9779fe41 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -131,10 +131,10 @@ func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, erro
}
// New returns a new instance of the connector.
-func New(ctx context.Context, accessToken string, baseURL string, accountCreationGroup string, syncDirectMembersOnly bool) (*Connector, error) {
+func New(ctx context.Context, accessToken string, baseURL string, accountCreationGroup string, syncDirectMembersOnly, syncAccessPaths bool) (*Connector, error) {
l := ctxzap.Extract(ctx)
- gitlabClient, err := client.New(ctx, accessToken, baseURL, accountCreationGroup, syncDirectMembersOnly)
+ gitlabClient, err := client.New(ctx, accessToken, baseURL, accountCreationGroup, syncDirectMembersOnly, syncAccessPaths)
if err != nil {
l.Error("error creating gitlab client", zap.Error(err))
return nil, err
diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go
index ac79c095..a75e5c32 100644
--- a/pkg/connector/groups.go
+++ b/pkg/connector/groups.go
@@ -2,9 +2,7 @@ package connector
import (
"context"
- "errors"
"fmt"
- "net/http"
"strconv"
"strings"
"time"
@@ -113,7 +111,22 @@ func (o *groupBuilder) Entitlements(_ context.Context, resource *v2.Resource, _
return rv, "", nil, nil
}
+// Grants dispatches on the SyncAccessPaths flag. With it disabled (default) the
+// connector emits flattened effective membership as before; with it enabled each
+// access path (direct, inherited from a parent group, or via an invited group) is
+// surfaced as a distinct expandable grant.
func (o *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
+ if o.client.SyncAccessPaths {
+ return o.grantsWithAccessPaths(ctx, resource, pToken)
+ }
+ return o.grantsFlattened(ctx, resource, pToken)
+}
+
+// grantsFlattened emits effective membership as flat direct grants — the
+// pre-access-path behavior, preserved unchanged for existing customers. When
+// SyncDirectMembersOnly is set, only direct members are listed; otherwise the full
+// effective set (direct + inherited) is flattened via ListAllGroupMembers.
+func (o *groupBuilder) grantsFlattened(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
var outGrants []*v2.Grant
var outputAnnotations = annotations.New()
var users []*client.GroupMember
@@ -165,6 +178,85 @@ func (o *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken
return outGrants, nextPageToken, outputAnnotations, nil
}
+// grantsWithAccessPaths emits a group's direct members plus its indirect access
+// paths — inheritance from ancestor groups and access via invited (shared) groups —
+// as expandable grants, so each path stays distinct and reviewable in C1. It reuses
+// the existing per-access-level entitlements (no synthetic membership entitlement)
+// and emits paths only for levels that actually have members (no empty expansions).
+func (o *groupBuilder) grantsWithAccessPaths(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
+ var outGrants []*v2.Grant
+ var outputAnnotations = annotations.New()
+
+ var pageToken string
+ if pToken != nil {
+ pageToken = pToken.Token
+ }
+
+ groupId, err := fromGroupResourceId(resource.Id.Resource)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("error parsing group resource id: %w", err)
+ }
+
+ users, nextPageToken, rateLimitDesc, err := o.client.ListGroupMembers(ctx, groupId, pageToken)
+ if rateLimitDesc != nil {
+ outputAnnotations.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ _, unhandledErr := handlePermissionError(ctx, err, "group", groupId)
+ if unhandledErr != nil {
+ return nil, "", outputAnnotations, unhandledErr
+ }
+ // Permission error listing members: skip direct members but still emit the
+ // pure-expansion indirect anchors below, which need no member data of this group.
+ users, nextPageToken = nil, ""
+ }
+
+ // Path 1/2: direct members.
+ for _, user := range users {
+ principalId, err := resourceSdk.NewResourceID(userResourceType, user.ID)
+ if err != nil {
+ return nil, "", outputAnnotations, fmt.Errorf("error creating principal ID: %w", err)
+ }
+ outGrants = append(outGrants, grant.NewGrant(
+ resource,
+ client.AccessLevelValue(user.AccessLevel).String(),
+ principalId,
+ ))
+ }
+
+ // Indirect access paths, emitted once on the first page (unless direct-only).
+ if pageToken == "" && !o.client.SyncDirectMembersOnly {
+ if resource.ParentResourceId != nil {
+ inherited, err := accessPathInheritanceGrants(ctx, o.client, resource, resource.ParentResourceId, &outputAnnotations)
+ if err != nil {
+ return nil, "", outputAnnotations, err
+ }
+ outGrants = append(outGrants, inherited...)
+ }
+
+ group, rlDesc, err := o.client.GetGroup(ctx, groupId)
+ if rlDesc != nil {
+ outputAnnotations.WithRateLimiting(rlDesc)
+ }
+ if err != nil {
+ _, unhandledErr := handlePermissionError(ctx, err, "group", groupId)
+ if unhandledErr != nil {
+ return nil, "", outputAnnotations, unhandledErr
+ }
+ // Permission error: skip invited-group grants and continue.
+ } else {
+ // Path 4: groups invited into this group (group→group = direct members).
+ invited, err := accessPathInvitedGrants(ctx, o.client, resource, group.SharedWithGroups, &outputAnnotations)
+ if err != nil {
+ return nil, "", outputAnnotations, err
+ }
+ outGrants = append(outGrants, invited...)
+ }
+ }
+
+ return outGrants, nextPageToken, outputAnnotations, nil
+}
+
func (o *groupBuilder) Grant(
ctx context.Context,
principal *v2.Resource,
@@ -207,7 +299,7 @@ func (o *groupBuilder) Grant(
userId, err := strconv.Atoi(principal.Id.Resource)
if err != nil {
- l.Warn("baton-gitlab grant: unable to parse user ID. falling back to email invite", zap.Error(err))
+ l.Debug("baton-gitlab grant: unable to parse user ID. falling back to email invite", zap.Error(err))
ut, err := resourceSdk.GetUserTrait(principal)
if err != nil {
return nil, fmt.Errorf("baton-gitlab: error getting user trait: %w", err)
@@ -242,14 +334,13 @@ func (o *groupBuilder) Grant(
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
- var errResp *client.ErrorResponse
- if errors.As(err, &errResp) {
- if errResp.Response != nil && errResp.Response.StatusCode == http.StatusConflict {
- return annotations.New(&v2.GrantAlreadyExists{}), nil
- }
- if strings.Contains(err.Error(), "should be greater than or equal to") {
- return annotations.New(&v2.GrantAlreadyExists{}), nil
- }
+ // Idempotency: GitLab returns 409 (uhttp → codes.AlreadyExists) when the user is
+ // already a member, and a 400 "should be greater than or equal to" when they already
+ // hold an equal/higher role. Both mean the desired grant already holds. (The gRPC
+ // code is checked directly because the hand-rolled *ErrorResponse never reaches the
+ // error chain for doRequest calls — uhttp errors on 4xx before CheckResponse runs.)
+ if isAlreadyExistsError(err) || isAlreadyAtOrAboveLevelError(err) {
+ return annotations.New(&v2.GrantAlreadyExists{}), nil
}
return outputAnnotations, fmt.Errorf("error adding user to group: %w", err)
}
@@ -270,8 +361,33 @@ func (o *groupBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
- if errors.Is(err, client.ErrNotFound) {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ if isNotFoundError(err) {
+ // With access-path labeling off (default), preserve the historical behavior:
+ // an absent direct membership is reported as already revoked. The effective-
+ // access check below only applies in access-path mode, so default-mode
+ // deployments see no provisioning behavior change.
+ if !o.client.SyncAccessPaths {
+ outputAnnotations.Update(&v2.GrantAlreadyRevoked{})
+ return outputAnnotations, nil
+ }
+ // Not a direct member. If the principal still has effective access, it is
+ // inherited/invited — reject instead of falsely reporting success (which
+ // would reappear next sync). Otherwise the membership is genuinely gone.
+ _, rlDesc, checkErr := o.client.GetGroupMemberAll(ctx, groupId, grant.Principal.Id.Resource)
+ if rlDesc != nil {
+ outputAnnotations.WithRateLimiting(rlDesc)
+ }
+ // GetGroupMemberAll always returns a non-nil member when checkErr is nil,
+ // so a nil-check would be dead here; keying on checkErr is sufficient.
+ switch {
+ case checkErr == nil:
+ return outputAnnotations, revokeInheritedError("group", groupIdAndName)
+ case !isNotFoundError(checkErr):
+ return outputAnnotations, fmt.Errorf("baton-gitlab: error verifying group membership: %w", checkErr)
+ default:
+ outputAnnotations.Update(&v2.GrantAlreadyRevoked{})
+ return outputAnnotations, nil
+ }
}
return outputAnnotations, fmt.Errorf("error removing user from group: %w", err)
}
diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go
index 6ce2fcfa..0561ab6e 100644
--- a/pkg/connector/helpers.go
+++ b/pkg/connector/helpers.go
@@ -4,15 +4,24 @@ import (
"context"
"errors"
"fmt"
+ "slices"
+ "strconv"
"strings"
"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/types/grant"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
+// maxAncestorDepth bounds the parent-group walk so a pathological/cyclic group
+// hierarchy can never deadlock a sync.
+const maxAncestorDepth = 20
+
func toGroupResourceId(groupId string) string {
return fmt.Sprintf("g/%s", groupId)
}
@@ -39,6 +48,45 @@ func parseAccessLevelFromEntitlementID(entitlementID string) (int, error) {
return int(levelValue), nil
}
+// revokeInheritedError rejects a revoke of access the principal holds indirectly
+// (inherited from a parent group or granted via an invited/shared group) rather than
+// as a direct membership. Removing a non-existent direct membership is a silent no-op
+// that reappears on the next sync (a revoke loop), so we reject with an actionable
+// message instead of falsely reporting success. The indirect access must be revoked
+// at its source (the parent or invited group).
+func revokeInheritedError(resourceType, resourceID string) error {
+ return status.Errorf(codes.InvalidArgument,
+ "baton-gitlab: cannot revoke: principal is not a direct member of %s %q; access is inherited from a parent group or granted via an invited group and must be revoked at its source",
+ resourceType, resourceID)
+}
+
+// isNotFoundError reports whether err is a GitLab 404. The uhttp client surfaces a 404
+// as a gRPC status with codes.NotFound; the client.ErrNotFound sentinel is only returned
+// by CheckResponse, which doRequest bypasses because uhttp already errors on 4xx. We
+// check both so the detection is robust regardless of which path produced the error.
+// (Dominant idiom across baton-openai/segment/microsoft-entra/active-directory.)
+func isNotFoundError(err error) bool {
+ return errors.Is(err, client.ErrNotFound) || status.Code(err) == codes.NotFound
+}
+
+// isAlreadyExistsError reports whether err is a GitLab 409 Conflict ("Member already
+// exists"). uhttp maps HTTP 409 → codes.AlreadyExists; the hand-rolled *ErrorResponse
+// path never fires for doRequest calls (uhttp errors on 4xx before CheckResponse runs),
+// so idempotency must gate on the gRPC code, mirroring isNotFoundError.
+func isAlreadyExistsError(err error) bool {
+ return status.Code(err) == codes.AlreadyExists
+}
+
+// isAlreadyAtOrAboveLevelError reports whether err is GitLab's 400 rejection raised when
+// the user already holds an equal or higher role than the one being granted ("access level
+// should be greater than or equal to ..."). Like a 409, this means the desired grant already
+// holds, so it is treated as idempotent success. Matched by message text because GitLab does
+// not expose a distinct status code for it; centralized so a GitLab wording change is fixed
+// in one place for both groups and projects.
+func isAlreadyAtOrAboveLevelError(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "should be greater than or equal to")
+}
+
func handlePermissionError(ctx context.Context, err error, resourceType, resourceId string) (bool, error) {
if err == nil {
return false, nil
@@ -46,7 +94,7 @@ func handlePermissionError(ctx context.Context, err error, resourceType, resourc
if status.Code(err) == codes.PermissionDenied || errors.Is(err, client.ErrForbidden) {
l := ctxzap.Extract(ctx)
- l.Warn(
+ l.Debug(
fmt.Sprintf("Permission denied while listing members for %s. Skipping.", resourceType),
zap.String(fmt.Sprintf("%s_id", resourceType), resourceId),
)
@@ -55,3 +103,360 @@ func handlePermissionError(ctx context.Context, err error, resourceType, resourc
return false, err
}
+
+// --- Access-path helpers (only used when SyncAccessPaths is enabled) ---
+//
+// The access-path model labels every grant by HOW access was obtained, using
+// expandable grants that reuse the existing per-access-level entitlements. It adds
+// NO new entitlements and emits paths only for access levels that actually have
+// members, so no expandable grant resolves to an empty set. See docs/doc-info.md.
+
+// sortedLevelSlugs returns the slugs of the given access levels sorted ascending by
+// level value, for deterministic grant emission.
+func sortedLevelSlugs(set map[client.AccessLevelValue]struct{}) []string {
+ levels := make([]client.AccessLevelValue, 0, len(set))
+ for l := range set {
+ levels = append(levels, l)
+ }
+ slices.Sort(levels)
+ out := make([]string, 0, len(levels))
+ for _, l := range levels {
+ if slug := l.String(); slug != "" {
+ out = append(out, slug)
+ }
+ }
+ return out
+}
+
+// inheritableLevelSlugs returns the distinct access levels actually held by the
+// given members, restricted to the inheritable range (Guest..Owner). Minimal and
+// None confer no access to child subgroups/projects, so they are excluded.
+func inheritableLevelSlugs(members []*client.GroupMember) []string {
+ present := make(map[client.AccessLevelValue]struct{})
+ for _, member := range members {
+ lvl := client.AccessLevelValue(member.AccessLevel)
+ if lvl < client.GuestPermissions || lvl > client.OwnerPermissions {
+ continue
+ }
+ present[lvl] = struct{}{}
+ }
+ return sortedLevelSlugs(present)
+}
+
+// inheritanceGrants emits one expandable grant per level the ancestor group holds:
+// principal = the ancestor group, expandable into that group's per-level entitlement.
+// Shallow keeps each path a single crisp hop ("access via membership");
+// the ancestor's own inheritance grants carry deeper paths as their own edges.
+func inheritanceGrants(target *v2.Resource, ancestor *v2.ResourceId, levelSlugs []string) []*v2.Grant {
+ grants := make([]*v2.Grant, 0, len(levelSlugs))
+ for _, slug := range levelSlugs {
+ grants = append(grants, grant.NewGrant(
+ target,
+ slug,
+ ancestor,
+ // GrantExpandable surfaces the ancestor's members as access-through-this-path;
+ // GrantImmutable marks the edge non-revocable — inherited access is structural
+ // and can only be removed at its source, so C1 must not offer a direct revoke.
+ grant.WithAnnotation(
+ &v2.GrantExpandable{
+ EntitlementIds: []string{fmt.Sprintf("%s:%s:%s", groupResourceType.Id, ancestor.Resource, slug)},
+ Shallow: true,
+ ResourceTypeIds: []string{userResourceType.Id},
+ },
+ &v2.GrantImmutable{},
+ ),
+ ))
+ }
+ return grants
+}
+
+// invitedGroupGrants emits access-path grants for groups invited (shared) into the
+// target. GitLab caps a shared member's effective access at min(their level in the
+// invited group, the share's group_access_level); we map each level present in the
+// invited group to that capped level and emit one expandable grant per resulting
+// target level, expanding the invited group's matching per-level entitlements.
+// Shallow, only real levels — no empty paths, no synthetic membership entitlement.
+func invitedGroupGrants(target *v2.Resource, shared []client.SharedGroup, membersByGroup map[int][]*client.GroupMember) []*v2.Grant {
+ var grants []*v2.Grant
+ for _, sharedGroup := range shared {
+ share := client.AccessLevelValue(sharedGroup.GroupAccessLevel)
+ invitedResourceID := toGroupResourceId(strconv.Itoa(sharedGroup.GroupID))
+ principalID := &v2.ResourceId{ResourceType: groupResourceType.Id, Resource: invitedResourceID}
+
+ // capped target level -> set of the invited group's per-level entitlements
+ // whose members land on that level.
+ sources := make(map[client.AccessLevelValue]map[string]struct{})
+ for _, m := range membersByGroup[sharedGroup.GroupID] {
+ lvl := client.AccessLevelValue(m.AccessLevel)
+ // A member level in range but not one of GitLab's defined levels has no
+ // entitlement slug; skip it so we never build a malformed source ID.
+ if lvl < client.MinimalAccessPermissions || lvl > client.OwnerPermissions || lvl.String() == "" {
+ continue
+ }
+ eff := lvl
+ if share >= client.MinimalAccessPermissions && share < eff {
+ eff = share
+ }
+ // A non-standard share level (in range but undefined) has no slug either;
+ // skip rather than emit a grant on an empty-slug entitlement.
+ if eff.String() == "" {
+ continue
+ }
+ src := fmt.Sprintf("%s:%s:%s", groupResourceType.Id, invitedResourceID, lvl.String())
+ if sources[eff] == nil {
+ sources[eff] = make(map[string]struct{})
+ }
+ sources[eff][src] = struct{}{}
+ }
+
+ effLevels := make([]client.AccessLevelValue, 0, len(sources))
+ for l := range sources {
+ effLevels = append(effLevels, l)
+ }
+ slices.Sort(effLevels)
+ for _, eff := range effLevels {
+ srcIDs := make([]string, 0, len(sources[eff]))
+ for s := range sources[eff] {
+ srcIDs = append(srcIDs, s)
+ }
+ slices.Sort(srcIDs)
+ grants = append(grants, grant.NewGrant(
+ target,
+ eff.String(),
+ principalID,
+ // Invited-group access flows through the invited group's membership
+ // (GrantExpandable) and is not revocable at the target (GrantImmutable):
+ // it must be removed by un-sharing or editing the invited group.
+ grant.WithAnnotation(
+ &v2.GrantExpandable{
+ EntitlementIds: srcIDs,
+ Shallow: true,
+ ResourceTypeIds: []string{userResourceType.Id},
+ },
+ &v2.GrantImmutable{},
+ ),
+ ))
+ }
+ }
+ return grants
+}
+
+// allDirectGroupMembers pages through a group's direct members, accumulating rate-limit
+// annotations. Used by the access-path helpers to enumerate ancestor/invited-group
+// membership levels.
+func allDirectGroupMembers(ctx context.Context, c *client.GitlabClient, groupID string, annos *annotations.Annotations) ([]*client.GroupMember, error) {
+ var all []*client.GroupMember
+ pageToken := ""
+ for {
+ members, next, rateLimitDesc, err := c.ListGroupMembers(ctx, groupID, pageToken)
+ if rateLimitDesc != nil {
+ annos.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ return all, err
+ }
+ all = append(all, members...)
+ if next == "" {
+ return all, nil
+ }
+ pageToken = next
+ }
+}
+
+// allEffectiveGroupMembers pages through a group's effective members — direct plus
+// inherited (GET /groups/:id/members/all) — accumulating rate-limit annotations. Used
+// for group→project invited access, which confers on the invited group's inherited
+// members too, not just its direct ones.
+func allEffectiveGroupMembers(ctx context.Context, c *client.GitlabClient, groupID string, annos *annotations.Annotations) ([]*client.GroupMember, error) {
+ var all []*client.GroupMember
+ pageToken := ""
+ for {
+ members, next, rateLimitDesc, err := c.ListAllGroupMembers(ctx, groupID, pageToken)
+ if rateLimitDesc != nil {
+ annos.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ return all, err
+ }
+ all = append(all, members...)
+ if next == "" {
+ return all, nil
+ }
+ pageToken = next
+ }
+}
+
+// allDirectProjectMembers pages through a project's direct members, accumulating
+// rate-limit annotations. Used to dedupe invited-group grants against direct membership.
+func allDirectProjectMembers(ctx context.Context, c *client.GitlabClient, projectID string, annos *annotations.Annotations) ([]*client.ProjectMember, error) {
+ var all []*client.ProjectMember
+ pageToken := ""
+ for {
+ members, next, rateLimitDesc, err := c.ListProjectMembers(ctx, projectID, pageToken)
+ if rateLimitDesc != nil {
+ annos.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ return all, err
+ }
+ all = append(all, members...)
+ if next == "" {
+ return all, nil
+ }
+ pageToken = next
+ }
+}
+
+// accessPathInheritanceGrants walks the target's ancestor group chain and emits, per
+// ancestor, "access via membership" expandable grants for the levels that
+// ancestor actually has direct members at. Together the per-ancestor edges surface
+// both intermediate-subgroup and top-level access as distinct, reviewable paths.
+func accessPathInheritanceGrants(ctx context.Context, c *client.GitlabClient, target *v2.Resource, immediateParent *v2.ResourceId, annos *annotations.Annotations) ([]*v2.Grant, error) {
+ var grants []*v2.Grant
+ cursor := immediateParent
+ for depth := 0; cursor != nil && depth < maxAncestorDepth; depth++ {
+ groupID, err := fromGroupResourceId(cursor.Resource)
+ if err != nil {
+ return nil, err
+ }
+
+ members, err := allDirectGroupMembers(ctx, c, groupID, annos)
+ if err != nil {
+ isPermissionError, unhandledErr := handlePermissionError(ctx, err, "group", groupID)
+ if unhandledErr != nil {
+ return nil, unhandledErr
+ }
+ if isPermissionError {
+ members = nil
+ }
+ }
+ grants = append(grants, inheritanceGrants(target, cursor, inheritableLevelSlugs(members))...)
+
+ group, rateLimitDesc, err := c.GetGroup(ctx, groupID)
+ if rateLimitDesc != nil {
+ annos.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ isPermissionError, unhandledErr := handlePermissionError(ctx, err, "group", groupID)
+ if unhandledErr != nil {
+ return nil, unhandledErr
+ }
+ if isPermissionError {
+ return grants, nil
+ }
+ }
+ if group == nil || group.ParentID == 0 {
+ return grants, nil
+ }
+ cursor = getParentGroup(group.ParentID)
+ }
+ return grants, nil
+}
+
+// accessPathInvitedGrants resolves each invited (shared) group's DIRECT members and emits
+// group-as-principal expandable grants for a GROUP target. GitLab group→group sharing
+// confers access only to the invited group's direct members, so direct enumeration is
+// correct here. For a PROJECT target use accessPathInvitedProjectGrants instead —
+// group→project sharing also confers access to the invited group's inherited members
+// (see docs/doc-info.md).
+func accessPathInvitedGrants(ctx context.Context, c *client.GitlabClient, target *v2.Resource, shared []client.SharedGroup, annos *annotations.Annotations) ([]*v2.Grant, error) {
+ if len(shared) == 0 {
+ return nil, nil
+ }
+ membersByGroup := make(map[int][]*client.GroupMember, len(shared))
+ for _, sg := range shared {
+ groupID := strconv.Itoa(sg.GroupID)
+ members, err := allDirectGroupMembers(ctx, c, groupID, annos)
+ if err != nil {
+ isPermissionError, unhandledErr := handlePermissionError(ctx, err, "group", groupID)
+ if unhandledErr != nil {
+ return nil, unhandledErr
+ }
+ if isPermissionError {
+ continue
+ }
+ }
+ membersByGroup[sg.GroupID] = members
+ }
+ return invitedGroupGrants(target, shared, membersByGroup), nil
+}
+
+// accessPathInvitedProjectGrants emits grants for groups invited (shared) into a project.
+// GitLab group→project sharing confers access to the invited group's EFFECTIVE members —
+// direct AND inherited — each capped at min(memberLevel, shareLevel). Emitted as
+// user-principal grants (marked immutable: the access is removed by un-sharing or editing
+// the invited group, not at the project), so an inherited member whose level has no direct
+// member in the invited group is not dropped (the group→group path only covers directs).
+func accessPathInvitedProjectGrants(ctx context.Context, c *client.GitlabClient, target *v2.Resource, shared []client.SharedGroup, annos *annotations.Annotations) ([]*v2.Grant, error) {
+ if len(shared) == 0 {
+ return nil, nil
+ }
+ // A user who is a direct project member at a given level already has a revocable grant
+ // from the direct-members path. An immutable invited grant with the identical grant ID
+ // (target + entitlement + user) could override it and block a legitimate revoke, so we
+ // skip that exact collision — the direct membership wins. On a permission error we
+ // simply can't build the map; the direct-members path then emits no grants either, so
+ // there is nothing to collide with.
+ directLevel := make(map[int]client.AccessLevelValue)
+ directMembers, err := allDirectProjectMembers(ctx, c, target.Id.Resource, annos)
+ if err != nil {
+ if _, unhandledErr := handlePermissionError(ctx, err, "project", target.Id.Resource); unhandledErr != nil {
+ return nil, unhandledErr
+ }
+ }
+ for _, member := range directMembers {
+ directLevel[member.ID] = client.AccessLevelValue(member.AccessLevel)
+ }
+
+ // A user can be an effective member of more than one invited group that caps to the
+ // same level; emit their grant on that level once.
+ emitted := make(map[string]struct{})
+ var grants []*v2.Grant
+ for _, sg := range shared {
+ share := client.AccessLevelValue(sg.GroupAccessLevel)
+ members, err := allEffectiveGroupMembers(ctx, c, strconv.Itoa(sg.GroupID), annos)
+ if err != nil {
+ isPermissionError, unhandledErr := handlePermissionError(ctx, err, "group", strconv.Itoa(sg.GroupID))
+ if unhandledErr != nil {
+ return nil, unhandledErr
+ }
+ if isPermissionError {
+ continue
+ }
+ }
+ for _, member := range members {
+ lvl := client.AccessLevelValue(member.AccessLevel)
+ // A level outside GitLab's defined set has no entitlement slug; skip it so we
+ // never build a grant on an empty-slug entitlement.
+ if lvl < client.MinimalAccessPermissions || lvl > client.OwnerPermissions || lvl.String() == "" {
+ continue
+ }
+ eff := lvl
+ if share >= client.MinimalAccessPermissions && share < eff {
+ eff = share
+ }
+ if eff.String() == "" {
+ continue
+ }
+ // Exact collision with a direct membership at the same level: the direct
+ // (revocable) grant is authoritative — skip the immutable invited duplicate.
+ if directLevel[member.ID] == eff {
+ continue
+ }
+ dedupeKey := fmt.Sprintf("%d:%s", member.ID, eff.String())
+ if _, ok := emitted[dedupeKey]; ok {
+ continue
+ }
+ emitted[dedupeKey] = struct{}{}
+ grants = append(grants, grant.NewGrant(
+ target,
+ eff.String(),
+ &v2.ResourceId{ResourceType: userResourceType.Id, Resource: strconv.Itoa(member.ID)},
+ // Invited-group access is not revocable at the target: it is removed by
+ // un-sharing or editing the invited group.
+ grant.WithAnnotation(&v2.GrantImmutable{}),
+ ))
+ }
+ }
+ return grants, nil
+}
diff --git a/pkg/connector/projects.go b/pkg/connector/projects.go
index a468fe5c..f3c9f4be 100644
--- a/pkg/connector/projects.go
+++ b/pkg/connector/projects.go
@@ -2,7 +2,6 @@ package connector
import (
"context"
- "errors"
"fmt"
"strconv"
"strings"
@@ -14,6 +13,8 @@ import (
"github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
@@ -95,7 +96,23 @@ func (o *projectBuilder) Entitlements(ctx context.Context, resource *v2.Resource
return rv, "", nil, nil
}
+// Grants dispatches on the SyncAccessPaths flag. With it disabled (default) the
+// connector emits flattened effective membership as before; with it enabled each
+// access path (direct, inherited from a parent group, or via an invited group) is
+// surfaced as a distinct expandable grant.
func (o *projectBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
+ if o.client.SyncAccessPaths {
+ return o.grantsWithAccessPaths(ctx, resource, pToken)
+ }
+ return o.grantsFlattened(ctx, resource, pToken)
+}
+
+// grantsFlattened emits effective membership as flat direct grants plus a
+// parent-group expandable grant per member — the pre-access-path behavior, preserved
+// unchanged for existing customers. When SyncDirectMembersOnly is set, only direct
+// members are listed; otherwise the full effective set is flattened via
+// ListAllProjectMembers.
+func (o *projectBuilder) grantsFlattened(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
var outGrants []*v2.Grant
var outputAnnotations = annotations.New()
@@ -151,6 +168,81 @@ func (o *projectBuilder) Grants(ctx context.Context, resource *v2.Resource, pTok
return outGrants, nextPageToken, outputAnnotations, nil
}
+// grantsWithAccessPaths emits a project's direct members plus its indirect access
+// paths — inheritance from ancestor groups (up to and including the top-level group)
+// and access via invited (shared) groups — as expandable grants. It reuses the
+// existing per-access-level entitlements (no synthetic membership entitlement) and
+// emits paths only for levels that actually have members (no empty expansions).
+func (o *projectBuilder) grantsWithAccessPaths(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
+ var outGrants []*v2.Grant
+ var outputAnnotations = annotations.New()
+
+ var pageToken string
+ if pToken != nil {
+ pageToken = pToken.Token
+ }
+
+ users, nextPageToken, rateLimitDesc, err := o.client.ListProjectMembers(ctx, resource.Id.Resource, pageToken)
+ if rateLimitDesc != nil {
+ outputAnnotations.WithRateLimiting(rateLimitDesc)
+ }
+ if err != nil {
+ _, unhandledErr := handlePermissionError(ctx, err, "project", resource.Id.Resource)
+ if unhandledErr != nil {
+ return nil, "", outputAnnotations, unhandledErr
+ }
+ // Permission error listing members: skip direct members but still emit the
+ // pure-expansion indirect anchors below, which need no project member data.
+ users, nextPageToken = nil, ""
+ }
+
+ // Path 1: direct members.
+ for _, user := range users {
+ principalId, err := resourceSdk.NewResourceID(userResourceType, user.ID)
+ if err != nil {
+ return nil, "", outputAnnotations, fmt.Errorf("error creating principal ID: %w", err)
+ }
+ outGrants = append(outGrants, grant.NewGrant(
+ resource,
+ client.AccessLevelValue(user.AccessLevel).String(),
+ principalId,
+ ))
+ }
+
+ // Indirect access paths, emitted once on the first page (unless direct-only).
+ if pageToken == "" && !o.client.SyncDirectMembersOnly {
+ // Path 2: inheritance from ancestor groups (immediate subgroup up to top-level).
+ if resource.ParentResourceId != nil {
+ inherited, err := accessPathInheritanceGrants(ctx, o.client, resource, resource.ParentResourceId, &outputAnnotations)
+ if err != nil {
+ return nil, "", outputAnnotations, err
+ }
+ outGrants = append(outGrants, inherited...)
+ }
+
+ // Path 3: groups invited into this project.
+ project, rlDesc, err := o.client.GetProject(ctx, resource.Id.Resource)
+ if rlDesc != nil {
+ outputAnnotations.WithRateLimiting(rlDesc)
+ }
+ if err != nil {
+ _, unhandledErr := handlePermissionError(ctx, err, "project", resource.Id.Resource)
+ if unhandledErr != nil {
+ return nil, "", outputAnnotations, unhandledErr
+ }
+ // Permission error: skip invited-group grants and continue.
+ } else {
+ invited, err := accessPathInvitedProjectGrants(ctx, o.client, resource, project.SharedWithGroups, &outputAnnotations)
+ if err != nil {
+ return nil, "", outputAnnotations, err
+ }
+ outGrants = append(outGrants, invited...)
+ }
+ }
+
+ return outGrants, nextPageToken, outputAnnotations, nil
+}
+
func (o *projectBuilder) Grant(
ctx context.Context,
principal *v2.Resource,
@@ -171,7 +263,7 @@ func (o *projectBuilder) Grant(
}
userId, err := strconv.Atoi(principal.Id.Resource)
if err != nil {
- return nil, fmt.Errorf("error converting user ID to int: %w", err)
+ return nil, status.Errorf(codes.InvalidArgument, "baton-gitlab: invalid user ID: %v", err)
}
memberRequest := &client.AddProjectMemberRequest{
@@ -183,6 +275,12 @@ func (o *projectBuilder) Grant(
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
+ // Idempotency: GitLab returns 409 (uhttp → codes.AlreadyExists) when the user is
+ // already a member, and a 400 "should be greater than or equal to" when they already
+ // hold an equal/higher role. Both mean the desired grant already holds.
+ if isAlreadyExistsError(err) || isAlreadyAtOrAboveLevelError(err) {
+ return annotations.New(&v2.GrantAlreadyExists{}), nil
+ }
return outputAnnotations, fmt.Errorf("error adding user to project: %w", err)
}
@@ -195,7 +293,7 @@ func (o *projectBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotatio
projectId := grant.Entitlement.Resource.Id.Resource
userId, err := strconv.Atoi(grant.Principal.Id.Resource)
if err != nil {
- return nil, fmt.Errorf("error converting user ID to int: %w", err)
+ return nil, status.Errorf(codes.InvalidArgument, "baton-gitlab: invalid user ID: %v", err)
}
rateLimitDesc, err := o.client.RemoveProjectMember(ctx, projectId, strconv.Itoa(userId))
@@ -203,8 +301,33 @@ func (o *projectBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotatio
outputAnnotations.WithRateLimiting(rateLimitDesc)
}
if err != nil {
- if errors.Is(err, client.ErrNotFound) {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ if isNotFoundError(err) {
+ // With access-path labeling off (default), preserve the historical behavior:
+ // an absent direct membership is reported as already revoked. The effective-
+ // access check below only applies in access-path mode, so default-mode
+ // deployments see no provisioning behavior change.
+ if !o.client.SyncAccessPaths {
+ outputAnnotations.Update(&v2.GrantAlreadyRevoked{})
+ return outputAnnotations, nil
+ }
+ // Not a direct member. If the principal still has effective access, it is
+ // inherited/invited — reject instead of falsely reporting success (which
+ // would reappear next sync). Otherwise the membership is genuinely gone.
+ _, rlDesc, checkErr := o.client.GetProjectMemberAll(ctx, projectId, strconv.Itoa(userId))
+ if rlDesc != nil {
+ outputAnnotations.WithRateLimiting(rlDesc)
+ }
+ // GetProjectMemberAll always returns a non-nil member when checkErr is nil,
+ // so a nil-check would be dead here; keying on checkErr is sufficient.
+ switch {
+ case checkErr == nil:
+ return outputAnnotations, revokeInheritedError("project", projectId)
+ case !isNotFoundError(checkErr):
+ return outputAnnotations, fmt.Errorf("baton-gitlab: error verifying project membership: %w", checkErr)
+ default:
+ outputAnnotations.Update(&v2.GrantAlreadyRevoked{})
+ return outputAnnotations, nil
+ }
}
return outputAnnotations, fmt.Errorf("error removing user from project: %w", err)
}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index ee8be648..5aa58656 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -420,6 +420,8 @@ github.com/golang/protobuf/ptypes/timestamp
# github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e
## explicit
github.com/golang/snappy
+# github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e
+## explicit; go 1.23
# github.com/google/uuid v1.6.0
## explicit
github.com/google/uuid
@@ -730,6 +732,8 @@ go.opentelemetry.io/proto/otlp/common/v1
go.opentelemetry.io/proto/otlp/logs/v1
go.opentelemetry.io/proto/otlp/resource/v1
go.opentelemetry.io/proto/otlp/trace/v1
+# go.uber.org/goleak v1.3.0
+## explicit; go 1.20
# go.uber.org/multierr v1.11.0
## explicit; go 1.19
go.uber.org/multierr
@@ -947,6 +951,8 @@ gopkg.in/yaml.v2
# gopkg.in/yaml.v3 v3.0.1
## explicit
gopkg.in/yaml.v3
+# modernc.org/fileutil v1.4.0
+## explicit; go 1.24
# modernc.org/libc v1.72.0
## explicit; go 1.25.0
modernc.org/libc
@@ -986,4 +992,3 @@ modernc.org/memory
modernc.org/sqlite
modernc.org/sqlite/lib
modernc.org/sqlite/vtab
-# gitlab.com/gitlab-org/api/client-go => gitlab.com/jirwin/client-go v0.123.1-0.20250228021302-c3f0af7d3169