Skip to content

feat: add provisioning to add/remove users to Jamf user groups#25

Open
joshblease wants to merge 2 commits into
ConductorOne:mainfrom
joshblease:add-usergroup-provisioning
Open

feat: add provisioning to add/remove users to Jamf user groups#25
joshblease wants to merge 2 commits into
ConductorOne:mainfrom
joshblease:add-usergroup-provisioning

Conversation

@joshblease

@joshblease joshblease commented Jul 2, 2026

Copy link
Copy Markdown

What

Implements Grant/Revoke provisioning so ConductorOne can add and remove end users to/from Jamf "User Groups" (the userGroup resource type). The connector was previously read-only — no builder implemented provisioning and the Jamf client only issued GET requests.

Scope is User Groups only (members are user resources). Admin account Groups (group.go) are intentionally out of scope.

How

  • pkg/jamf/client.go — adds write support via doPutXML, a PUT+XML helper that mirrors the existing doRequest token keep-alive and one-shot re-auth on Unauthenticated. Adds AddUserToUserGroup / RemoveUserFromUserGroup targeting the Classic API PUT /JSSResource/usergroups/id/{id} with <user_additions> / <user_deletions> bodies.
  • pkg/jamf/models.go — adds the UserGroupMembershipUpdate XML request body. Pointer-wrapped user_additions / user_deletions elements with omitempty so only the used element is serialized.
  • pkg/connector/userGroup.go — implements Grant and Revoke on userGroupResourceType. Validates the principal is a user, resolves the native Jamf user ID, and rejects smart groups (their membership is criteria-based and cannot be edited via the API).
  • pkg/connector/user.go + helpers.go — sets WithExternalID during user sync and adds a nativeUserID resolver (prefers ExternalId, falls back to the resource ID) so provisioning can obtain the native user ID.
  • baton_capabilities.json — regenerated; userGroup now advertises CAPABILITY_PROVISION. Provisioning is auto-derived by the SDK from the implemented interface, so no Metadata() change is required.

Grant/Revoke are naturally idempotent: Jamf treats adding an existing member (or deleting a non-member) as a successful no-op — the incremental <user_additions>/<user_deletions> writes only touch the target user, leaving other members untouched.

Bug fix: recover from an expired Jamf token

While validating against a live deployment, sporadic provisioning after an idle period failed with failed to get user group details: 401 Unauthorized. Root cause: Jamf's keep-alive endpoint requires a still-valid token, so once the bearer token fully expires (e.g. between infrequent syncs, or before an on-demand grant) keep-alive returns 401 and keepAliveToken propagated it — the re-auth-from-credentials fallback only existed in the request path and was never reached. Frequent sync bursts kept the token warm and masked this.

  • pkg/jamf/client.gokeepAliveToken now mints a fresh token via CreateBearerToken when keep-alive fails, so any call (sync or provisioning) transparently recovers from an expired token.
  • pkg/jamf/client_test.go — adds the repo's first unit test, reproducing the expired-token path against an httptest server (keep-alive 401 → re-auth → request retried with the fresh token).

Manual verification

Driven through the real binary against a live Jamf Pro instance, using a static user group ("Aikido Endpoint Protection", 27 existing members) and a test user, verified out-of-band via the Classic API:

  • Grant--grant-entitlement 'userGroup:<id>:member' --grant-principal '<userId>' --grant-principal-type user adds the user (group 27 → 28 members; target user now present; other 27 members untouched).
  • Idempotent re-grant — re-running the grant on an existing member succeeds with no error.
  • Revoke--revoke-grant 'userGroup:<id>:member:user:<userId>' removes the user (28 → 27; target user gone; others untouched).
  • Smart-group rejection (fail-closed) — a grant against a smart group is rejected with jamf-connector: cannot modify membership of smart user group "..." (membership is determined by criteria) and writes nothing.
  • Capabilities./baton-jamf capabilities shows userGroup advertising CAPABILITY_PROVISION (matches the regenerated baton_capabilities.json).
  • Expired-token recovery — reproduced deterministically in the new unit test; also observed and fixed on the live deployment below.

Production tenant

Additionally deployed and exercised end-to-end on a production ConductorOne tenant (self-hosted connector running as a systemd service on a VM). Full syncs (resources + entitlements + grants, including userGroup member entitlements) complete and upload cleanly, and the provisioning path was driven from the C1 UI. The expired-token 401 above was first observed here after the connector sat idle, and the fix was verified against the same tenant.

Test plan

  • go build ./..., go vet ./pkg/..., gofmt clean
  • go test ./... — passing (expired-token recovery unit test)
  • Regenerated baton_capabilities.json verified semantically identical to the SDK capabilities output
  • Manual grant / revoke / idempotency / smart-group rejection against a live Jamf Pro instance
  • Deployed and exercised on a production ConductorOne tenant (self-hosted connector), including full sync + provisioning

🤖 Generated with Claude Code

Implement Grant/Revoke on the userGroup resource type so ConductorOne can
add and remove end users to/from Jamf "User Groups".

- Add write support to the Jamf client (doPutXML helper mirroring
  doRequest's token keep-alive and one-shot re-auth), plus
  AddUserToUserGroup / RemoveUserFromUserGroup using the Classic API
  PUT /JSSResource/usergroups/id/{id} with <user_additions>/<user_deletions>
  XML bodies.
- Implement Grant/Revoke on userGroupResourceType, validating the principal
  type and rejecting smart groups (criteria-based membership can't be edited).
- Set WithExternalID during user sync so provisioning can resolve the native
  Jamf user ID.
- Regenerate baton_capabilities.json (userGroup now advertises
  CAPABILITY_PROVISION).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joshblease joshblease requested a review from a team July 2, 2026 10:23
The Jamf keep-alive endpoint requires a still-valid token, so once the
bearer token fully expires (e.g. after an idle period between syncs, or
before a sporadic provisioning task) keep-alive returns 401 and the token
cannot be refreshed. keepAliveToken previously propagated that 401, so the
request failed with "401 Unauthorized" — the re-auth-from-credentials
fallback only existed in the request path, which was never reached.

Now keepAliveToken mints a fresh token via CreateBearerToken when keep-alive
fails, so calls transparently recover. This surfaced as grant failures:
"failed to get user group details: 401 Unauthorized" after the connector
sat idle, while frequent sync bursts masked it.

Adds a white-box unit test (the repo's first) reproducing the expired-token
path against an httptest server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant