From 9f07623c5b7bd775014fb3dc988f4df355a05b61 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Fri, 21 Aug 2026 17:39:11 -0500 Subject: [PATCH 1/3] add group membership provisioning --- README.md | 2 +- baton_capabilities.json | 3 +- docs/connector.mdx | 4 ++- pkg/client/client.go | 70 +++++++++++++++++++++++++++++++++++++++++ pkg/client/helper.go | 7 +++++ pkg/client/model.go | 4 +++ pkg/connector/groups.go | 45 ++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a7984aff..2028f022 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ `baton-atlassian` is a connector for [Atlassian](https://www.atlassian.com) built using the [Baton SDK](https://github.com/conductorone/baton-sdk). This connector is intended to use for managing general aspects of an Atlassian Organization, not specifically limited to any product or site. -People will be able to provision Roles for the users on different Workspaces (product-site) if they have the Atlassian feature enabled for their organization. +People will be able to provision Roles for the users on different Workspaces (product-site) if they have the Atlassian feature enabled for their organization. Users can also be provisioned into and out of groups (group membership). Check out [Baton](https://github.com/conductorone/baton) to learn more the project in general. diff --git a/baton_capabilities.json b/baton_capabilities.json index dd275611..45dee49e 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -23,7 +23,8 @@ ] }, "capabilities": [ - "CAPABILITY_SYNC" + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" ], "permissions": {} }, diff --git a/docs/connector.mdx b/docs/connector.mdx index ad3a2de6..8cf0fe53 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -12,11 +12,13 @@ sidebarTitle: "Atlassian" | :--- | :--- | :--- | | Accounts | | | | Workspaces | | | -| Groups | | | +| Groups | | | | API Tokens | | | This connector can provision roles in Atlassian workspaces. Depending on your Atlassian implementation, not all roles may be available for all workspaces, and some roles can be only configured if the user meets certain requirements. The Atlassian connector is unable to predict whether a role will be available to a user before the role is requested, but the connector will show an error if a requested role cannot be provisioned. +The connector can also add and remove users from Atlassian groups. Group membership provisioning uses the organization API key and does not require the extra endpoint access described below. Groups synced from an identity provider (SCIM) cannot be modified this way; manage those in the identity provider instead. + The Atlassian connector supports [automatic account provisioning](/product/admin/account-provisioning) via SCIM API. New users are created in the directory without passwords. If a managed Atlassian account already exists for the specified email address, the user will be linked to that existing account. ### Connector actions diff --git a/pkg/client/client.go b/pkg/client/client.go index b32cacc1..5429e6ec 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -19,6 +19,9 @@ const ( groupsEP = "v2/orgs/%s/directories/-/groups" usersRoleAssignmentEP = "v2/orgs/%s/directories/-/users/%s/role-assignments" groupsRoleAssignmentEP = "v2/orgs/%s/directories/-/groups/%s/role-assignments" + groupDetailEP = "v2/orgs/%s/directories/-/groups/%s" + groupMembershipsEP = "v2/orgs/%s/directories/%s/groups/%s/memberships" + groupMembershipEP = "v2/orgs/%s/directories/%s/groups/%s/memberships/%s" userAssignRolesEP = "v1/orgs/%s/users/%s/roles/assign" userRevokeRolesEP = "v1/orgs/%s/users/%s/roles/revoke" @@ -306,6 +309,73 @@ func (c *AtlassianClient) GetGroupRoleAssignments(ctx context.Context, pageToken return roleAssignmentsResponse.Data, nextPageToken, nil } +// GetGroupDirectoryID resolves a group's directory id. GET v2/orgs/{org}/directories/-/groups/{groupID}. Requires read:groups:admin. +func (c *AtlassianClient) GetGroupDirectoryID(ctx context.Context, groupID string) (string, error) { + var groupResponse GroupDetailResponse + requestURL, err := url.JoinPath(c.getBaseURL(), fmt.Sprintf(groupDetailEP, c.config.organizationID, groupID)) + if err != nil { + return "", err + } + + _, err = c.doRequest(ctx, + http.MethodGet, + requestURL, + &groupResponse, + nil, + ) + if err != nil { + return "", err + } + + return groupResponse.Data.DirectoryId, nil +} + +// AddUserToGroup adds a user to a group. POST v2/orgs/{org}/directories/{directoryID}/groups/{groupID}/memberships. Requires an unscoped organization API key. +func (c *AtlassianClient) AddUserToGroup(ctx context.Context, directoryID, groupID, accountID string) error { + requestBody := struct { + AccountId string `json:"accountId"` + }{ + AccountId: accountID, + } + + requestURL, err := url.JoinPath(c.getBaseURL(), fmt.Sprintf(groupMembershipsEP, c.config.organizationID, directoryID, groupID)) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, + http.MethodPost, + requestURL, + nil, + requestBody, + ) + if err != nil { + return err + } + + return nil +} + +// RemoveUserFromGroup removes a user from a group. DELETE v2/orgs/{org}/directories/{directoryID}/groups/{groupID}/memberships/{accountID}. Requires an unscoped organization API key. +func (c *AtlassianClient) RemoveUserFromGroup(ctx context.Context, directoryID, groupID, accountID string) error { + requestURL, err := url.JoinPath(c.getBaseURL(), fmt.Sprintf(groupMembershipEP, c.config.organizationID, directoryID, groupID, accountID)) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, + http.MethodDelete, + requestURL, + nil, + nil, + ) + if err != nil { + return err + } + + return nil +} + func (c *AtlassianClient) AssignRoleToUser(ctx context.Context, userID, workspaceID, roleID string) error { requestBody := RoleAssignmentBody{ Role: roleID, diff --git a/pkg/client/helper.go b/pkg/client/helper.go index 0d5310b9..451924f8 100644 --- a/pkg/client/helper.go +++ b/pkg/client/helper.go @@ -3,6 +3,9 @@ package client import ( "fmt" "strconv" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // maxItemsPerPage that the API allows is 100. The default value is 20. @@ -42,3 +45,7 @@ func (er *APIError) Message() string { } return "Error response empty" } + +func IsNotFound(err error) bool { + return status.Code(err) == codes.NotFound +} diff --git a/pkg/client/model.go b/pkg/client/model.go index 76e15929..afcf8a08 100644 --- a/pkg/client/model.go +++ b/pkg/client/model.go @@ -110,6 +110,10 @@ type Group struct { } `json:"links"` } +type GroupDetailResponse struct { + Data Group `json:"data"` +} + type RoleAssignmentsResponse struct { Data []RoleAssignment `json:"data"` Links struct { diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index 07e2a03a..ed627ee8 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -198,6 +198,51 @@ func (b *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken return grantResources, "", nil, nil } +func (b *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { + if principal.Id.ResourceType != userResourceType.Id { + return nil, fmt.Errorf("baton-atlassian: only users can be granted group membership") + } + + groupID := entitlement.Resource.Id.Resource + accountID := principal.Id.Resource + + directoryID, err := b.client.GetGroupDirectoryID(ctx, groupID) + if err != nil { + return nil, fmt.Errorf("baton-atlassian: failed to resolve group directory: %w", err) + } + + err = b.client.AddUserToGroup(ctx, directoryID, groupID, accountID) + if err != nil { + return nil, fmt.Errorf("baton-atlassian: failed to add user to group: %w", err) + } + + return nil, nil +} + +func (b *groupBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + if grant.Principal.Id.ResourceType != userResourceType.Id { + return nil, fmt.Errorf("baton-atlassian: only users can be revoked from group membership") + } + + groupID := grant.Entitlement.Resource.Id.Resource + accountID := grant.Principal.Id.Resource + + directoryID, err := b.client.GetGroupDirectoryID(ctx, groupID) + if err != nil { + return nil, fmt.Errorf("baton-atlassian: failed to resolve group directory: %w", err) + } + + err = b.client.RemoveUserFromGroup(ctx, directoryID, groupID, accountID) + if err != nil { + if client.IsNotFound(err) { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + return nil, fmt.Errorf("baton-atlassian: failed to remove user from group: %w", err) + } + + return nil, nil +} + func parseIntoGroupResource(group client.Group) (*v2.Resource, error) { profile := map[string]interface{}{ "name": group.Name, From c2a7e6429cdcfc14913e9983a81ca5b9973186a5 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Mon, 24 Aug 2026 11:18:01 -0500 Subject: [PATCH 2/3] CXH-1928: harden group membership grant/revoke idempotency Address review feedback on PR #35: - Grant now treats an already-existing membership (HTTP 409 -> codes.AlreadyExists) as success, mirroring Revoke's not-found handling. - Revoke now treats a 404 from GetGroupDirectoryID (group deleted upstream) as GrantAlreadyRevoked, since a missing group means the membership is gone. - docs-info.md now lists Group Memberships as a provisionable resource. --- docs/docs-info.md | 1 + pkg/client/helper.go | 4 ++++ pkg/connector/groups.go | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/docs/docs-info.md b/docs/docs-info.md index b2d1a8e6..e5aa468f 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -24,6 +24,7 @@ - This connector can provision User Roles, meaning that you can Grant or Revoke roles on different Workspaces (product-sites) within the organization. Customer must consider that depending on their settings not all roles may be available for all sites. There are some of them that could be enabled or not on certain sites; there may be other roles that can be only configured if the user meets certain requirements. The connector is unable to determine which will be effectively available, but a descriptive error message should communicate the situation when a Grant can't be provisioned. +- This connector can also provision Group Memberships, meaning that you can Grant or Revoke a user's membership in a group. ## Connector credentials diff --git a/pkg/client/helper.go b/pkg/client/helper.go index 451924f8..4c129607 100644 --- a/pkg/client/helper.go +++ b/pkg/client/helper.go @@ -49,3 +49,7 @@ func (er *APIError) Message() string { func IsNotFound(err error) bool { return status.Code(err) == codes.NotFound } + +func IsAlreadyExists(err error) bool { + return status.Code(err) == codes.AlreadyExists +} diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index ed627ee8..98696491 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -213,6 +213,9 @@ func (b *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitl err = b.client.AddUserToGroup(ctx, directoryID, groupID, accountID) if err != nil { + if client.IsAlreadyExists(err) { + return annotations.New(&v2.GrantAlreadyExists{}), nil + } return nil, fmt.Errorf("baton-atlassian: failed to add user to group: %w", err) } @@ -229,6 +232,9 @@ func (b *groupBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations directoryID, err := b.client.GetGroupDirectoryID(ctx, groupID) if err != nil { + if client.IsNotFound(err) { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } return nil, fmt.Errorf("baton-atlassian: failed to resolve group directory: %w", err) } From 1d66c324a5e3f76d417e42dd6e46f2811bcd4bf3 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Mon, 24 Aug 2026 14:50:25 -0500 Subject: [PATCH 3/3] CXH-1928: surface directory-resolution and DELETE errors clearly Address review-bot feedback on PR #35: - GetGroupDirectoryID now returns codes.Internal when a 200 response omits directoryId, instead of silently building a wrong membership URL from an empty path segment. - The DELETE branch of doRequest now passes uhttp.WithErrorResponse, so revoke failures keep the Atlassian error detail. IsNotFound still resolves through the resulting joined error, so revoke idempotency is unchanged. --- pkg/client/client.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index 5429e6ec..3a92db8d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -9,6 +9,8 @@ import ( "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) const ( @@ -327,6 +329,10 @@ func (c *AtlassianClient) GetGroupDirectoryID(ctx context.Context, groupID strin return "", err } + if groupResponse.Data.DirectoryId == "" { + return "", status.Error(codes.Internal, "baton-atlassian: group detail response missing directory id") + } + return groupResponse.Data.DirectoryId, nil } @@ -563,7 +569,7 @@ func (c *AtlassianClient) doRequest( } case http.MethodDelete: - resp, err = c.wrapper.Do(req) + resp, err = c.wrapper.Do(req, uhttp.WithErrorResponse(&apiErr)) if resp != nil { defer resp.Body.Close() }