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