Skip to content
Closed
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
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
36 changes: 36 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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"
groupMembershipsEP = "v2/orgs/%s/directories/-/groups/%s/memberships"
groupMembershipEP = "v2/orgs/%s/directories/-/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 +308,40 @@ func (c *AtlassianClient) GetGroupRoleAssignments(ctx context.Context, pageToken
return roleAssignmentsResponse.Data, nextPageToken, nil
}

func (c *AtlassianClient) AddUserToGroup(ctx context.Context, groupID, accountID string) error {
requestBody := struct {
AccountId string `json:"accountId"`
}{
AccountId: accountID,
}

requestURL, err := url.JoinPath(c.getBaseURL(), fmt.Sprintf(groupMembershipsEP, c.config.organizationID, groupID))
if err != nil {
return err
}

_, err = c.doRequest(ctx, http.MethodPost, requestURL, nil, requestBody)
if err != nil {
return err
}

return nil
}

func (c *AtlassianClient) RemoveUserFromGroup(ctx context.Context, groupID, accountID string) error {
requestURL, err := url.JoinPath(c.getBaseURL(), fmt.Sprintf(groupMembershipEP, c.config.organizationID, groupID, accountID))
if err != nil {
return err
}

_, err = c.doRequest(ctx, http.MethodDelete, requestURL, nil, nil)
if err != nil {
return err
}

return nil
}
Comment on lines +331 to +343

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This deletes the membership by treating accountID as the final path segment of .../memberships/{id}. Confirm the Atlassian Admin v2 membership API keys the DELETE on the user's accountId rather than a distinct membership ID — if it expects a membership ID, revokes will 404. The PR notes manual testing is still pending, so please verify this against a live tenant. (confidence: low)


func (c *AtlassianClient) AssignRoleToUser(ctx context.Context, userID, workspaceID, roleID string) error {
requestBody := RoleAssignmentBody{
Role: roleID,
Expand Down
32 changes: 32 additions & 0 deletions pkg/connector/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,38 @@ 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
userID := principal.Id.Resource

err := b.client.AddUserToGroup(ctx, groupID, userID)
if err != nil {
return nil, fmt.Errorf("baton-atlassian: failed to add user to group: %w", err)
}

return nil, nil
Comment on lines +209 to +214

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Grant is not idempotent. If the user is already a member, Atlassian's membership POST typically returns 409, which surfaces here as an error and fails the access request. Consider detecting the already-exists case and returning annotations.New(&v2.GrantAlreadyExists{}) with a nil error (see CLAUDE.md "Grant Idempotency"). (confidence: medium)

}

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 removed from group membership")
}

groupID := grant.Entitlement.Resource.Id.Resource
userID := grant.Principal.Id.Resource

err := b.client.RemoveUserFromGroup(ctx, groupID, userID)
if err != nil {
return nil, fmt.Errorf("baton-atlassian: failed to remove user from group: %w", err)
}

return nil, nil
Comment on lines +225 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Revoke is not idempotent. If the membership no longer exists, the DELETE likely returns 404 and this returns an error rather than treating it as already-revoked. Consider detecting not-found and returning annotations.New(&v2.GrantAlreadyRevoked{}) with a nil error. (confidence: medium)

}

func parseIntoGroupResource(group client.Group) (*v2.Resource, error) {
profile := map[string]interface{}{
"name": group.Name,
Expand Down
Loading