Skip to content

CXH-1928: add group membership provisioning - #35

Open
al-conductorone wants to merge 1 commit into
mainfrom
cxh-1928-group-membership-provisioning
Open

CXH-1928: add group membership provisioning#35
al-conductorone wants to merge 1 commit into
mainfrom
cxh-1928-group-membership-provisioning

Conversation

@al-conductorone

Copy link
Copy Markdown

Users can now be added to and removed from Atlassian groups through C1 access requests, instead of managing group membership by hand in the Atlassian admin console.

@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

CXH-1928

Comment thread pkg/connector/groups.go
Comment on lines +214 to +217
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)
}

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 treats not-found as success via client.IsNotFound, but Grant has no matching already-exists handling. Re-granting membership a user already has (a common retry/idempotency path for C1) will surface a hard error instead of succeeding. Consider adding an IsAlreadyExists-style check next to IsNotFound in pkg/client/helper.go and returning annotations.New(&v2.GrantAlreadyExists{}), nil when Atlassian reports the membership already exists (409 maps to codes.Unknown via uhttp, so this may need to inspect the parsed APIError code rather than the gRPC status).

Comment thread pkg/client/client.go
return "", err
}

return groupResponse.Data.DirectoryId, nil

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 returns Data.DirectoryId without checking it is non-empty. If the group-detail response omits directoryId (or data is absent), the caller gets "" and builds v2/orgs/{org}/directories//groups/{id}/memberships; url.JoinPath cleans the empty segment away, so the request silently goes to .../directories/groups/{id}/memberships — a wrong path rather than a clear failure. Returning an explicit error when DirectoryId == "" would make the failure diagnosable.

Comment thread pkg/client/client.go
Comment on lines +366 to +371
_, err = c.doRequest(ctx,
http.MethodDelete,
requestURL,
nil,
nil,
)

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 is the first http.MethodDelete caller in the client, and the MethodDelete branch of doRequest (line 565-569) calls c.wrapper.Do(req) with no uhttp.WithErrorResponse(&apiErr). Revoke failures will therefore lose the Atlassian error detail that APIError.Message() exists to surface. The gRPC code mapping still works (so IsNotFound is fine), but adding the error-response option to the DELETE branch would make failures debuggable.

@github-actions

Copy link
Copy Markdown
Contributor

Connector PR Review: CXH-1928: add group membership provisioning

Blocking Issues: 0 | Suggestions: 5 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 93fb23da6312.
Review mode: full
View review run

Review Summary

Reviewed the full PR diff (client endpoints, group Grant/Revoke, capabilities JSON, README, docs) for security and correctness. Provisioning entity sources are correct - principal.Id.Resource for the account, entitlement.Resource.Id.Resource / grant.Entitlement.Resource.Id.Resource for the group, matching user.AccountId as used for user resource IDs during sync - and the AddUserToGroup/RemoveUserFromGroup argument order matches both the function signatures and the URL format strings. No blocking issues found; the five items below are idempotency, robustness, and coverage suggestions.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/groups.go:214-217 - Grant has no already-exists handling, while Revoke handles not-found; re-granting an existing membership will error instead of succeeding (P3).
  • pkg/client/client.go:330 - GetGroupDirectoryID returns DirectoryId unchecked; an empty value produces a silently wrong URL because url.JoinPath collapses the empty path segment.
  • pkg/client/client.go:366-371 - this is the first DELETE caller, and the MethodDelete branch of doRequest omits the uhttp error-response option, so revoke failures lose the Atlassian error detail.
  • pkg/connector/groups.go:230-233 - a 404 from the group lookup during Revoke (deleted group) returns a hard error rather than GrantAlreadyRevoked.
  • pkg/connector/groups.go:246-260 / pkg/client/client.go:313 - every grant and revoke spends an extra round trip resolving the directory ID, even though Group.DirectoryId is already populated by ListGroups; storing it in the group resource profile at sync time would remove the call. No test coverage was added for the new client methods or Grant/Revoke (the repo has no existing connector tests).
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/groups.go`:
- Around line 214-217: The Grant method returns a hard error when AddUserToGroup fails,
  including when the user is already a member of the group. Revoke already handles the
  mirror case via client.IsNotFound. Add an already-exists check (for example an
  IsAlreadyExists helper alongside IsNotFound in pkg/client/helper.go) and return the
  v2.GrantAlreadyExists annotation with a nil error when Atlassian reports the membership
  already exists. Note that uhttp maps HTTP 409 to codes.Unknown, so a gRPC-code check
  alone will not distinguish it; you likely need to inspect the parsed APIError body.
- Around line 230-233: In Revoke, a NotFound error from GetGroupDirectoryID (for example a
  group that was deleted upstream) is returned as a failure. Treat client.IsNotFound on
  this call as already-revoked and return the GrantAlreadyRevoked annotation with a nil
  error, the same way the RemoveUserFromGroup error is handled.
- Around line 246-260: parseIntoGroupResource does not persist the group directoryId into
  the resource profile, so Grant and Revoke each make an extra GetGroupDirectoryID round
  trip. Group.DirectoryId is already returned by ListGroups; add it to the profile map and
  read it back from the group trait in Grant/Revoke, falling back to the API lookup only
  when it is absent.
- New behavior added in this PR (Grant, Revoke, GetGroupDirectoryID, AddUserToGroup,
  RemoveUserFromGroup) has no test coverage. Consider adding table-driven tests against an
  httptest server driven through the existing --base-url support, covering success,
  already-member, and not-a-member cases.

In `pkg/client/client.go`:
- Around line 330: GetGroupDirectoryID returns groupResponse.Data.DirectoryId without
  validating it. If the response omits directoryId, callers receive an empty string and
  build ".../directories//groups/<groupID>/memberships"; url.JoinPath cleans the empty
  segment, so the request silently targets ".../directories/groups/<groupID>/memberships"
  instead of failing. Return an explicit error when the value is empty, naming the group id.
- Around line 366-371: RemoveUserFromGroup is the first http.MethodDelete caller in this
  client, and the MethodDelete branch of doRequest (around line 565) calls
  c.wrapper.Do(req) with no DoOptions. Add the uhttp error-response option for the APIError
  value to that branch so DELETE failures carry the parsed Atlassian error detail, matching
  the GET/POST/PUT branch.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@FeliLucero1 FeliLucero1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ran a deep review on this — mostly good, a couple idempotency gaps worth fixing before merge (see inline comments). Also small heads up: docs/docs-info.md still says only User Roles are provisionable, might want to update that too even though it is not part of this diff.

Comment thread pkg/connector/groups.go

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[High] Grant() does not treat "already a member" as a no-op. Revoke() right below checks client.IsNotFound and returns GrantAlreadyRevoked on 404 — Grant() should do the mirror thing for a 409/AlreadyExists so a retry does not hard-fail on a grant that already went through. uhttp already maps 409 -> codes.AlreadyExists the same way it maps 404 -> codes.NotFound, so this is basically the same fix pattern:

if client.IsAlreadyExists(err) {
    return annotations.New(&v2.GrantAlreadyExists{}), nil
}

Comment thread pkg/connector/groups.go

directoryID, err := b.client.GetGroupDirectoryID(ctx, groupID)
if err != nil {
return nil, fmt.Errorf("baton-atlassian: failed to resolve group directory: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] Smaller version of the same idempotency thing: if the group itself got deleted upstream, this GetGroupDirectoryID call 404s and we return a hard error here instead of GrantAlreadyRevoked — even though "group is gone" means the membership is gone too. Might be worth wrapping this err check with client.IsNotFound as well, same as the one a few lines down.

Comment thread pkg/connector/groups.go
groupID := entitlement.Resource.Id.Resource
accountID := principal.Id.Resource

directoryID, err := b.client.GetGroupDirectoryID(ctx, groupID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Medium] ListGroups already returns DirectoryId on every group during sync (see client.Group), but parseIntoGroupResource drops it. So every single Grant/Revoke call pays for an extra GET here just to look it back up again — doubles the API calls for every provisioning op (same thing happens in Revoke() below). Might be worth stashing DirectoryId on the resource at sync time instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants