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/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/client.go b/pkg/client/client.go
index b32cacc1..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 (
@@ -19,6 +21,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 +311,77 @@ 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
+ }
+
+ if groupResponse.Data.DirectoryId == "" {
+ return "", status.Error(codes.Internal, "baton-atlassian: group detail response missing directory id")
+ }
+
+ 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,
@@ -493,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()
}
diff --git a/pkg/client/helper.go b/pkg/client/helper.go
index 0d5310b9..4c129607 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,11 @@ func (er *APIError) Message() string {
}
return "Error response empty"
}
+
+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/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..98696491 100644
--- a/pkg/connector/groups.go
+++ b/pkg/connector/groups.go
@@ -198,6 +198,57 @@ 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 {
+ if client.IsAlreadyExists(err) {
+ return annotations.New(&v2.GrantAlreadyExists{}), 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 {
+ if client.IsNotFound(err) {
+ return annotations.New(&v2.GrantAlreadyRevoked{}), 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,