Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion baton_capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
]
},
"capabilities": [
"CAPABILITY_SYNC"
"CAPABILITY_SYNC",
"CAPABILITY_PROVISION"
],
"permissions": {}
},
Expand Down
4 changes: 3 additions & 1 deletion docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ sidebarTitle: "Atlassian"
| :--- | :--- | :--- |
| Accounts | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Groups | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
| Groups | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| API Tokens | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |

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
Expand Down
1 change: 1 addition & 0 deletions docs/docs-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 77 additions & 1 deletion pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"
Expand Down Expand Up @@ -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
Comment thread
al-conductorone marked this conversation as resolved.
}

// 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,
)
Comment thread
al-conductorone marked this conversation as resolved.
if err != nil {
return err
}

return nil
}

func (c *AtlassianClient) AssignRoleToUser(ctx context.Context, userID, workspaceID, roleID string) error {
requestBody := RoleAssignmentBody{
Role: roleID,
Expand Down Expand Up @@ -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()
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/client/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions pkg/client/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions pkg/connector/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
al-conductorone marked this conversation as resolved.
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)
Comment thread
al-conductorone marked this conversation as resolved.
}
Comment thread
al-conductorone marked this conversation as resolved.

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)
Comment thread
al-conductorone marked this conversation as resolved.
}

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,
Expand Down
Loading