Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8c7000d
feat(gitlab): label grants by access path and sync invited-group rela…
mateoHernandez123 Jul 13, 2026
d4a26d1
docs(gitlab): fix doc-info.md path in code comment references
mateoHernandez123 Jul 13, 2026
5d9c2c1
docs(gitlab): document shared-group access-level upper-bound limitation
mateoHernandez123 Jul 13, 2026
0fce159
chore(gitlab): log invited groups skipped for unsupported access level
mateoHernandez123 Jul 13, 2026
4b85231
feat(gitlab): gate access-path labeling behind opt-in sync-access-pat…
mateoHernandez123 Jul 14, 2026
872ce02
fix(gitlab): emit pure-expansion access-path anchors on member permis…
mateoHernandez123 Jul 15, 2026
e712ebc
feat(gitlab): rework access-path labeling to a lean expandable-grant …
mateoHernandez123 Jul 15, 2026
da9c736
fix(gitlab): fix revoke/grant idempotency detection and mark indirect…
mateoHernandez123 Jul 16, 2026
5df18f7
chore(gitlab): remove constants left unused by main's rename after re…
mateoHernandez123 Jul 16, 2026
ae79df2
chore(gitlab): remove orphaned client-go replace directive from go.mod
mateoHernandez123 Jul 16, 2026
bf8ab09
refactor(gitlab): address revoke-path review nits (helper, dead branc…
mateoHernandez123 Jul 17, 2026
8443f03
fix(gitlab): preserve rate-limit annotations on the flag-off already-…
mateoHernandez123 Jul 17, 2026
f89bc64
fix(gitlab): sync invited groups' inherited members on project access…
mateoHernandez123 Jul 17, 2026
a550269
fix(gitlab): dedupe invited-project grants against direct members
mateoHernandez123 Jul 17, 2026
46725c1
fix(gitlab): regenerate config schema to expose sync-access-paths flag
mateoHernandez123 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions cmd/baton-gitlab/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>
**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.
</Note>

## Gather GitLab credentials

Configuring the connector requires you to pass in credentials generated in GitLab. Gather these credentials before you move on.
Expand Down
50 changes: 50 additions & 0 deletions docs/doc-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions pkg/config/conf.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +44,7 @@ var (
BaseURL,
AccountCreationGroup,
SyncDirectMembersOnly,
SyncAccessPaths,
}

// FieldRelationships defines relationships between the fields listed in
Expand Down
38 changes: 37 additions & 1 deletion pkg/connector/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions pkg/connector/client/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading