diff --git a/README.md b/README.md
index 77201962..46f5a262 100644
--- a/README.md
+++ b/README.md
@@ -10,12 +10,12 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more the proje
|------------|--------|
| Sync | Yes |
| Account Creation (Users, User Accounts) | Yes — one type per connector instance, see `create-account-resource-type` below |
-| Account Deletion (Users, User Accounts) | Yes |
+| Account Deletion (Users, User Accounts) | Yes — except the user account the connector authenticates with |
| Provisioning (Grant/Revoke) | Yes — Groups (`userAccount` principal only, Group Access accounts only), User Groups (static groups only), Sites (`user` principal only), Managed Devices (`assigned`, `user` principal only, opt-in — see Data Model below), Roles (built-in privilege sets and individual privileges, `userAccount`/`group` principal, see below). |
Account creation/deletion and Grant/Revoke all require `-p`/`$BATON_PROVISIONING` to be set; without it the connector only syncs.
-Tested with Jamf Pro 11.32.1. Managed Devices use the Jamf Pro API v4 computers-inventory and v2 mobile-devices endpoints.
+Requires Jamf Pro 11.26.0 or later (`GET /api/v1/users` was added in 11.26.0). Managed Devices, which are opt-in, use the Jamf Pro API v4 computers-inventory endpoints, added in 11.30.0, and the v2 mobile-devices endpoints.
## Authentication
@@ -36,9 +36,9 @@ The Administrator privilege set covers everything. For least privilege, grant:
| Sync users | `Read User` |
| Sync user accounts and groups | `Read Accounts` |
| Sync user groups | `Read Static User Groups`, `Read Smart User Groups` (Smart is only needed when smart groups exist — it's checked per group) |
-| Sync sites | `Read Sites`, `Read User`, `Read Static User Groups`, `Read Smart User Groups`, `Read Accounts` (site grants are built by reading users, user groups and accounts) |
+| Sync sites | `Read Sites`, `Read Accounts` |
| Sync roles | `Read API Roles`, `Read Accounts` |
-| Sync managed devices (opt-in) | `Read Computers`, `Read Mobile Devices`, `Read User` (needed even if `user` isn't synced) |
+| Sync managed devices (opt-in) | `Read Computers`, `Read Mobile Devices` |
| Create/delete users | `Create User` and `Read User` (the connector reads the user back after creating); `Delete User` |
| Create/delete user accounts | `Create Accounts` and `Read Accounts`; `Delete Accounts` |
| Group Grant/Revoke (`userAccount` principal) | `Read Accounts`, `Update Accounts` |
@@ -49,15 +49,12 @@ The Administrator privilege set covers everything. For least privilege, grant:
`Read Accounts`/`Create Accounts`/`Update Accounts`/`Delete Accounts` cover both admin user accounts and admin groups — Jamf has no separate privilege for account groups. Every write path reads the object first, so each `Update` privilege above needs its `Read` counterpart too.
-Jamf reports a missing privilege differently depending on which API it uses: the Classic API (`/JSSResource` — Users, Accounts, Static User Groups) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the roles catalog, Computers, Mobile Devices) with HTTP 403. The connector reports both as a permission error.
+A missing privilege fails the sync or the operation with a permission error.
Group Grant/Revoke adds or removes a `userAccount` from an admin account
group's member list. A group with no members cannot receive its first member
through the connector, and Revoke on such a group returns a retryable error
-instead of reporting success — Jamf can transiently return an empty member
-list even when the group genuinely has members, and the connector refuses to
-write over (or assume someone was removed from) a list it can't trust is
-complete. Only Group Access
+instead of reporting success. Only Group Access
accounts can usefully be granted membership — a Full or Site Access account's
rights come from its own `privilege_set`, not its groups, so Grant rejects
adding one (membership grants it nothing). Do not use Grant/Revoke on
@@ -86,20 +83,33 @@ built-in privilege sets (`Administrator`, `Auditor`, `Enrollment Only`) and
every individual privilege are grantable — `Custom` itself is not a role you
grant directly. Granting a set displaces whichever one the principal
previously held. Revoke moves the principal to `Custom` with only the
-`Read License Information` privilege — the lowest access Jamf allows, since
-`privilege_set` has no neutral "no access" value. Jamf keeps that privilege on
-every `Custom` set it creates and never lets a client remove it, so revoking
-it specifically is rejected. Individual privileges can
+`Read License Information` privilege, which can't be revoked itself.
+Individual privileges can
only be granted while the principal's `privilege_set` is already `Custom`:
the flow is Revoke the current set first (which moves it to Custom), then
Grant the individual privileges — granting a built-in set again later leaves
Custom and discards those individual privileges. Group Access accounts are
not supported for Role Grant/Revoke — their rights come from their groups, so
assign the role to the group instead. Changing a Full or Site Access
-account's role removes it from any admin group it belonged to (that
-membership granted it nothing). A write rejected for the connector's own Jamf
+account's role removes it from any admin group it belonged to. A write rejected for the connector's own Jamf
account is reported as a permission error.
+## Sync cost
+
+Requests per full sync:
+
+| Resource type | Requests |
+|---|---|
+| `user` | ⌈users / 1000⌉ |
+| `userAccount` | ⌈accounts / 100⌉ |
+| `group` | 1, plus one read per group |
+| `userGroup` | 1, plus one read per user group |
+| `site` | 1, plus per site ⌊users / 1000⌋ + 1, ⌊user groups / 1000⌋ + 1 and ⌈accounts / 100⌉, each only when `user`, `userGroup` or `userAccount` is synced |
+| `role` | 1, plus ⌈accounts / 100⌉ + ⌈`Custom` accounts / 10⌉ and one read per `Custom` account, when `userAccount` is synced |
+| `managedDevice` | ⌈computers / 100⌉ + ⌈mobile devices / 100⌉ |
+
+Validation adds one request, plus one token request per new process (each Lambda container) and each time the token expires. Most SDK calls make one request; the largest is a `role` grants page of `Custom` accounts, which reads each one's details (up to 11). A group or user group detail comes back whole, so that read grows with the size of the group. For example, 50,000 users, 1,000 accounts (300 of them `Custom`), 50 groups, 200 user groups, 20 sites holding the users and 20,000 devices come to roughly 950 requests; sync time is about that count times the instance's API response time.
+
## Jamf Pro console admin account creation (`userAccount`)
When `--create-account-resource-type=userAccount`, a created account is
diff --git a/baton_capabilities.json b/baton_capabilities.json
index a7e2bc20..5fc25935 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -7,6 +7,11 @@
"displayName": "Group",
"traits": [
"TRAIT_GROUP"
+ ],
+ "annotations": [
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
+ }
]
},
"capabilities": [
@@ -41,6 +46,14 @@
"displayName": "Role",
"traits": [
"TRAIT_ROLE"
+ ],
+ "annotations": [
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
+ },
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.TypeScopedGrants"
+ }
]
},
"capabilities": [
@@ -52,7 +65,12 @@
{
"resourceType": {
"id": "site",
- "displayName": "Site"
+ "displayName": "Site",
+ "annotations": [
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
+ }
+ ]
},
"capabilities": [
"CAPABILITY_SYNC",
@@ -105,6 +123,11 @@
"displayName": "User Group",
"traits": [
"TRAIT_GROUP"
+ ],
+ "annotations": [
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
+ }
]
},
"capabilities": [
diff --git a/cmd/baton-jamf/main.go b/cmd/baton-jamf/main.go
index 739e161d..bdc49bed 100644
--- a/cmd/baton-jamf/main.go
+++ b/cmd/baton-jamf/main.go
@@ -19,5 +19,6 @@ func main() {
cfg.Config,
connector.New,
connectorrunner.WithDefaultCapabilitiesConnectorBuilderV2(&connector.Jamf{}),
+ connectorrunner.WithSessionStoreEnabled(),
)
}
diff --git a/docs/connector.mdx b/docs/connector.mdx
index 5fb2bb3b..fac33ab3 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -1,8 +1,8 @@
---
title: "Set up a Jamf connector"
og:title: "Set up a Jamf connector"
-description: "ConductorOne provides identity governance and just-in-time provisioning for Jamf Pro. Integrate your Jamf Pro instance with ConductorOne to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision Jamf accounts."
-og:description: "ConductorOne provides identity governance and just-in-time provisioning for Jamf Pro. Integrate your Jamf Pro instance with ConductorOne to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision Jamf accounts."
+description: "C1 provides identity governance and just-in-time provisioning for Jamf Pro. Integrate your Jamf Pro instance with C1 to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision Jamf accounts."
+og:description: "C1 provides identity governance and just-in-time provisioning for Jamf Pro. Integrate your Jamf Pro instance with C1 to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision Jamf accounts."
sidebarTitle: "Jamf"
---
@@ -26,18 +26,18 @@ The Jamf connector syncs the following resources:
{/* AUTO-GENERATED:END - capabilities */}
**Notes:**
-- Jamf has two distinct account types: **Users** (directory users) and **User Accounts** (Jamf Pro console admins). The connector can only create **one** of these types per connector instance — set by the **Account Provisioning Target** configuration field. Deletion works for both types regardless of this setting. Created accounts are `Enabled`, `Full Access`, and default to the `Auditor` privilege set; a random password is generated and returned once, as plaintext. See [Jamf account privileges](#jamf-account-privileges) below for the privileges this requires.
+- Jamf has two distinct account types: **Users** (directory users) and **User Accounts** (Jamf Pro console admins). The connector can only create **one** of these types per connector instance — set by the **Account Provisioning Target** configuration field. Deletion works for both types regardless of this setting, except for the user account the connector authenticates with, which Delete refuses. Created accounts are `Enabled`, `Full Access`, and default to the `Auditor` privilege set; a random password is generated and returned once, as plaintext. See [Jamf account privileges](#jamf-account-privileges) below for the privileges this requires.
- **User Groups**: Grant/Revoke adds or removes a Jamf user from a **static** User Group only — Smart User Groups are rejected.
-- **Sites**: Grant/Revoke is only supported for the `user` principal — a Jamf user's site membership is a true multi-valued list. `userGroup`/`userAccount`/`group` site membership remains sync-only (each of those objects can only belong to one site at a time, so granting a new one would silently displace the old one instead of adding a membership).
+- **Sites**: Grant/Revoke is only supported for the `user` principal — a Jamf user's site membership is a true multi-valued list. `userGroup`/`userAccount`/`group` site membership remains sync-only.
- **Managed Devices**: Grant/Revoke sets or clears a device's `assigned` user (computer or mobile device) — single-valued, so granting to a new user displaces whichever user was previously assigned. Devices with no assignee expose no grantable `assigned` entitlement. Computers use the Jamf Pro v4 computers-inventory API: Grant overwrites the assignee's username, name, email, position and phone with the new user's values (Jamf never auto-populates these for computers), and Revoke clears all five. Mobile devices take these fields from the directory user automatically — Grant only sets the username, and Revoke clears it (and everything Jamf derived from it) by clearing the username.
-- **Groups** (Jamf Pro admin account groups): Grant/Revoke a `userAccount` with **Group Access** only — a **Full** or **Site Access** account's rights come from its own privilege_set, not its groups, so Grant rejects adding one. A group with no members cannot receive its first member through the connector, and Revoke on such a group returns a retryable error instead of reporting success (an ambiguous empty read is never written over, or assumed to mean someone was removed). Do not use Grant/Revoke on directory (LDAP) groups: their membership is managed by the directory and the connector does not detect them.
-- **Roles**: Grant/Revoke sets or clears a `userAccount` or `group`'s role — the three built-in privilege sets (`Administrator`, `Auditor`, `Enrollment Only`) and every individual privilege are grantable; `Custom` itself is not a role you grant directly. Revoke moves the principal to `Custom` with only the `Read License Information` privilege (Jamf's lowest access, and not itself revocable). Individual privileges can only be granted while the principal is already `Custom`: revoke the current set first, then grant the individual privileges. **Group Access** accounts are rejected for Role Grant/Revoke — assign the role to the group instead. Changing a **Full** or **Site Access** account's role removes it from any admin group it belonged to (that membership granted it nothing).
+- **Groups** (Jamf Pro admin account groups): Grant/Revoke a `userAccount` with **Group Access** only — a **Full** or **Site Access** account's rights come from its own privilege_set, not its groups, so Grant rejects adding one. A group with no members cannot receive its first member through the connector, and Revoke on such a group returns a retryable error instead of reporting success. Do not use Grant/Revoke on directory (LDAP) groups: their membership is managed by the directory and the connector does not detect them.
+- **Roles**: Grant/Revoke sets or clears a `userAccount` or `group`'s role — the three built-in privilege sets (`Administrator`, `Auditor`, `Enrollment Only`) and every individual privilege are grantable; `Custom` itself is not a role you grant directly. Revoke moves the principal to `Custom` with only the `Read License Information` privilege, which can't be revoked itself. Individual privileges can only be granted while the principal is already `Custom`: revoke the current set first, then grant the individual privileges. **Group Access** accounts are rejected for Role Grant/Revoke — assign the role to the group instead. Changing a **Full** or **Site Access** account's role removes it from any admin group it belonged to.
**Managed Devices is opt-in.** This resource type is off by default in C1 so existing connectors keep working after upgrading. Enable it by selecting the **Managed Device** resource type in the connector's sync configuration. Self-hosted runs sync it unless `--sync-resource-types`/`$BATON_SYNC_RESOURCE_TYPES` leaves it out. When enabled, the Jamf account used by the connector must additionally have the **Read Computers** and **Read Mobile Devices** privileges, or the sync will fail.
-Tested with Jamf Pro 11.32.1. Managed Devices use the Jamf Pro API v4 computers-inventory and v2 mobile-devices endpoints.
+Requires Jamf Pro 11.26.0 or later (Managed Devices: 11.30.0).
## Gather Jamf credentials
@@ -91,9 +91,9 @@ The **Administrator** privilege set covers everything. For least privilege, gran
| Sync users | `Read User` |
| Sync user accounts and groups | `Read Accounts` |
| Sync user groups | `Read Static User Groups`, `Read Smart User Groups` (Smart is only needed when smart groups exist — it's checked per group) |
-| Sync sites | `Read Sites`, `Read User`, `Read Static User Groups`, `Read Smart User Groups`, `Read Accounts` (site grants are built by reading users, user groups and accounts) |
+| Sync sites | `Read Sites`, `Read Accounts` |
| Sync roles | `Read API Roles`, `Read Accounts` |
-| Sync managed devices (opt-in) | `Read Computers`, `Read Mobile Devices`, `Read User` (needed even if `user` isn't synced) |
+| Sync managed devices (opt-in) | `Read Computers`, `Read Mobile Devices` |
| Create/delete users | `Create User` and `Read User` (the connector reads the user back after creating); `Delete User` |
| Create/delete user accounts | `Create Accounts` and `Read Accounts`; `Delete Accounts` |
| Group Grant/Revoke (`userAccount` principal) | `Read Accounts`, `Update Accounts` |
@@ -104,7 +104,7 @@ The **Administrator** privilege set covers everything. For least privilege, gran
`Read Accounts`/`Create Accounts`/`Update Accounts`/`Delete Accounts` cover both admin user accounts and admin groups — Jamf has no separate privilege for account groups. Every write path reads the object first, so each `Update` privilege above needs its `Read` counterpart too.
-Jamf reports a missing privilege differently depending on which API it uses: the Classic API (`/JSSResource` — Users, Accounts, Static User Groups) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the roles catalog, Computers, Mobile Devices) with HTTP 403. The connector reports both as a permission error.
+A missing privilege fails the sync or the operation with a permission error.
## Configure the Jamf connector
@@ -160,7 +160,7 @@ Choose an authentication method and enter its fields:
Both methods also take:
- **Instance URL**: The base URL of your Jamf Pro instance, including `https://` (for example `https://yourcompany.jamfcloud.com`).
-- **Account Provisioning Target** (optional): Which Jamf account type ConductorOne should create when provisioning accounts — **user** (default) creates directory users, **userAccount** creates Jamf Pro console admin accounts. Only one type can be created at a time per connector instance.
+- **Account Provisioning Target** (optional): Which Jamf account type C1 should create when provisioning accounts — **user** (default) creates directory users, **userAccount** creates Jamf Pro console admin accounts. Only one type can be created at a time per connector instance.
{/* AUTO-GENERATED:END - config-params */}
@@ -309,7 +309,7 @@ Check that the connector data uploaded correctly. In C1, click **Apps**. On the
-You can find pre-built binaries for `baton-jamf` on the [ConductorOne download center](https://dist.conductorone.com/ConductorOne/baton-jamf).
+You can find pre-built binaries for `baton-jamf` on the [C1 download center](https://dist.conductorone.com/ConductorOne/baton-jamf).
diff --git a/docs/docs-info.md b/docs/docs-info.md
index 0e0fd54b..0412434b 100644
--- a/docs/docs-info.md
+++ b/docs/docs-info.md
@@ -23,7 +23,7 @@ Yes — account creation/deletion for **User** and **User Account**, plus Grant/
| Resource | Grant | Revoke | Create | Delete |
|----------|-------|--------|--------|--------|
| **User** | - | - | ✅ Creates a Jamf directory user (`POST /JSSResource/users/id/0`) | ✅ Deletes a Jamf directory user |
-| **User Account** | - | - | ✅ Creates a Jamf Pro console admin account (`POST /JSSResource/accounts/userid/0`), with a C1-generated random password returned as plaintext | ✅ Deletes a Jamf Pro console admin account |
+| **User Account** | - | - | ✅ Creates a Jamf Pro console admin account (`POST /JSSResource/accounts/userid/0`), with a C1-generated random password returned as plaintext | ✅ Deletes a Jamf Pro console admin account, except the one the connector authenticates with |
| **Group** | ✅ `userAccount`, Group Access only | ✅ | - | - |
| **User Group** | ✅ `user`, static groups only | ✅ | - | - |
| **Role** | ✅ `userAccount`/`group` | ✅ | - | - |
@@ -70,7 +70,9 @@ See the "Jamf account privileges" section of the README / connector docs page fo
None identified — every endpoint below is part of standard Jamf Pro, not gated behind a separate add-on or tier. The connector uses both the Classic API (`/JSSResource/*`) and the Jamf Pro API (`/api/*`):
- Classic API: `/JSSResource/users`, `/JSSResource/accounts`, `/JSSResource/usergroups`, `/JSSResource/sites` (and their `/id/{id}` and create/update/delete variants) — see the links below.
-- Jamf Pro API: `POST /api/v1/auth/token` or `POST /api/v1/oauth/token` (authentication), `GET /api/v1/auth` (validation), `GET /api/v1/api-role-privileges` (Role sync), `GET /api/v4/computers-inventory` and `PATCH /api/v4/computers-inventory-detail/{id}` (Managed Device computers), `GET`/`PATCH /api/v2/mobile-devices[/{id}]` (Managed Device mobile devices).
+- Jamf Pro API: `POST /api/v1/auth/token` or `POST /api/v1/oauth/token` (authentication), `GET /api/v1/auth` (validation and the delete self-check), `GET /api/v1/api-role-privileges` (Role sync), `GET /api/v1/users` (User sync and managed device owner lookup), `GET /api/v1/accounts` (User Account sync, Role and Site grants), `GET /api/v1/sites/{id}/objects` (Site grants), `GET /api/v4/computers-inventory` and `PATCH /api/v4/computers-inventory-detail/{id}` (Managed Device computers), `GET`/`PATCH /api/v2/mobile-devices[/{id}]` (Managed Device mobile devices).
+
+Version requirements: Jamf Pro 11.26.0 or later. `GET /api/v1/users` was added in [11.26.0](https://developer.jamf.com/jamf-pro/changelog/11260-additions), `GET /api/v1/accounts` in [11.20.0](https://developer.jamf.com/jamf-pro/changelog/11200-additions), `GET /api/v1/sites/{id}/objects` in [11.2.0](https://developer.jamf.com/jamf-pro/changelog/1120-additions) and `GET /api/v1/api-role-privileges` in [10.49.0](https://developer.jamf.com/jamf-pro/changelog/10490-additions) (Jamf Pro API changelog); an older instance fails the sync with `FailedPrecondition` naming the missing endpoint. Managed Devices (opt-in) need [11.30.0](https://developer.jamf.com/jamf-pro/changelog/11300-additions) for `/api/v4/computers-inventory`.
### Classic API content-type contract
@@ -84,6 +86,9 @@ The Jamf Pro Classic API only accepts **XML** for POST/PUT request bodies — JS
- [Create bearer token](https://developer.jamf.com/jamf-pro/reference/post_v1-auth-token)
- [Obtain an access token using an API client](https://developer.jamf.com/jamf-pro/reference/postoauthtoken)
- [API role privileges catalog](https://developer.jamf.com/jamf-pro/reference/get_v1-api-role-privileges)
+- [Users (v1)](https://developer.jamf.com/jamf-pro/reference/get_v1-users)
+- [Accounts (v1)](https://developer.jamf.com/jamf-pro/reference/get_v1-accounts)
+- [Site objects (v1)](https://developer.jamf.com/jamf-pro/reference/get_v1-sites-id-objects)
- [Computers inventory (v4)](https://developer.jamf.com/jamf-pro/reference/get_v4-computers-inventory)
- [Computers inventory detail PATCH (v4)](https://developer.jamf.com/jamf-pro/reference/patch_v4-computers-inventory-detail-id)
- [Mobile devices (v2)](https://developer.jamf.com/jamf-pro/reference/get_v2-mobile-devices)
diff --git a/pkg/connector/admin_fixture_test.go b/pkg/connector/admin_fixture_test.go
new file mode 100644
index 00000000..55d1b086
--- /dev/null
+++ b/pkg/connector/admin_fixture_test.go
@@ -0,0 +1,202 @@
+package connector
+
+import (
+ "fmt"
+ "net/http"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/conductorone/baton-jamf/pkg/jamf"
+)
+
+// proPrivilegeLevelsByName maps a Classic privilege set to the privilegeLevel the Pro accounts API reports.
+var proPrivilegeLevelsByName = map[string]string{
+ privilegeSetAdministrator: "ADMINISTRATOR",
+ privilegeSetAuditor: "AUDITOR",
+ privilegeSetEnrollmentOnly: "ENROLLMENT",
+ privilegeSetCustom: "CUSTOM",
+}
+
+// adminFixture is a fake Jamf for the role, group and site syncers: the Pro accounts list (privilegeLevel and
+// siteId filters, any page size), a site's objects, the Classic account and group details, and the privilege
+// catalog. It records every request so tests can assert what the syncers asked for.
+type adminFixture struct {
+ t *testing.T
+ accounts []jamf.UserAccount
+ groups []jamf.Group
+ catalog []string
+ // siteObjects maps "/" to the object IDs, ascending.
+ siteObjects map[string][]int
+ // deletedAccountIDs are listed by the Pro accounts API, but their Classic detail answers 404.
+ deletedAccountIDs []int
+
+ mu sync.Mutex
+ paths []string
+ accountsRequests []accountsRequest
+}
+
+type accountsRequest struct {
+ filter string
+ pageSize int
+}
+
+func newAdminFixture(t *testing.T, accounts []jamf.UserAccount, groups []jamf.Group) *adminFixture {
+ t.Helper()
+ return &adminFixture{t: t, accounts: accounts, groups: groups}
+}
+
+func (fixture *adminFixture) client() *jamf.Client {
+ return newTestJamfClient(fixture.t, fixture.ServeHTTP)
+}
+
+// requestCount returns how many requests had a path starting with prefix.
+func (fixture *adminFixture) requestCount(prefix string) int {
+ fixture.mu.Lock()
+ defer fixture.mu.Unlock()
+
+ count := 0
+ for _, path := range fixture.paths {
+ if strings.HasPrefix(path, prefix) {
+ count++
+ }
+ }
+ return count
+}
+
+// accountsRequestsSeen returns every GET /api/v1/accounts request, in order.
+func (fixture *adminFixture) accountsRequestsSeen() []accountsRequest {
+ fixture.mu.Lock()
+ defer fixture.mu.Unlock()
+
+ return slices.Clone(fixture.accountsRequests)
+}
+
+func (fixture *adminFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ fixture.mu.Lock()
+ fixture.paths = append(fixture.paths, r.URL.Path)
+ fixture.mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.URL.Path == "/api/v1/api-role-privileges":
+ encodeJSON(fixture.t, w, jamf.PrivilegesResponse{Privileges: fixture.catalog})
+ case r.URL.Path == "/api/v1/accounts":
+ fixture.serveAccounts(w, r)
+ case strings.HasPrefix(r.URL.Path, "/api/v1/sites/"):
+ fixture.serveSiteObjects(w, r)
+ case strings.HasPrefix(r.URL.Path, "/JSSResource/accounts/userid/"):
+ accountID := accountIDFromPath(fixture.t, r.URL.Path)
+ for _, account := range fixture.accounts {
+ if account.ID == accountID && !slices.Contains(fixture.deletedAccountIDs, accountID) {
+ encodeJSON(fixture.t, w, jamf.UserAccountResponse{UserAccount: account})
+ return
+ }
+ }
+ http.NotFound(w, r)
+ case strings.HasPrefix(r.URL.Path, "/JSSResource/accounts/groupid/"):
+ groupID := accountIDFromPath(fixture.t, r.URL.Path)
+ for _, group := range fixture.groups {
+ if group.ID == groupID {
+ encodeJSON(fixture.t, w, jamf.GroupResponse{Group: group})
+ return
+ }
+ }
+ http.NotFound(w, r)
+ default:
+ fixture.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ http.NotFound(w, r)
+ }
+}
+
+// serveAccounts answers GET /api/v1/accounts. It only knows a single-term privilegeLevel== or siteId== filter.
+func (fixture *adminFixture) serveAccounts(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ page, pageErr := strconv.Atoi(query.Get("page"))
+ pageSize, pageSizeErr := strconv.Atoi(query.Get("page-size"))
+ if pageErr != nil || pageSizeErr != nil || pageSize < 1 || query.Get("sort") != "id:asc" {
+ fixture.t.Errorf("unexpected accounts query %q", r.URL.RawQuery)
+ http.Error(w, "bad query", http.StatusBadRequest)
+ return
+ }
+
+ filter := query.Get("filter")
+ matching := slices.Clone(fixture.accounts)
+ field, value, found := strings.Cut(filter, "==")
+ siteID, siteIDErr := strconv.Atoi(value)
+ switch {
+ case filter == "":
+ case found && field == "privilegeLevel":
+ matching = slices.DeleteFunc(matching, func(account jamf.UserAccount) bool {
+ return proPrivilegeLevelsByName[account.PrivilegeSet] != value
+ })
+ case found && field == "siteId" && siteIDErr == nil:
+ matching = slices.DeleteFunc(matching, func(account jamf.UserAccount) bool { return account.Site.ID != siteID })
+ default:
+ fixture.t.Errorf("unexpected accounts filter %q", filter)
+ http.Error(w, "bad filter", http.StatusBadRequest)
+ return
+ }
+ fixture.mu.Lock()
+ fixture.accountsRequests = append(fixture.accountsRequests, accountsRequest{filter: filter, pageSize: pageSize})
+ fixture.mu.Unlock()
+
+ slices.SortFunc(matching, func(left, right jamf.UserAccount) int { return left.ID - right.ID })
+ start := min(page*pageSize, len(matching))
+ end := min(start+pageSize, len(matching))
+ results := make([]map[string]any, 0, end-start)
+ for _, account := range matching[start:end] {
+ results = append(results, map[string]any{
+ "id": strconv.Itoa(account.ID),
+ "username": account.Name,
+ "realname": account.FullName,
+ "email": account.Email,
+ "accountStatus": "Enabled",
+ "privilegeLevel": proPrivilegeLevelsByName[account.PrivilegeSet],
+ })
+ }
+ encodeJSON(fixture.t, w, map[string]any{"totalCount": len(matching), "results": results})
+}
+
+// serveSiteObjects answers GET /api/v1/sites/{id}/objects with a bare array, filtered by objectType=="".
+func (fixture *adminFixture) serveSiteObjects(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ page, pageErr := strconv.Atoi(query.Get("page"))
+ pageSize, pageSizeErr := strconv.Atoi(query.Get("page-size"))
+ objectType := strings.TrimSuffix(strings.TrimPrefix(query.Get("filter"), `objectType=="`), `"`)
+ if pageErr != nil || pageSizeErr != nil || pageSize != jamf.SiteObjectsPageSize || query.Get("sort") != "objectId:asc" || objectType == "" {
+ fixture.t.Errorf("unexpected site objects query %q", r.URL.RawQuery)
+ http.Error(w, "bad query", http.StatusBadRequest)
+ return
+ }
+
+ siteID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/sites/"), "/objects")
+ objectIDs := fixture.siteObjects[siteID+"/"+objectType]
+ start := min(page*pageSize, len(objectIDs))
+ end := min(start+pageSize, len(objectIDs))
+ results := make([]map[string]string, 0, end-start)
+ for _, objectID := range objectIDs[start:end] {
+ results = append(results, map[string]string{"siteId": siteID, "objectType": objectType, "objectId": strconv.Itoa(objectID)})
+ }
+ encodeJSON(fixture.t, w, results)
+}
+
+func accountIDFromPath(t *testing.T, path string) int {
+ t.Helper()
+ parts := strings.Split(path, "/")
+ id, err := strconv.Atoi(parts[len(parts)-1])
+ if err != nil {
+ t.Fatalf("parse id from path %q: %v", path, err)
+ }
+ return id
+}
+
+func testAccount(id int, privilegeSet string) jamf.UserAccount {
+ return jamf.UserAccount{
+ BaseType: jamf.BaseType{ID: id, Name: fmt.Sprintf("account%d", id)},
+ AccessLevel: "Full Access",
+ PrivilegeSet: privilegeSet,
+ }
+}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 27a8e323..9332138f 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -15,58 +15,6 @@ import (
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
)
-var (
- resourceTypeUser = &v2.ResourceType{
- Id: "user",
- DisplayName: "User",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_USER,
- },
- Annotations: annotationsForUserResourceType(),
- }
- resourceTypeGroup = &v2.ResourceType{
- Id: "group",
- DisplayName: "Group",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_GROUP,
- },
- }
- resourceTypeUserAccount = &v2.ResourceType{
- Id: "userAccount",
- DisplayName: "User Account",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_USER,
- },
- Annotations: annotationsForUserResourceType(),
- }
- resourceTypeSite = &v2.ResourceType{
- Id: "site",
- DisplayName: "Site",
- }
- resourceTypeUserGroup = &v2.ResourceType{
- Id: "userGroup",
- DisplayName: "User Group",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_GROUP,
- },
- }
- resourceTypeRole = &v2.ResourceType{
- Id: "role",
- DisplayName: "Role",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_ROLE,
- },
- }
- resourceTypeManagedDevice = &v2.ResourceType{
- Id: "managedDevice",
- DisplayName: "Managed Device",
- Traits: []v2.ResourceType_Trait{
- v2.ResourceType_TRAIT_MANAGED_DEVICE,
- },
- Annotations: annotationsForManagedDeviceResourceType(),
- }
-)
-
type Jamf struct {
client *jamf.Client
@@ -76,6 +24,14 @@ type Jamf struct {
// connector instance; Delete is not gated by this and works for both
// types regardless of the configured target.
accountProvisioningTarget string
+
+ // Whether the sync includes each type: the grants that point at it, and the reads they need, depend on that.
+ syncUsers bool
+ syncUserGroups bool
+ syncRoles bool
+ syncSites bool
+ syncUserAccounts bool
+ syncManagedDevices bool
}
// userProvisioningActive treats an empty accountProvisioningTarget as "user"
@@ -121,7 +77,16 @@ func New(ctx context.Context, cc *cfg.Jamf, opts *cli.ConnectorOpts) (connectorb
accountProvisioningTarget = resourceTypeUser.Id
}
- return &Jamf{client: client, accountProvisioningTarget: accountProvisioningTarget}, nil, nil
+ return &Jamf{
+ client: client,
+ accountProvisioningTarget: accountProvisioningTarget,
+ syncUsers: opts.WillSyncResourceType(resourceTypeUser.Id),
+ syncUserGroups: opts.WillSyncResourceType(resourceTypeUserGroup.Id),
+ syncRoles: opts.WillSyncResourceType(resourceTypeRole.Id),
+ syncSites: opts.WillSyncResourceType(resourceTypeSite.Id),
+ syncUserAccounts: opts.WillSyncResourceType(resourceTypeUserAccount.Id),
+ syncManagedDevices: opts.WillSyncResourceType(resourceTypeManagedDevice.Id),
+ }, nil, nil
}
func (j *Jamf) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
@@ -134,7 +99,7 @@ func (j *Jamf) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
}
func (j *Jamf) Validate(ctx context.Context) (annotations.Annotations, error) {
- tokenDetails, err := j.client.GetTokenDetails(ctx)
+ tokenDetails, annos, err := j.client.GetTokenDetails(ctx)
if err != nil {
return nil, fmt.Errorf("jamf-connector: error fetching token details: %w", err)
}
@@ -142,17 +107,17 @@ func (j *Jamf) Validate(ctx context.Context) (annotations.Annotations, error) {
if reflect.ValueOf(tokenDetails).IsZero() {
return nil, fmt.Errorf("jamf-connector: missing token details")
}
- return nil, nil
+ return annos, nil
}
func (j *Jamf) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 {
return []connectorbuilder.ResourceSyncerV2{
j.userSyncer(),
- groupBuilder(j.client),
+ groupBuilder(j.client, j.syncRoles, j.syncSites),
j.userAccountSyncer(),
userGroupBuilder(j.client),
- siteBuilder(j.client),
- roleBuilder(j.client),
+ siteBuilder(j.client, j.syncUsers, j.syncUserGroups, j.syncUserAccounts),
+ roleBuilder(j.client, j.syncUserAccounts),
managedDeviceBuilder(j.client),
}
}
@@ -171,7 +136,7 @@ func (j *Jamf) accountCreationSchema() *v2.ConnectorAccountCreationSchema {
// provisionableUserType for why only ever registering one target as an
// AccountManagerV2 matters.
func (j *Jamf) userSyncer() connectorbuilder.ResourceSyncerV2 {
- base := userBuilder(j.client)
+ base := userBuilder(j.client, j.syncManagedDevices)
if j.userProvisioningActive() {
return &provisionableUserType{base}
}
diff --git a/pkg/connector/group.go b/pkg/connector/group.go
index 201c1efb..10eff559 100644
--- a/pkg/connector/group.go
+++ b/pkg/connector/group.go
@@ -5,7 +5,6 @@ import (
"fmt"
"slices"
"strconv"
- "time"
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
@@ -13,6 +12,7 @@ import (
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
@@ -24,6 +24,8 @@ const memberEntitlement = "member"
type groupResourceType struct {
resourceType *v2.ResourceType
client *jamf.Client
+ syncRoles bool
+ syncSites bool
}
func (g *groupResourceType) ResourceType(_ context.Context) *v2.ResourceType {
@@ -53,82 +55,138 @@ func groupResource(group *jamf.Group, parentResourceID *v2.ResourceId) (*v2.Reso
}
func (g *groupResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- _, groups, err := g.client.GetAccounts(ctx)
+ accounts, annos, err := g.client.GetBaseAccounts(ctx)
if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to list accounts: %w", err)
}
var rv []*v2.Resource
- for _, group := range groups {
- groupCopy := group
- gr, err := groupResource(groupCopy, parentId)
+ for _, group := range accounts.Groups {
+ resource, err := groupResource(&group, parentId)
if err != nil {
return nil, nil, err
}
- rv = append(rv, gr)
+ rv = append(rv, resource)
}
- return rv, nil, nil
+ return rv, &rs.SyncOpResults{Annotations: annos}, nil
}
-func (g *groupResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
- var rv []*v2.Entitlement
-
- assignmentOptions := []ent.EntitlementOption{
- ent.WithGrantableTo(resourceTypeUserAccount),
- ent.WithDescription(fmt.Sprintf("Member of %s Group", resource.DisplayName)),
- ent.WithDisplayName(fmt.Sprintf("%s Group %s", resource.DisplayName, memberEntitlement)),
- }
-
- en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assignmentOptions...)
- rv = append(rv, en)
+// Entitlements is unused: StaticEntitlements declares the member entitlement.
+func (g *groupResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ return nil, nil, nil
+}
- return rv, nil, nil
+func (g *groupResourceType) StaticEntitlements(_ context.Context, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ return []*v2.Entitlement{
+ ent.NewAssignmentEntitlement(nil, memberEntitlement,
+ ent.WithDisplayName("Member"),
+ ent.WithDescription("Member of the Jamf account group"),
+ ent.WithGrantableTo(resourceTypeUserAccount),
+ ),
+ }, nil, nil
}
+// Grants emits the group's members, the roles it holds and its site, paged by grant ID.
func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
- var rv []*v2.Grant
-
groupId, err := strconv.Atoi(resource.Id.Resource)
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: invalid group id %q: %w", resource.Id.Resource, err)
}
- // HACK: the endpoint to get group details returns a members list, but it comes back empty
- // sometimes when it shouldn't. This is a bug in the Jamf API.
- // This is a workaround to get the members list as of 22/05/2025 and is not 100% reliable.
- // but from what's i've seen, it will return the members list after 2-3 tries. (if there are
- // any members at all in that group)
- // https://developer.jamf.com/jamf-pro/reference/findgroupsbyid
- // if this endpoint becomes reliable again, we can remove this for loop
- var group *jamf.Group
- count := 0
- for count < 5 {
- group, err = g.client.GetGroupDetails(ctx, groupId)
+ page, nextCursor, annos, err := g.grantsPage(ctx, attrs.Session, resource, groupId, attrs.PageToken.Token)
+ if err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: list group %d grants: %w", groupId, err)
+ }
+ return page, &rs.SyncOpResults{NextPageToken: nextCursor, Annotations: annos}, nil
+}
+
+// grantsPage returns the group's grants page after cursor, a grant id. Classic returns the whole group at once,
+// so a read from Jamf stores the group when it has later pages; a group missing from the store is read again.
+func (g *groupResourceType) grantsPage(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, groupId int, cursor string) ([]*v2.Grant, string, annotations.Annotations, error) {
+ if cursor != "" {
+ stored, found, err := cachedGroupDetails(ctx, store, groupId)
if err != nil {
- return nil, nil, err
+ return nil, "", nil, err
}
- if len(group.Members) > 0 {
- break
+ if found {
+ return g.groupGrants(ctx, store, resource, &stored, cursor)
}
- count++
- time.Sleep(time.Second)
}
- for _, user := range group.Members {
- userAccountDetails, err := g.client.GetUserAccountDetails(ctx, user.ID)
- if err != nil {
- return nil, nil, err
+ group, annos, err := g.client.GetGroupDetails(ctx, groupId)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ page, nextCursor, catalogAnnos, err := g.groupGrants(ctx, store, resource, group, cursor)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ // A catalog read comes after the group read.
+ if catalogAnnos != nil {
+ annos = catalogAnnos
+ }
+ if nextCursor != "" {
+ if err := cacheGroupDetails(ctx, store, groupId, group); err != nil {
+ return nil, "", nil, err
}
- ur, err := userAccountResource(userAccountDetails, resource.Id)
+ }
+ return page, nextCursor, annos, nil
+}
+
+// groupGrants returns the page after cursor of one group's grants: its members, then the roles and the site it holds.
+// The annotations are the privilege catalog read's, when it read the catalog.
+func (g *groupResourceType) groupGrants(
+ ctx context.Context,
+ store sessions.SessionStore,
+ resource *v2.Resource,
+ group *jamf.Group,
+ cursor string,
+) ([]*v2.Grant, string, annotations.Annotations, error) {
+ grants := make([]*v2.Grant, 0, len(group.Members))
+ for _, member := range group.Members {
+ account := &v2.ResourceId{ResourceType: resourceTypeUserAccount.Id, Resource: strconv.Itoa(member.ID)}
+ grants = append(grants, grant.NewGrant(resource, memberEntitlement, account))
+ }
+
+ var annos annotations.Annotations
+ if g.syncRoles {
+ roles, catalogAnnos, err := g.heldRoles(ctx, store, group)
if err != nil {
- return nil, nil, err
+ return nil, "", nil, err
}
+ annos = catalogAnnos
+ for _, role := range roles {
+ roleID := &v2.ResourceId{ResourceType: resourceTypeRole.Id, Resource: role}
+ grants = append(grants, grant.NewGrant(&v2.Resource{Id: roleID}, memberEntitlement, resource.Id))
+ }
+ }
+
+ // Jamf reports a site id of -1 for none.
+ if g.syncSites && group.Site.ID > 0 {
+ siteID := &v2.ResourceId{ResourceType: resourceTypeSite.Id, Resource: strconv.Itoa(group.Site.ID)}
+ grants = append(grants, grant.NewGrant(&v2.Resource{Id: siteID}, memberEntitlement, resource.Id))
+ }
+ page, nextCursor, err := pageGrants(grants, cursor)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ return page, nextCursor, annos, nil
+}
- grant := grant.NewGrant(resource, memberEntitlement, ur.Id)
- rv = append(rv, grant)
+// heldRoles returns the roles a group holds: its privilege set, or the individual privileges of a Custom group.
+func (g *groupResourceType) heldRoles(ctx context.Context, store sessions.SessionStore, group *jamf.Group) ([]string, annotations.Annotations, error) {
+ if slices.Contains(privilegeSets, group.PrivilegeSet) {
+ return []string{group.PrivilegeSet}, nil, nil
+ }
+ if group.PrivilegeSet != privilegeSetCustom {
+ return nil, nil, nil
}
- return rv, nil, nil
+ catalog, annos, err := privilegeCatalogSet(ctx, store, g.client)
+ if err != nil {
+ return nil, nil, err
+ }
+ return customPrivileges(group.PrivilegeSet, &group.Privileges, catalog), annos, nil
}
// Grant adds principal (a Jamf admin account) to the static admin account
@@ -147,7 +205,7 @@ func (g *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, e
return nil, nil, err
}
- current, err := g.client.GetGroupDetails(ctx, groupID)
+ current, annos, err := g.client.GetGroupDetails(ctx, groupID)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant group member: group %d not found", groupID)
@@ -164,14 +222,16 @@ func (g *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, e
}
if slices.ContainsFunc(current.Members, func(m jamf.BaseType) bool { return m.ID == userID }) {
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
// Membership only grants anything to a Group Access account — a Full or
// Site Access account's rights come from its own privilege_set, so
// adding it to a group would silently do nothing. Fetched fresh, right
// before the write, so this reflects the account's current access level.
- account, err := g.client.GetUserAccountDetails(ctx, userID)
+ // The write below supplies the annotations.
+ account, _, err := g.client.GetUserAccountDetails(ctx, userID)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant group member: account %d not found", userID)
@@ -184,11 +244,12 @@ func (g *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, e
}
newMembers := append(append([]jamf.BaseType{}, current.Members...), jamf.BaseType{ID: userID})
- if err := g.client.UpdateGroupMembers(ctx, groupID, current.Name, newMembers); err != nil {
+ annos, err = g.client.UpdateGroupMembers(ctx, groupID, current.Name, newMembers)
+ if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: grant group member: %w", err)
}
- return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, nil, nil
+ return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, annos, nil
}
// Revoke removes gr's principal (a Jamf admin account) from the static
@@ -205,7 +266,7 @@ func (g *groupResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotatio
return nil, err
}
- current, err := g.client.GetGroupDetails(ctx, groupID)
+ current, annos, err := g.client.GetGroupDetails(ctx, groupID)
if err != nil {
if jamf.IsNotFoundError(err) {
// The group has been deleted — a deleted group trivially has no
@@ -237,19 +298,23 @@ func (g *groupResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotatio
remaining = append(remaining, member)
}
if !found {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
- if err := g.client.UpdateGroupMembers(ctx, groupID, current.Name, remaining); err != nil {
+ annos, err = g.client.UpdateGroupMembers(ctx, groupID, current.Name, remaining)
+ if err != nil {
return nil, fmt.Errorf("jamf-connector: revoke group member: %w", err)
}
- return nil, nil
+ return annos, nil
}
-func groupBuilder(client *jamf.Client) *groupResourceType {
+func groupBuilder(client *jamf.Client, syncRoles, syncSites bool) *groupResourceType {
return &groupResourceType{
resourceType: resourceTypeGroup,
client: client,
+ syncRoles: syncRoles,
+ syncSites: syncSites,
}
}
diff --git a/pkg/connector/group_test.go b/pkg/connector/group_test.go
index 897b59da..ec64d0e3 100644
--- a/pkg/connector/group_test.go
+++ b/pkg/connector/group_test.go
@@ -6,12 +6,15 @@ import (
"fmt"
"io"
"net/http"
+ "slices"
"strings"
"testing"
"github.com/conductorone/baton-jamf/pkg/jamf"
+ "github.com/conductorone/baton-sdk/pkg/pagination"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
+ rs "github.com/conductorone/baton-sdk/pkg/types/resource"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -144,7 +147,7 @@ func failOnCallHandler(t *testing.T) http.HandlerFunc {
func TestGroupGrant_Success(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{10}, {10, 42}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
grants, annos, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err != nil {
@@ -169,7 +172,7 @@ func TestGroupGrant_Success(t *testing.T) {
func TestGroupGrant_AlreadyMember_MapsToGrantAlreadyExists(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{10, 42}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, annos, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err != nil {
@@ -186,7 +189,7 @@ func TestGroupGrant_AlreadyMember_MapsToGrantAlreadyExists(t *testing.T) {
func TestGroupGrant_EmptyMemberList_AbortsWithoutWrite(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, _, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err == nil {
@@ -202,7 +205,7 @@ func TestGroupGrant_EmptyMemberList_AbortsWithoutWrite(t *testing.T) {
func TestGroupGrant_Group404_MapsToNotFound(t *testing.T) {
client := newTestJamfClient(t, groupNotFoundHandler(t))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, _, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err == nil {
@@ -222,7 +225,7 @@ func TestGroupGrant_AccountFullAccess_Rejected(t *testing.T) {
rawGroupHandler(t, 7, "Test Group", [][]int{{10}}, &putBodies),
accountAccessLevelHandler("Full Access"),
))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, _, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err == nil {
@@ -244,7 +247,7 @@ func TestGroupGrant_AccountNotFound_MapsToNotFound(t *testing.T) {
rawGroupHandler(t, 7, "Test Group", [][]int{{10}}, &putBodies),
accountNotFoundHandler(t),
))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, _, err := g.Grant(context.Background(), userAccountPrincipal(t, 42), groupEntitlement(t, 7))
if err == nil {
@@ -260,7 +263,7 @@ func TestGroupGrant_AccountNotFound_MapsToNotFound(t *testing.T) {
func TestGroupGrant_NonUserAccountPrincipal_Rejected(t *testing.T) {
client := newTestJamfClient(t, failOnCallHandler(t))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
_, _, err := g.Grant(context.Background(), groupPrincipal(t, 99), groupEntitlement(t, 7))
if err == nil {
@@ -274,7 +277,7 @@ func TestGroupGrant_NonUserAccountPrincipal_Rejected(t *testing.T) {
func TestGroupRevoke_Success(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{10, 42}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrant(t, 7, 42)
annos, err := g.Revoke(context.Background(), gr)
@@ -301,7 +304,7 @@ func TestGroupRevoke_Success(t *testing.T) {
func TestGroupRevoke_LastMember_ExplicitEmptyMembersElement(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{42}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrant(t, 7, 42)
if _, err := g.Revoke(context.Background(), gr); err != nil {
@@ -318,7 +321,7 @@ func TestGroupRevoke_LastMember_ExplicitEmptyMembersElement(t *testing.T) {
func TestGroupRevoke_NotAMember_MapsToGrantAlreadyRevoked(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{10}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrant(t, 7, 42)
annos, err := g.Revoke(context.Background(), gr)
@@ -336,7 +339,7 @@ func TestGroupRevoke_NotAMember_MapsToGrantAlreadyRevoked(t *testing.T) {
func TestGroupRevoke_EmptyMemberList_ReturnsRetryableErrorNoWrite(t *testing.T) {
var putBodies [][]byte
client := newTestJamfClient(t, groupHandler(t, 7, "Test Group", [][]int{{}}, &putBodies))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrant(t, 7, 42)
_, err := g.Revoke(context.Background(), gr)
@@ -353,7 +356,7 @@ func TestGroupRevoke_EmptyMemberList_ReturnsRetryableErrorNoWrite(t *testing.T)
func TestGroupRevoke_Group404_MapsToGrantAlreadyRevoked(t *testing.T) {
client := newTestJamfClient(t, groupNotFoundHandler(t))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrant(t, 7, 42)
annos, err := g.Revoke(context.Background(), gr)
@@ -367,7 +370,7 @@ func TestGroupRevoke_Group404_MapsToGrantAlreadyRevoked(t *testing.T) {
func TestGroupRevoke_NonUserAccountPrincipal_Rejected(t *testing.T) {
client := newTestJamfClient(t, failOnCallHandler(t))
- g := groupBuilder(client)
+ g := groupBuilder(client, false, false)
gr := groupRevokeGrantForPrincipal(t, 7, groupPrincipal(t, 99))
_, err := g.Revoke(context.Background(), gr)
@@ -390,3 +393,87 @@ func groupRevokeGrantForPrincipal(t *testing.T, groupID int, principal *v2.Resou
t.Helper()
return grant.NewGrant(groupEntitlement(t, groupID).Resource, memberEntitlement, principal.Id)
}
+
+// Member grants come from one group read, and the session store serves the later pages; any per-member account
+// read would 404 and fail the sync.
+func TestGroupGrants_PagesPastOneThousandMembers(t *testing.T) {
+ const memberCount = 1500
+ members := make([]jamf.BaseType, 0, memberCount)
+ for _, id := range idRange(1, memberCount) {
+ members = append(members, jamf.BaseType{ID: id})
+ }
+ fixture := newAdminFixture(t, nil, []jamf.Group{{BaseType: jamf.BaseType{ID: 5, Name: "Test Group"}, Members: members}})
+ builder := groupBuilder(fixture.client(), false, false)
+ resource := groupPrincipal(t, 5)
+ // Fresh reads keep the HTTP cache from hiding the Jamf reads this test counts.
+ freshGrants := func(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
+ return builder.Grants(jamf.WithFreshReads(ctx), resource, attrs)
+ }
+ const groupReads = "/JSSResource/accounts/groupid/"
+
+ grants, calls := collectGrants(t, freshGrants, resource, newMemorySessionStore())
+
+ if calls != 2 {
+ t.Errorf("Grants calls = %d, want 2", calls)
+ }
+ if got := len(grantPrincipals(t, grants)); got != memberCount {
+ t.Errorf("unique principals = %d, want %d", got, memberCount)
+ }
+ if reads := fixture.requestCount(groupReads); reads != 1 {
+ t.Errorf("Jamf reads = %d, want 1: the session store serves page 1", reads)
+ }
+
+ // A page missing from the store is read from Jamf again.
+ _, firstResults, err := freshGrants(context.Background(), resource, rs.SyncOpAttrs{Session: newMemorySessionStore()})
+ if err != nil {
+ t.Fatalf("Grants page 0: %v", err)
+ }
+ secondPage, results, err := freshGrants(context.Background(), resource, rs.SyncOpAttrs{
+ Session: newMemorySessionStore(),
+ PageToken: pagination.Token{Token: firstResults.NextPageToken},
+ })
+ if err != nil {
+ t.Fatalf("Grants page 1 on a store miss: %v", err)
+ }
+ if len(secondPage) != memberCount-grantsPageSize || results.NextPageToken != "" {
+ t.Errorf("page 1 = %d grants, next token %q; want %d grants and no token", len(secondPage), results.NextPageToken, memberCount-grantsPageSize)
+ }
+ if reads := fixture.requestCount(groupReads); reads != 3 {
+ t.Errorf("Jamf reads after the store miss = %d, want 3", reads)
+ }
+}
+
+// A Custom group with a site: its member, role and site grants all come from one group read.
+func TestGroupGrants_EmitsRoleAndSiteGrantsFromTheSameRead(t *testing.T) {
+ customGroup := jamf.Group{
+ BaseType: jamf.BaseType{ID: 30, Name: "group30"},
+ PrivilegeSet: privilegeSetCustom,
+ Privileges: jamf.Privileges{
+ JSSObjects: []string{"Read Users", "Not In Catalog"},
+ Recon: []string{"Read Computers", "Read Users"},
+ },
+ Site: jamf.BaseType{ID: 3},
+ Members: []jamf.BaseType{{ID: 10}, {ID: 11}},
+ }
+ fixture := newAdminFixture(t, nil, []jamf.Group{customGroup})
+ fixture.catalog = []string{"Delete Users", "Read Computers", "Read Users"}
+ builder := groupBuilder(fixture.client(), true, true)
+
+ grants, _ := collectGrants(t, builder.Grants, groupPrincipal(t, customGroup.ID), newMemorySessionStore())
+
+ // Each grant as ":|:".
+ got := make([]string, 0, len(grants))
+ for _, memberGrant := range grants {
+ target := memberGrant.GetEntitlement().GetResource().GetId()
+ principal := memberGrant.GetPrincipal().GetId()
+ got = append(got, fmt.Sprintf("%s:%s|%s:%s", target.GetResourceType(), target.GetResource(), principal.GetResourceType(), principal.GetResource()))
+ }
+ slices.Sort(got)
+ want := []string{"group:30|userAccount:10", "group:30|userAccount:11", "role:Read Computers|group:30", "role:Read Users|group:30", "site:3|group:30"}
+ if !slices.Equal(got, want) {
+ t.Errorf("grants = %v, want %v", got, want)
+ }
+ if reads := fixture.requestCount("/JSSResource/accounts/groupid/"); reads != 1 {
+ t.Errorf("group reads = %d, want 1", reads)
+ }
+}
diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go
index f6f5f710..2d5e16c6 100644
--- a/pkg/connector/helpers.go
+++ b/pkg/connector/helpers.go
@@ -1,16 +1,45 @@
package connector
import (
+ "errors"
"fmt"
+ "slices"
+ "sort"
"strconv"
+ "strings"
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
- "github.com/conductorone/baton-sdk/pkg/annotations"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
+// grantsPageSize keeps a Grants response under gRPC's 4 MiB receive limit.
+const grantsPageSize = 1000
+
+// pageGrants returns the page of grants after cursor and the cursor of the next page, "" on the last one.
+// The cursor is a grant ID and a page starts strictly after it, so the cursor always advances.
+func pageGrants(grants []*v2.Grant, cursor string) ([]*v2.Grant, string, error) {
+ slices.SortFunc(grants, func(left, right *v2.Grant) int { return strings.Compare(left.GetId(), right.GetId()) })
+
+ start := 0
+ if cursor != "" {
+ start = sort.Search(len(grants), func(index int) bool { return grants[index].GetId() > cursor })
+ }
+
+ end := min(start+grantsPageSize, len(grants))
+ page := grants[start:end]
+ if end == len(grants) {
+ return page, "", nil
+ }
+
+ nextCursor := page[len(page)-1].GetId()
+ if nextCursor == "" {
+ return nil, "", errors.New("jamf-connector: grants page has more items but an empty cursor")
+ }
+ return page, nextCursor, nil
+}
+
// stringSliceFromProfile reads a repeated-string field out of an account
// creation profile map (as produced by structpb's AsMap — a []interface{} of
// strings), tolerating an absent or wrongly-typed field by returning nil.
@@ -28,25 +57,6 @@ func stringSliceFromProfile(profileMap map[string]interface{}, key string) []str
return out
}
-func annotationsForUserResourceType() annotations.Annotations {
- annos := annotations.Annotations{}
- annos.Update(&v2.SkipEntitlementsAndGrants{})
- return annos
-}
-
-// annotationsForManagedDeviceResourceType marks the managedDevice resource type
-// as opt-in. The OptInRequired annotation is surfaced in baton_capabilities.json
-// so the C1 platform leaves device syncing OFF by default; existing installs
-// whose Jamf API role lacks "Read Computers" / "Read Mobile Devices" are
-// therefore unaffected until the type is explicitly enabled. See the
-// registration gate in (*Jamf).ResourceSyncers for the connector-side
-// enforcement that keeps local/CLI syncs off by default too.
-func annotationsForManagedDeviceResourceType() annotations.Annotations {
- annos := annotations.Annotations{}
- annos.Update(&v2.OptInRequired{})
- return annos
-}
-
// requirePrincipalType rejects principalType unless it matches want, naming
// noun/verb in the InvalidArgument message (e.g. "group membership" /
// "granted to user accounts").
diff --git a/pkg/connector/helpers_test.go b/pkg/connector/helpers_test.go
new file mode 100644
index 00000000..29d771eb
--- /dev/null
+++ b/pkg/connector/helpers_test.go
@@ -0,0 +1,105 @@
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+ "testing"
+
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
+ "github.com/conductorone/baton-sdk/pkg/pagination"
+ rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
+)
+
+type grantsFunc func(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error)
+
+// collectGrants follows NextPageToken until it is empty and returns every
+// grant plus the number of Grants calls it took.
+func collectGrants(t *testing.T, grantsFn grantsFunc, resource *v2.Resource, store sessions.SessionStore) ([]*v2.Grant, int) {
+ t.Helper()
+
+ var collected []*v2.Grant
+ token := ""
+ for calls := 1; calls <= 100; calls++ {
+ grants, results, err := grantsFn(context.Background(), resource, rs.SyncOpAttrs{
+ Session: store,
+ PageToken: pagination.Token{Token: token},
+ })
+ if err != nil {
+ t.Fatalf("Grants call %d: %v", calls, err)
+ }
+ collected = append(collected, grants...)
+
+ if results == nil || results.NextPageToken == "" {
+ return collected, calls
+ }
+ if results.NextPageToken == token {
+ t.Fatalf("Grants call %d returned the same page token %q", calls, token)
+ }
+ token = results.NextPageToken
+ }
+ t.Fatal("Grants did not terminate within 100 calls")
+ return nil, 0
+}
+
+// grantPrincipals returns the sorted ":" of each grant's principal and fails on a duplicate.
+func grantPrincipals(t *testing.T, grants []*v2.Grant) []string {
+ t.Helper()
+
+ principals := make([]string, 0, len(grants))
+ for _, grant := range grants {
+ principalID := grant.GetPrincipal().GetId()
+ principals = append(principals, principalID.GetResourceType()+":"+principalID.GetResource())
+ }
+ slices.Sort(principals)
+ if len(slices.Compact(slices.Clone(principals))) != len(principals) {
+ t.Fatal("grants contain a duplicate principal")
+ }
+ return principals
+}
+
+// encodeJSON writes body as the JSON response; t.Errorf is safe from handler goroutines.
+func encodeJSON(t *testing.T, w http.ResponseWriter, body any) {
+ t.Helper()
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ t.Errorf("encode response: %v", err)
+ }
+}
+
+// idRange returns count consecutive IDs starting at first.
+func idRange(first, count int) []int {
+ ids := make([]int, 0, count)
+ for id := first; id < first+count; id++ {
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+// hasMarkerAnnotation reports whether annos carries a GrantAlreadyExists, GrantAlreadyRevoked or GrantReplaced marker.
+func hasMarkerAnnotation(annos annotations.Annotations) bool {
+ return annos.ContainsAny(&v2.GrantAlreadyExists{}, &v2.GrantAlreadyRevoked{}, &v2.GrantReplaced{})
+}
+
+// A cursor whose grant was removed since the previous page still resumes right after its position.
+func TestPageGrants_CursorGrantRemovedBetweenPages(t *testing.T) {
+ grants := make([]*v2.Grant, 0, 5)
+ for i := range 5 {
+ grants = append(grants, &v2.Grant{Id: fmt.Sprintf("grant-%d", i)})
+ }
+
+ got, nextCursor, err := pageGrants(grants, "grant-1a")
+ if err != nil {
+ t.Fatalf("pageGrants: %v", err)
+ }
+
+ if len(got) != 3 || nextCursor != "" {
+ t.Fatalf("page = %d grants, next cursor %q; want 3 grants and no cursor", len(got), nextCursor)
+ }
+ if got[0].GetId() != "grant-2" {
+ t.Errorf("first grant = %q, want grant-2", got[0].GetId())
+ }
+}
diff --git a/pkg/connector/managedDevice.go b/pkg/connector/managedDevice.go
index 62edfe42..cf23d8c0 100644
--- a/pkg/connector/managedDevice.go
+++ b/pkg/connector/managedDevice.go
@@ -3,6 +3,7 @@ package connector
import (
"context"
"fmt"
+ "slices"
"strconv"
"strings"
"sync"
@@ -79,17 +80,10 @@ type managedDeviceResourceType struct {
resourceType *v2.ResourceType
client *jamf.Client
- // userIndex maps lowercased username / email keys to the ResourceId of the
- // synced Jamf user resource, so devices can cross-link their assigned owner.
- // It is built lazily on first use and cached for the life of the process;
- // List invalidates it at the start of each sync (see its first-page check)
- // so a long-running process never serves a stale user list across syncs.
- //
// deviceOwners maps a device resource id to the assignee recorded while
// listing devices, so Entitlements/Grants can emit the device->user grant
- // without carrying it on the resource.
+ // without carrying it on the resource. mu guards it.
mu sync.Mutex
- userIndex map[string]*v2.ResourceId
deviceOwners map[string]assignee
}
@@ -98,13 +92,6 @@ func (d *managedDeviceResourceType) ResourceType(_ context.Context) *v2.Resource
}
func (d *managedDeviceResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- if attrs.PageToken.Token == "" {
- // No incoming pagination cursor means this is the first page of a new
- // sync; drop the cached user index so getUserIndex rebuilds it instead
- // of serving a list left over from a prior sync.
- d.resetUserIndex()
- }
-
bag := &pagination.Bag{}
if err := bag.Unmarshal(attrs.PageToken.Token); err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to parse device page token: %w", err)
@@ -122,13 +109,15 @@ func (d *managedDeviceResourceType) List(ctx context.Context, parentId *v2.Resou
page, seen := parseDevicePageToken(current.Token)
var resources []*v2.Resource
+ var annos annotations.Annotations
switch current.ResourceTypeID {
case devicePhaseComputer:
- resp, err := d.client.GetComputersInventory(ctx, page, pageSize, jamf.ComputerInventorySections)
+ resp, pageAnnos, err := d.client.GetComputersInventory(ctx, page, pageSize, jamf.ComputerInventorySections)
if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to list computers inventory: %w", err)
}
+ annos = pageAnnos
for i := range resp.Results {
c := &resp.Results[i]
r, err := computerResource(c, parentId)
@@ -151,10 +140,11 @@ func (d *managedDeviceResourceType) List(ctx context.Context, parentId *v2.Resou
}
case devicePhaseMobile:
- resp, err := d.client.GetMobileDevices(ctx, page, pageSize)
+ resp, pageAnnos, err := d.client.GetMobileDevices(ctx, page, pageSize)
if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to list mobile devices: %w", err)
}
+ annos = pageAnnos
for i := range resp.Results {
m := &resp.Results[i]
r, err := mobileDeviceResource(m, parentId)
@@ -182,7 +172,7 @@ func (d *managedDeviceResourceType) List(ctx context.Context, parentId *v2.Resou
return nil, nil, err
}
- return resources, &rs.SyncOpResults{NextPageToken: nextToken}, nil
+ return resources, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil
}
// Entitlements exposes the "assigned" assignment entitlement, but only for
@@ -205,18 +195,18 @@ func (d *managedDeviceResourceType) Entitlements(_ context.Context, resource *v2
// When the assignee is a synced Jamf user it grants that user directly; otherwise
// it annotates the grant with an ExternalResourceMatch so the platform can bind it
// to a directory identity from an external source.
-func (d *managedDeviceResourceType) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
- a := d.deviceAssignee(resource)
- if a.empty() {
+func (d *managedDeviceResourceType) Grants(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
+ owner := d.deviceAssignee(resource)
+ if owner.empty() {
return nil, nil, nil
}
- userIndex, err := d.getUserIndex(ctx)
+ userIDs, err := cachedUserIDs(ctx, attrs.Session, assigneeLookupKeys(owner))
if err != nil {
- return nil, nil, fmt.Errorf("jamf-connector: failed to build user index for device owner resolution: %w", err)
+ return nil, nil, fmt.Errorf("jamf-connector: failed to resolve device owner: %w", err)
}
- grants, err := deviceGrants(resource, a, userIndex)
+ grants, err := deviceGrants(resource, owner, userResourceIDs(userIDs))
if err != nil {
return nil, nil, err
}
@@ -248,7 +238,7 @@ func (d *managedDeviceResourceType) Grant(ctx context.Context, principal *v2.Res
return nil, nil, status.Errorf(codes.FailedPrecondition, "jamf-connector: jamf user %s has no username", principal.Id.Resource)
}
- current, err := d.currentAssignedUser(ctx, entitlement.Resource)
+ current, annos, err := d.currentAssignedUser(ctx, entitlement.Resource)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant device assigned: device %q not found", entitlement.Resource.Id.Resource)
@@ -261,14 +251,15 @@ func (d *managedDeviceResourceType) Grant(ctx context.Context, principal *v2.Res
if current.matches(newAssignee) {
// principal is already the live assignee — nothing would be displaced,
// and re-sending the same PATCH would only churn Jamf's audit log.
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
- if err := d.setAssignedUser(ctx, entitlement.Resource, newAssignee); err != nil {
+ annos, err = d.setAssignedUser(ctx, entitlement.Resource, newAssignee)
+ if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: grant device assigned: %w", err)
}
- var annos annotations.Annotations
if !current.empty() {
replacedGrantID, ok, rerr := d.replacedGrantID(ctx, current, entitlement)
switch {
@@ -282,7 +273,7 @@ func (d *managedDeviceResourceType) Grant(ctx context.Context, principal *v2.Res
ctxzap.Extract(ctx).Debug("jamf-connector: grant device assigned: failed to resolve previous assignee for GrantReplaced",
zap.Error(rerr))
case ok:
- annos = annotations.New(&v2.GrantReplaced{ReplacedGrantId: replacedGrantID})
+ annos.Update(&v2.GrantReplaced{ReplacedGrantId: replacedGrantID})
}
}
@@ -313,7 +304,7 @@ func (d *managedDeviceResourceType) Revoke(ctx context.Context, gr *v2.Grant) (a
return nil, fmt.Errorf("jamf-connector: revoke device assigned: resolve principal identity: %w", err)
}
- current, err := d.currentAssignedUser(ctx, gr.Entitlement.Resource)
+ current, annos, err := d.currentAssignedUser(ctx, gr.Entitlement.Resource)
if err != nil {
if jamf.IsNotFoundError(err) {
// The device itself has been deleted — nothing left to revoke.
@@ -327,7 +318,8 @@ func (d *managedDeviceResourceType) Revoke(ctx context.Context, gr *v2.Grant) (a
// this grant was synced. Clearing now would either be a no-op or,
// worse, would clear a different user's live assignment, so treat
// this specific grant as already gone rather than touching the device.
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
// Clearing the whole assignee identity: for computers, every
@@ -338,10 +330,11 @@ func (d *managedDeviceResourceType) Revoke(ctx context.Context, gr *v2.Grant) (a
// the same way via location.username alone (see
// SetMobileDeviceAssignedUser): Jamf drops the auto-populated fields
// together with it.
- if err := d.setAssignedUser(ctx, gr.Entitlement.Resource, assignee{}); err != nil {
+ annos, err = d.setAssignedUser(ctx, gr.Entitlement.Resource, assignee{})
+ if err != nil {
return nil, fmt.Errorf("jamf-connector: revoke device assigned: %w", err)
}
- return nil, nil
+ return annos, nil
}
// setAssignedUser dispatches to the computer or mobile-device client call
@@ -349,10 +342,10 @@ func (d *managedDeviceResourceType) Revoke(ctx context.Context, gr *v2.Grant) (a
// id at List() time. A computer's userAndLocation is written with every
// field of a; mobile devices only send a.username (Jamf auto-populates the
// rest, see SetMobileDeviceAssignedUser). The zero assignee clears the device.
-func (d *managedDeviceResourceType) setAssignedUser(ctx context.Context, resource *v2.Resource, a assignee) error {
+func (d *managedDeviceResourceType) setAssignedUser(ctx context.Context, resource *v2.Resource, a assignee) (annotations.Annotations, error) {
phase, id, err := parseDeviceObjectID(resource.Id.Resource)
if err != nil {
- return err
+ return nil, err
}
if phase == devicePhaseComputer {
@@ -373,31 +366,31 @@ func (d *managedDeviceResourceType) setAssignedUser(ctx context.Context, resourc
// into the resource id. Returns the zero assignee when the device is
// currently unassigned; email is always "" for mobile devices, since the
// mobile-device detail endpoint this uses exposes no email field.
-func (d *managedDeviceResourceType) currentAssignedUser(ctx context.Context, resource *v2.Resource) (assignee, error) {
+func (d *managedDeviceResourceType) currentAssignedUser(ctx context.Context, resource *v2.Resource) (assignee, annotations.Annotations, error) {
phase, id, err := parseDeviceObjectID(resource.Id.Resource)
if err != nil {
- return assignee{}, err
+ return assignee{}, nil, err
}
if phase == devicePhaseComputer {
- detail, err := d.client.GetComputerInventoryDetail(ctx, id)
+ detail, annos, err := d.client.GetComputerInventoryDetail(ctx, id)
if err != nil {
- return assignee{}, err
+ return assignee{}, nil, err
}
if detail.UserAndLocation == nil {
- return assignee{}, nil
+ return assignee{}, annos, nil
}
- return assignee{username: detail.UserAndLocation.Username, email: detail.UserAndLocation.EmailAddr()}, nil
+ return assignee{username: detail.UserAndLocation.Username, email: detail.UserAndLocation.EmailAddr()}, annos, nil
}
- detail, err := d.client.GetMobileDeviceDetail(ctx, id)
+ detail, annos, err := d.client.GetMobileDeviceDetail(ctx, id)
if err != nil {
- return assignee{}, err
+ return assignee{}, nil, err
}
if detail.Location == nil {
- return assignee{}, nil
+ return assignee{}, annos, nil
}
- return assignee{username: detail.Location.Username}, nil
+ return assignee{username: detail.Location.Username}, annos, nil
}
// parseDeviceObjectID reverses deviceObjectID, splitting a resource id like
@@ -420,7 +413,8 @@ func resolvePrincipalUser(ctx context.Context, client *jamf.Client, principal *v
return assignee{}, err
}
- user, err := client.GetUserDetails(ctx, userID)
+ // Grant and Revoke return the device calls' annotations, not this read's.
+ user, _, err := client.GetUserDetails(ctx, userID)
if err != nil {
return assignee{}, err
}
@@ -460,20 +454,23 @@ func assigneePrincipal(idx map[string]*v2.ResourceId, a assignee) (*v2.ResourceI
// replacedGrantID resolves a device's outgoing assignee to the grant id
// Grants() would have emitted for them, so Grant can report a GrantReplaced
// annotation naming the exact grant being displaced. ok is false only when
-// there is truly nothing to resolve (a is empty).
-func (d *managedDeviceResourceType) replacedGrantID(ctx context.Context, a assignee, entitlement *v2.Entitlement) (string, bool, error) {
- idx, err := d.getUserIndex(ctx)
+// there is truly nothing to resolve (owner is empty).
+func (d *managedDeviceResourceType) replacedGrantID(ctx context.Context, owner assignee, entitlement *v2.Entitlement) (string, bool, error) {
+ keys := assigneeLookupKeys(owner)
+ if len(keys) == 0 {
+ return "", false, nil
+ }
+
+ // Grant has no session store, so ask Jamf. Grant returns the PATCH's annotations, not this read's.
+ users, _, err := d.client.GetProUsersByUsernameOrEmail(ctx, keys)
if err != nil {
return "", false, err
}
- rid, _, err := assigneePrincipal(idx, a)
+ principal, _, err := assigneePrincipal(userResourceIDs(userIDsByLookupKey(users)), owner)
if err != nil {
return "", false, err
}
- if rid == nil {
- return "", false, nil
- }
- return grant.NewGrantID(rid, entitlement), true, nil
+ return grant.NewGrantID(principal, entitlement), true, nil
}
// principalIdentityForRevoke resolves gr's principal to the assignee Revoke
@@ -518,56 +515,6 @@ func managedDeviceBuilder(client *jamf.Client) *managedDeviceResourceType {
}
}
-// getUserIndex lazily builds (and caches) the username/email -> user ResourceId
-// lookup used to cross-link a device to its assigned owner. On error nothing is
-// cached, so a subsequent call retries.
-func (d *managedDeviceResourceType) getUserIndex(ctx context.Context) (map[string]*v2.ResourceId, error) {
- d.mu.Lock()
- defer d.mu.Unlock()
-
- if d.userIndex != nil {
- return d.userIndex, nil
- }
-
- users, err := d.client.GetUsers(ctx)
- if err != nil {
- return nil, err
- }
-
- idx := make(map[string]*v2.ResourceId, len(users))
- for _, u := range users {
- if u == nil {
- continue
- }
- rid := &v2.ResourceId{}
- rid.SetResourceType(resourceTypeUser.Id)
- rid.SetResource(strconv.Itoa(u.ID))
-
- for _, key := range []string{u.Name, u.Username, u.Email, u.EmailAddress} {
- key = strings.ToLower(strings.TrimSpace(key))
- if key == "" {
- continue
- }
- // First writer wins so a stable user keeps the key on collisions.
- if _, exists := idx[key]; !exists {
- idx[key] = rid
- }
- }
- }
-
- d.userIndex = idx
- return d.userIndex, nil
-}
-
-// resetUserIndex drops the cached user index so the next getUserIndex call
-// rebuilds it. Called at the start of a managed-device sync (List's first
-// page).
-func (d *managedDeviceResourceType) resetUserIndex() {
- d.mu.Lock()
- defer d.mu.Unlock()
- d.userIndex = nil
-}
-
// computerResource maps a Jamf computer-inventory record onto a ManagedDevice
// resource carrying a ManagedDeviceTrait.
func computerResource(c *jamf.ComputerInventory, parentResourceID *v2.ResourceId) (*v2.Resource, error) {
@@ -869,10 +816,31 @@ func mobileOS(m *jamf.MobileDevice) *v2.DeviceOS {
return d
}
+// assigneeLookupKeys returns the distinct non-empty lookup keys of owner's username and email.
+func assigneeLookupKeys(owner assignee) []string {
+ keys := make([]string, 0, 2)
+ for _, value := range []string{owner.username, owner.email} {
+ key := userLookupKey(value)
+ if key != "" && !slices.Contains(keys, key) {
+ keys = append(keys, key)
+ }
+ }
+ return keys
+}
+
+// userResourceIDs maps each lookup key to its user ResourceId.
+func userResourceIDs(userIDs map[string]string) map[string]*v2.ResourceId {
+ resourceIDs := make(map[string]*v2.ResourceId, len(userIDs))
+ for key, userID := range userIDs {
+ resourceIDs[key] = &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: userID}
+ }
+ return resourceIDs
+}
+
// resolveUser looks up a synced user ResourceId by username then email.
func resolveUser(idx map[string]*v2.ResourceId, username, email string) (*v2.ResourceId, bool) {
for _, key := range []string{username, email} {
- key = strings.ToLower(strings.TrimSpace(key))
+ key = userLookupKey(key)
if key == "" {
continue
}
diff --git a/pkg/connector/managedDevice_test.go b/pkg/connector/managedDevice_test.go
index b5509a99..9c6c0a0e 100644
--- a/pkg/connector/managedDevice_test.go
+++ b/pkg/connector/managedDevice_test.go
@@ -440,12 +440,10 @@ func jamfDeviceAssignHandlerEmail(t *testing.T, principalUsername, principalEmai
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
switch {
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users":
- // Grant's getUserIndex scan when resolving a GrantReplaced
- // annotation for the outgoing assignee — empty is fine here, the
- // annotation's exact id isn't asserted by these tests.
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users":
+ // Owner lookup for the GrantReplaced annotation; no match is fine, these tests don't assert its id.
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"users": []any{}})
+ encodeJSON(t, w, map[string]any{"totalCount": 0, "results": []any{}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/JSSResource/users/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -531,8 +529,8 @@ func TestManagedDeviceRevoke_ExternalMatchPrincipal_Patches(t *testing.T) {
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations (grant should be revoked), got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations (grant should be revoked), got %v", annos)
}
if patchBody == nil {
t.Fatal("expected a PATCH to be sent for an externally-matched principal that matches the current assignee")
@@ -570,8 +568,8 @@ func TestManagedDeviceRevoke_ExternalMatchPrincipal_EmailOnly_DeviceHasUsername_
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations (grant should be revoked), got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations (grant should be revoked), got %v", annos)
}
if patchBody == nil {
t.Fatal("expected a PATCH to be sent: the principal's email matches the device's current assignee even though the device also reports a username")
@@ -597,8 +595,8 @@ func TestManagedDeviceGrant_Computer_PatchesUserAndLocation(t *testing.T) {
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations for a previously-unassigned device, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations for a previously-unassigned device, got %v", annos)
}
if want := `{"userAndLocation":{"username":"jappleseed","realname":"","email":"","position":"","phone":""}}` + "\n"; string(patchBody) != want {
t.Errorf("PATCH body = %q, want %q", string(patchBody), want)
@@ -665,8 +663,8 @@ func TestManagedDeviceGrant_Mobile_PatchesLocation(t *testing.T) {
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations for a previously-unassigned device, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations for a previously-unassigned device, got %v", annos)
}
if want := `{"location":{"username":"jappleseed"}}` + "\n"; string(patchBody) != want {
t.Errorf("PATCH body = %q, want %q", string(patchBody), want)
@@ -836,13 +834,8 @@ func TestManagedDeviceGrant_SameAssignee_ReturnsGrantAlreadyExists(t *testing.T)
}
}
-// jamfDeviceGrantDisplaceHandler extends jamfDeviceAssignHandlerWithCurrent's
-// computer-only endpoints with GET /JSSResource/users (base user list) and GET
-// /JSSResource/users/id/{oldUserID} (outgoing-assignee detail), so
-// getUserIndex can resolve the device's outgoing assignee to a synced user's
-// ResourceId for Grant's GrantReplaced annotation. oldUserID == 0 models an
-// outgoing assignee that isn't a synced Jamf user (GetUsers returns nobody
-// matching), exercising the ExternalResourceMatch-style fallback instead.
+// deviceGrantDisplaceScenario is the principal being granted, the device's current assignee, and the synced user
+// that assignee resolves to. oldUserID 0 means the assignee is not a synced user: the owner lookup finds nobody.
type deviceGrantDisplaceScenario struct {
principalID int
principalUsername, principalEmail string
@@ -851,6 +844,8 @@ type deviceGrantDisplaceScenario struct {
oldUsername, oldEmail string
}
+// jamfDeviceGrantDisplaceHandler extends jamfDeviceAssignHandlerWithCurrent's computer-only endpoints with
+// GET /api/v1/users, which replacedGrantID uses to resolve the outgoing assignee for Grant's GrantReplaced annotation.
func jamfDeviceGrantDisplaceHandler(t *testing.T, sc deviceGrantDisplaceScenario, gotPATCHBody *[]byte) http.HandlerFunc {
t.Helper()
principalID, principalUsername, principalEmail := sc.principalID, sc.principalUsername, sc.principalEmail
@@ -858,23 +853,18 @@ func jamfDeviceGrantDisplaceHandler(t *testing.T, sc deviceGrantDisplaceScenario
oldUserID, oldUsername, oldEmail := sc.oldUserID, sc.oldUsername, sc.oldEmail
return func(w http.ResponseWriter, r *http.Request) {
switch {
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users":
- w.Header().Set("Content-Type", "application/json")
- var users []map[string]any
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users":
+ users := []map[string]any{}
if oldUserID != 0 {
- users = append(users, map[string]any{"id": oldUserID, "name": oldUsername})
+ users = append(users, map[string]any{"id": strconv.Itoa(oldUserID), "username": oldUsername, "email": oldEmail})
}
- _ = json.NewEncoder(w).Encode(map[string]any{"users": users})
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"totalCount": len(users), "results": users})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, fmt.Sprintf("/JSSResource/users/id/%d", principalID)):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{"id": principalID, "name": principalUsername, "username": principalUsername, "email": principalEmail},
})
- case oldUserID != 0 && r.Method == http.MethodGet && strings.Contains(r.URL.Path, fmt.Sprintf("/JSSResource/users/id/%d", oldUserID)):
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "user": map[string]any{"id": oldUserID, "name": oldUsername, "username": oldUsername, "email": oldEmail},
- })
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/v4/computers-inventory-detail/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -940,7 +930,7 @@ func TestManagedDeviceGrant_DisplacesDifferentUser_ReturnsGrantReplaced(t *testi
// TestManagedDeviceGrant_DisplacesUnsyncedUser_ReturnsGrantReplacedExternalMatch
// is the unsynced-assignee counterpart: the outgoing assignee doesn't resolve
-// to any synced Jamf user via getUserIndex (oldUserID 0 — GetUsers returns
+// to any synced Jamf user via the owner lookup (oldUserID 0 — the API finds
// nobody), so GrantReplaced must fall back to the same external-match-style
// principal id deviceGrants builds for that case.
func TestManagedDeviceGrant_DisplacesUnsyncedUser_ReturnsGrantReplacedExternalMatch(t *testing.T) {
@@ -993,8 +983,8 @@ func TestManagedDeviceRevoke_ClearsUsername(t *testing.T) {
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations, got %v", annos)
}
if want := `{"userAndLocation":{"username":"","realname":"","email":"","position":"","phone":""}}` + "\n"; string(patchBody) != want {
t.Errorf("PATCH body = %q, want %q", string(patchBody), want)
@@ -1091,8 +1081,8 @@ func TestManagedDeviceRevoke_MobileDevice_ClearsUsername(t *testing.T) {
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations, got %v", annos)
}
if want := `{"location":{"username":""}}` + "\n"; string(patchBody) != want {
t.Errorf("PATCH body = %q, want %q", string(patchBody), want)
@@ -1119,8 +1109,8 @@ func TestManagedDeviceRevoke_EmailDoesNotMatch_StillClearsEverything(t *testing.
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations (grant should be revoked), got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations (grant should be revoked), got %v", annos)
}
if patchBody == nil {
t.Fatal("expected a PATCH to be sent to clear the assignee")
@@ -1158,8 +1148,8 @@ func TestManagedDeviceRevoke_ExternalMatchPrincipal_EmailDoesNotMatch_StillClear
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations (grant should be revoked), got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations (grant should be revoked), got %v", annos)
}
if patchBody == nil {
t.Fatal("expected a PATCH to be sent to clear the assignee")
@@ -1291,32 +1281,16 @@ func TestOSTypeFromName(t *testing.T) {
}
}
-// replacedGrantIDHandler serves the endpoints replacedGrantID's full-index
-// resolution depends on: GET /JSSResource/users plus GET
-// /JSSResource/users/id/{id}. listCalls counts requests to the bare list
-// endpoint.
-func replacedGrantIDHandler(t *testing.T, indexUsers []map[string]any, listCalls *int) http.HandlerFunc {
+// replacedGrantIDHandler serves GET /api/v1/users with users as the results and counts the requests.
+func replacedGrantIDHandler(t *testing.T, users []map[string]any, usersCalls *int) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users":
- *listCalls++
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"users": indexUsers})
- case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/JSSResource/users/id/"):
- idStr := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
- id, _ := strconv.Atoi(idStr)
- for _, u := range indexUsers {
- if u["id"] == id {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"user": u})
- return
- }
- }
- w.WriteHeader(http.StatusNotFound)
- default:
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/users" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
+ *usersCalls++
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"totalCount": len(users), "results": users})
}
}
@@ -1330,130 +1304,54 @@ func wantReplacedGrantID(t *testing.T, userID string, en *v2.Entitlement) string
return grant.NewGrantID(rid, en)
}
-// TestReplacedGrantID_ResolvesViaIndex covers resolving a synced outgoing
-// assignee through the full user index.
-func TestReplacedGrantID_ResolvesViaIndex(t *testing.T) {
- var listCalls int
- indexUsers := []map[string]any{{"id": 7, "name": "old.user", "username": "old.user", "email": "old.user@ex.com"}}
- client := newTestJamfClient(t, replacedGrantIDHandler(t, indexUsers, &listCalls))
- d := managedDeviceBuilder(client)
+// A synced outgoing assignee resolves to that user's grant ID through the filtered users lookup.
+func TestReplacedGrantID_ResolvesViaLookup(t *testing.T) {
+ var usersCalls int
+ users := []map[string]any{{"id": "7", "username": "old.user", "email": "old.user@ex.com"}}
+ client := newTestJamfClient(t, replacedGrantIDHandler(t, users, &usersCalls))
+ devices := managedDeviceBuilder(client)
+ entitlement := deviceEntitlement(t, "computer:17")
+
+ got, ok, err := devices.replacedGrantID(context.Background(), assignee{username: "old.user", email: "old.user@ex.com"}, entitlement)
- en := deviceEntitlement(t, "computer:17")
- rid, ok, err := d.replacedGrantID(context.Background(), assignee{username: "old.user", email: "old.user@ex.com"}, en)
if err != nil {
t.Fatalf("replacedGrantID: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
- if want := wantReplacedGrantID(t, "7", en); rid != want {
- t.Errorf("replacedGrantID = %q, want %q", rid, want)
+ if want := wantReplacedGrantID(t, "7", entitlement); got != want {
+ t.Errorf("replacedGrantID = %q, want %q", got, want)
}
- if listCalls != 1 {
- t.Errorf("want 1 call to the users list endpoint, got %d", listCalls)
+ if usersCalls != 1 {
+ t.Errorf("users requests = %d, want 1", usersCalls)
}
}
-// TestReplacedGrantID_NotInIndex_ExternalMatch covers an outgoing assignee
-// that doesn't resolve to any synced Jamf user: replacedGrantID falls back
-// to the same external-match-style resource id deviceGrants builds.
-func TestReplacedGrantID_NotInIndex_ExternalMatch(t *testing.T) {
- var listCalls int
- client := newTestJamfClient(t, replacedGrantIDHandler(t, nil, &listCalls))
- d := managedDeviceBuilder(client)
+// An outgoing assignee that is not a synced user falls back to the external-match user ID deviceGrants emits.
+func TestReplacedGrantID_NotFound_ExternalMatch(t *testing.T) {
+ var usersCalls int
+ client := newTestJamfClient(t, replacedGrantIDHandler(t, nil, &usersCalls))
+ devices := managedDeviceBuilder(client)
+ entitlement := deviceEntitlement(t, "computer:17")
+
+ got, ok, err := devices.replacedGrantID(context.Background(), assignee{email: "ghost@ex.com"}, entitlement)
- en := deviceEntitlement(t, "computer:17")
- rid, ok, err := d.replacedGrantID(context.Background(), assignee{email: "ghost@ex.com"}, en)
if err != nil {
t.Fatalf("replacedGrantID: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
- externalRid, err := rs.NewResourceID(resourceTypeUser, "ghost@ex.com")
+ externalUserID, err := rs.NewResourceID(resourceTypeUser, "ghost@ex.com")
if err != nil {
t.Fatalf("NewResourceID: %v", err)
}
- if want := grant.NewGrantID(externalRid, en); rid != want {
- t.Errorf("replacedGrantID = %q, want %q", rid, want)
- }
- if listCalls != 1 {
- t.Errorf("want 1 call to the users list endpoint, got %d", listCalls)
- }
-}
-
-// emptyDevicePagesHandler serves empty computers-inventory and mobile-devices
-// pages (ending the sync after one page of each phase), plus the Classic API
-// users list endpoint used by getUserIndex, counting calls to the latter.
-func emptyDevicePagesHandler(listCalls *int) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == http.MethodGet && r.URL.Path == "/api/v4/computers-inventory":
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}, "totalCount": 0})
- case r.Method == http.MethodGet && r.URL.Path == "/api/v2/mobile-devices":
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}, "totalCount": 0})
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users":
- *listCalls++
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"users": []any{}})
- }
- }
-}
-
-// TestManagedDeviceList_InvalidatesUserIndex_OnNewSyncOnly covers the
-// userIndex invalidation requirement: List must drop the cached index at the
-// start of a new sync (its first page, carrying no incoming pagination
-// cursor) so a long-running process never serves a stale user list across
-// syncs, but must leave the cache alone between pages of the SAME sync.
-func TestManagedDeviceList_InvalidatesUserIndex_OnNewSyncOnly(t *testing.T) {
- var listCalls int
- client := newTestJamfClient(t, emptyDevicePagesHandler(&listCalls))
- d := managedDeviceBuilder(client)
- ctx := context.Background()
- // getUserIndex's GET benefits from the HTTP client's response cache on
- // sync paths (see jamf.WithFreshReads' doc comment); bypass it here so
- // listCalls observes whether getUserIndex actually re-issued the request
- // rather than the in-process cache being masked by the HTTP-level one.
- indexCtx := jamf.WithFreshReads(ctx)
-
- // Sync 1, page 1 (no incoming cursor): builds the index for the first time.
- _, results, err := d.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Token: ""}})
- if err != nil {
- t.Fatalf("List (sync 1, page 1): %v", err)
- }
- if _, err := d.getUserIndex(indexCtx); err != nil {
- t.Fatalf("getUserIndex: %v", err)
- }
- if listCalls != 1 {
- t.Fatalf("want 1 call to the users list endpoint after the first index build, got %d", listCalls)
- }
- if results.NextPageToken == "" {
- t.Fatal("expected a non-empty continuation token into the mobile-device phase")
- }
-
- // Sync 1, page 2 (carries sync 1's cursor): same sync, so the cached
- // index must survive untouched.
- if _, _, err := d.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Token: results.NextPageToken}}); err != nil {
- t.Fatalf("List (sync 1, page 2): %v", err)
+ if want := grant.NewGrantID(externalUserID, entitlement); got != want {
+ t.Errorf("replacedGrantID = %q, want %q", got, want)
}
- if _, err := d.getUserIndex(indexCtx); err != nil {
- t.Fatalf("getUserIndex: %v", err)
- }
- if listCalls != 1 {
- t.Errorf("want the cached index to survive across pages of the same sync, got %d calls to the users list endpoint", listCalls)
- }
-
- // Sync 2, page 1 (no incoming cursor again): a new sync must rebuild it.
- if _, _, err := d.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Token: ""}}); err != nil {
- t.Fatalf("List (sync 2, page 1): %v", err)
- }
- if _, err := d.getUserIndex(indexCtx); err != nil {
- t.Fatalf("getUserIndex: %v", err)
- }
- if listCalls != 2 {
- t.Errorf("want a new sync's first page to rebuild the index, got %d calls to the users list endpoint", listCalls)
+ if usersCalls != 1 {
+ t.Errorf("users requests = %d, want 1", usersCalls)
}
}
@@ -1485,3 +1383,137 @@ func TestManagedDeviceEntitlements_NeverAssigned_EmitsNothing(t *testing.T) {
t.Errorf("want no grants for a never-assigned device, got %d", len(grants))
}
}
+
+// externalResourceMatch returns the grant's ExternalResourceMatch annotation and whether it has one.
+func externalResourceMatch(t *testing.T, deviceGrant *v2.Grant) (*v2.ExternalResourceMatch, bool) {
+ t.Helper()
+ match := &v2.ExternalResourceMatch{}
+ grantAnnotations := annotations.Annotations(deviceGrant.GetAnnotations())
+ found, err := grantAnnotations.Pick(match)
+ if err != nil {
+ t.Fatalf("pick ExternalResourceMatch: %v", err)
+ }
+ return match, found
+}
+
+// A device owner already seen by the user sync resolves from the session store, case-insensitively, with no API
+// lookup. An owner the user sync never saw becomes an ExternalResourceMatch, also with no lookup.
+func TestDeviceGrants_ResolvesOwnerFromUserList(t *testing.T) {
+ users := []map[string]any{
+ {"id": "7", "username": "Jdoe", "realname": "J Doe", "email": "jdoe@example.com"},
+ {"id": "9", "username": "asmith", "realname": "A Smith", "email": ""},
+ }
+ var usersRequests int
+ client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/users" {
+ t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ usersRequests++
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"totalCount": len(users), "results": users})
+ })
+ store := newMemorySessionStore()
+ ctx := context.Background()
+ if _, _, err := userBuilder(client, true).List(ctx, nil, rs.SyncOpAttrs{Session: store}); err != nil {
+ t.Fatalf("user List: %v", err)
+ }
+ requestsAfterList := usersRequests
+
+ devices := managedDeviceBuilder(client)
+ syncedOwnerDevice := deviceResourceForTest(t, "computer:17")
+ devices.recordDeviceOwner(syncedOwnerDevice, assignee{username: "jdoe"})
+ unknownOwnerDevice := deviceResourceForTest(t, "computer:18")
+ devices.recordDeviceOwner(unknownOwnerDevice, assignee{username: "ghost"})
+
+ // The owner is a synced user: a direct grant to user 7.
+ grants, _, err := devices.Grants(ctx, syncedOwnerDevice, rs.SyncOpAttrs{Session: store})
+ if err != nil {
+ t.Fatalf("device Grants: %v", err)
+ }
+ if len(grants) != 1 {
+ t.Fatalf("want 1 grant, got %d", len(grants))
+ }
+ if got := grants[0].GetPrincipal().GetId(); got.GetResourceType() != resourceTypeUser.Id || got.GetResource() != "7" {
+ t.Errorf("principal = %s/%s, want user/7", got.GetResourceType(), got.GetResource())
+ }
+ if _, ok := externalResourceMatch(t, grants[0]); ok {
+ t.Error("expected a direct grant, got an ExternalResourceMatch")
+ }
+ if usersRequests != requestsAfterList {
+ t.Errorf("want no /api/v1/users request during Grants, got %d", usersRequests-requestsAfterList)
+ }
+
+ // The owner is not a synced user: an ExternalResourceMatch on the username.
+ grants, _, err = devices.Grants(ctx, unknownOwnerDevice, rs.SyncOpAttrs{Session: store})
+ if err != nil {
+ t.Fatalf("unknown owner device Grants: %v", err)
+ }
+ if len(grants) != 1 {
+ t.Fatalf("want 1 grant for the unknown owner, got %d", len(grants))
+ }
+ if match, ok := externalResourceMatch(t, grants[0]); !ok || match.GetValue() != "ghost" {
+ t.Errorf("want an ExternalResourceMatch on ghost, got %v (found=%v)", match, ok)
+ }
+ if usersRequests != requestsAfterList {
+ t.Errorf("want no /api/v1/users request for an unknown owner after the list, got %d", usersRequests-requestsAfterList)
+ }
+}
+
+// The last of UsersPageSize+1 users reuses user 1's email on the second page; the lowest ID keeps it.
+func TestDeviceGrants_KeyReusedOnLaterPage_KeepsLowestID(t *testing.T) {
+ userCount := jamf.UsersPageSize + 1
+ users := make([]map[string]any, 0, userCount)
+ for _, id := range idRange(1, userCount) {
+ email := fmt.Sprintf("user%d@example.com", id)
+ if id == userCount {
+ email = "user1@example.com"
+ }
+ users = append(users, map[string]any{
+ "id": strconv.Itoa(id),
+ "username": fmt.Sprintf("user%d", id),
+ "realname": fmt.Sprintf("User Number%d", id),
+ "email": email,
+ })
+ }
+ client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ page, pageErr := strconv.Atoi(query.Get("page"))
+ pageSize, pageSizeErr := strconv.Atoi(query.Get("page-size"))
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/users" || pageErr != nil || pageSizeErr != nil {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL)
+ http.Error(w, "unexpected request", http.StatusBadRequest)
+ return
+ }
+
+ start := min(page*pageSize, len(users))
+ end := min(start+pageSize, len(users))
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"totalCount": len(users), "results": users[start:end]})
+ })
+ store := newMemorySessionStore()
+ ctx := context.Background()
+ userSyncer := userBuilder(client, true)
+ _, results, err := userSyncer.List(ctx, nil, rs.SyncOpAttrs{Session: store})
+ if err != nil {
+ t.Fatalf("user List page 1: %v", err)
+ }
+ _, _, err = userSyncer.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Token: results.NextPageToken}})
+ if err != nil {
+ t.Fatalf("user List page 2: %v", err)
+ }
+ devices := managedDeviceBuilder(client)
+ device := deviceResourceForTest(t, "computer:17")
+ devices.recordDeviceOwner(device, assignee{email: "user1@example.com"})
+
+ grants, _, err := devices.Grants(ctx, device, rs.SyncOpAttrs{Session: store})
+
+ if err != nil {
+ t.Fatalf("device Grants: %v", err)
+ }
+ if len(grants) != 1 {
+ t.Fatalf("want 1 grant, got %d", len(grants))
+ }
+ if got := grants[0].GetPrincipal().GetId(); got.GetResourceType() != resourceTypeUser.Id || got.GetResource() != "1" {
+ t.Errorf("principal = %s/%s, want user/1", got.GetResourceType(), got.GetResource())
+ }
+}
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
new file mode 100644
index 00000000..c80b6bfe
--- /dev/null
+++ b/pkg/connector/resource_types.go
@@ -0,0 +1,63 @@
+package connector
+
+import (
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
+)
+
+var (
+ resourceTypeUser = &v2.ResourceType{
+ Id: "user",
+ DisplayName: "User",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_USER,
+ },
+ Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}),
+ }
+ resourceTypeGroup = &v2.ResourceType{
+ Id: "group",
+ DisplayName: "Group",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_GROUP,
+ },
+ Annotations: annotations.New(&v2.SkipEntitlements{}),
+ }
+ resourceTypeUserAccount = &v2.ResourceType{
+ Id: "userAccount",
+ DisplayName: "User Account",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_USER,
+ },
+ Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}),
+ }
+ resourceTypeSite = &v2.ResourceType{
+ Id: "site",
+ DisplayName: "Site",
+ Annotations: annotations.New(&v2.SkipEntitlements{}),
+ }
+ resourceTypeUserGroup = &v2.ResourceType{
+ Id: "userGroup",
+ DisplayName: "User Group",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_GROUP,
+ },
+ Annotations: annotations.New(&v2.SkipEntitlements{}),
+ }
+ resourceTypeRole = &v2.ResourceType{
+ Id: "role",
+ DisplayName: "Role",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_ROLE,
+ },
+ Annotations: annotations.New(&v2.SkipEntitlements{}, &v2.TypeScopedGrants{}),
+ }
+ resourceTypeManagedDevice = &v2.ResourceType{
+ Id: "managedDevice",
+ DisplayName: "Managed Device",
+ Traits: []v2.ResourceType_Trait{
+ v2.ResourceType_TRAIT_MANAGED_DEVICE,
+ },
+ // Opt-in: existing API roles may lack Read Computers and Read Mobile Devices.
+ Annotations: annotations.New(&v2.OptInRequired{}),
+ }
+)
diff --git a/pkg/connector/role.go b/pkg/connector/role.go
index a1afe8ee..e3b9e649 100644
--- a/pkg/connector/role.go
+++ b/pkg/connector/role.go
@@ -4,13 +4,17 @@ import (
"context"
"fmt"
"slices"
+ "strconv"
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
+ "github.com/conductorone/baton-sdk/pkg/connectorbuilder"
+ "github.com/conductorone/baton-sdk/pkg/pagination"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
"github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
@@ -18,10 +22,13 @@ import (
)
type roleResourceType struct {
- resourceType *v2.ResourceType
- client *jamf.Client
+ resourceType *v2.ResourceType
+ client *jamf.Client
+ syncUserAccounts bool
}
+var _ connectorbuilder.TypeScopedGrantsSyncer = (*roleResourceType)(nil)
+
func (o *roleResourceType) ResourceType(_ context.Context) *v2.ResourceType {
return o.resourceType
}
@@ -43,6 +50,14 @@ func roleResource(ctx context.Context, role string, parentResourceID *v2.Resourc
"role_id": role,
}
+ description := fmt.Sprintf("Individual privilege %q — only grantable while the principal's privilege set is %q", role, privilegeSetCustom)
+ if slices.Contains(privilegeSets, role) {
+ description = fmt.Sprintf(
+ "Privilege set of %s — granting this overwrites the principal's current privilege set; revoking moves the principal to %q with only the %q privilege",
+ role, privilegeSetCustom, privilegeReadLicenseInformation,
+ )
+ }
+
ret, err := resource.NewRoleResource(
role,
resourceTypeRole,
@@ -50,6 +65,7 @@ func roleResource(ctx context.Context, role string, parentResourceID *v2.Resourc
nil,
resource.WithParentResourceID(parentResourceID),
resource.WithResourceProfile(profile),
+ resource.WithDescription(description),
)
if err != nil {
return nil, err
@@ -68,12 +84,13 @@ func (o *roleResourceType) List(ctx context.Context, parentId *v2.ResourceId, at
rv = append(rv, rr)
}
- res, err := o.client.GetPrivileges(ctx)
+ // Also stores the catalog the grants read, so Jamf is asked once per sync.
+ privileges, annos, err := privilegeCatalog(ctx, attrs.Session, o.client)
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: list privileges: %w", err)
}
- for _, privilege := range res.Privileges {
+ for _, privilege := range privileges {
rr, err := roleResource(ctx, privilege, parentId)
if err != nil {
return nil, nil, err
@@ -81,90 +98,148 @@ func (o *roleResourceType) List(ctx context.Context, parentId *v2.ResourceId, at
rv = append(rv, rr)
}
- return rv, nil, nil
+ return rv, &resource.SyncOpResults{Annotations: annos}, nil
}
-// Entitlements declares every Role resource — the three built-in privilege
-// sets and every individual privilege — as grantable to both userAccount and
-// group principals. Grant/Revoke (below) are the source of truth for the
-// actual rules (a privilege set is always grantable; an individual privilege
-// is only meaningfully grantable while the principal's privilege_set is
-// Custom) — this declaration is deliberately permissive so the platform can
-// always attempt a grant and get back a precise, actionable error instead of
-// never offering the option at all. Custom itself is not a Role resource —
-// see List — so it is never declared grantable here either.
-func (o *roleResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) {
- description := fmt.Sprintf("Individual privilege %q — only grantable while the principal's privilege set is %q", resource.DisplayName, privilegeSetCustom)
- if slices.Contains(privilegeSets, resource.Id.Resource) {
- description = fmt.Sprintf(
- "Privilege set of %s — granting this overwrites the principal's current privilege set; revoking moves the principal to %q with only the %q privilege",
- resource.DisplayName, privilegeSetCustom, privilegeReadLicenseInformation,
- )
- }
+// Entitlements is unused: StaticEntitlements declares the member entitlement.
+func (o *roleResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) {
+ return nil, nil, nil
+}
- privilegesEn := ent.NewPermissionEntitlement(resource, memberEntitlement,
- ent.WithDisplayName(fmt.Sprintf("%s privilege set %s", resource.DisplayName, memberEntitlement)),
- ent.WithDescription(description),
- ent.WithGrantableTo(resourceTypeUserAccount, resourceTypeGroup),
- )
+// Grantable to both even where Jamf refuses the grant, so Grant answers with an actionable error.
+func (o *roleResourceType) StaticEntitlements(_ context.Context, _ resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) {
+ return []*v2.Entitlement{
+ ent.NewPermissionEntitlement(nil, memberEntitlement,
+ ent.WithDisplayName("Member"),
+ ent.WithGrantableTo(resourceTypeUserAccount, resourceTypeGroup),
+ ),
+ }, nil, nil
+}
- return []*v2.Entitlement{privilegesEn}, nil, nil
+// Grants is unused: GrantsForResourceType emits the accounts' role grants; the group Grants emits the groups'.
+func (o *roleResourceType) Grants(_ context.Context, _ *v2.Resource, _ resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) {
+ return nil, nil, nil
}
-// matchesIndividualPrivilege reports whether an account/group holding
-// privilegeSet and privileges should be granted the given individual
-// privilege role. Privileges is only meaningful for a Custom privilege_set
-// (see jamf.AccountPrivilegesUpdateBody.Privileges) — a built-in set's
-// Privileges data, if Jamf ever returns any, must not be treated as an
-// access grant.
-func matchesIndividualPrivilege(privilegeSet string, privileges *jamf.Privileges, privilege string) bool {
- return privilegeSet == privilegeSetCustom && privileges.Contains(privilege)
+// Phases of the type-scoped role grants.
+const (
+ rolePhasePrivilegeSets = "privilegeSets"
+ rolePhaseCustomAccounts = "customAccounts"
+)
+
+// customAccountsPageSize keeps a page of Custom accounts holding every privilege (~528 grants of ~270 bytes each)
+// well under gRPC's 4 MiB response limit.
+const customAccountsPageSize = 10
+
+// GrantsForResourceType emits the accounts' role grants in two phases: each account's privilege set,
+// then the individual privileges of the Custom accounts. Groups' role grants come from the group Grants.
+func (o *roleResourceType) GrantsForResourceType(ctx context.Context, _ string, attrs resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) {
+ // Role grants only name accounts.
+ if !o.syncUserAccounts {
+ return nil, nil, nil
+ }
+
+ bag := &pagination.Bag{}
+ if err := bag.Unmarshal(attrs.PageToken.Token); err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: parse role grants page token: %w", err)
+ }
+ if bag.Current() == nil {
+ bag.Push(pagination.PageState{ResourceTypeID: rolePhaseCustomAccounts})
+ bag.Push(pagination.PageState{ResourceTypeID: rolePhasePrivilegeSets})
+ }
+
+ phase := bag.Current()
+ var grants []*v2.Grant
+ var nextPage string
+ var annos annotations.Annotations
+ var err error
+ switch phase.ResourceTypeID {
+ case rolePhasePrivilegeSets:
+ grants, nextPage, annos, err = o.privilegeSetGrants(ctx, phase.Token)
+ case rolePhaseCustomAccounts:
+ grants, nextPage, annos, err = o.customPrivilegeGrants(ctx, attrs.Session, phase.Token)
+ default:
+ err = fmt.Errorf("unknown phase %q", phase.ResourceTypeID)
+ }
+ if err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: list role grants (%s): %w", phase.ResourceTypeID, err)
+ }
+
+ nextToken, err := bag.NextToken(nextPage)
+ if err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: build role grants page token: %w", err)
+ }
+ return grants, &resource.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil
}
-func (o *roleResourceType) Grants(ctx context.Context, resource *v2.Resource, attrs resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) {
- var rv []*v2.Grant
- isCustomPrivilege := !slices.Contains(privilegeSets, resource.Id.Resource)
- userAccounts, groups, err := o.client.GetAccounts(ctx)
+// privilegeSetGrants grants each account on the page its privilege set: Administrator, Auditor or Enrollment Only.
+func (o *roleResourceType) privilegeSetGrants(ctx context.Context, page string) ([]*v2.Grant, string, annotations.Annotations, error) {
+ accounts, nextPage, annos, err := o.client.GetProAccounts(ctx, "", page, jamf.AccountsPageSize)
if err != nil {
- return nil, nil, err
+ return nil, "", nil, err
}
- for _, group := range groups {
- groupCopy := group
- gr, err := groupResource(groupCopy, resource.Id)
- if err != nil {
- return nil, nil, err
+ var grants []*v2.Grant
+ for _, account := range accounts {
+ privilegeSet := account.UserAccount().PrivilegeSet
+ if slices.Contains(privilegeSets, privilegeSet) {
+ grants = append(grants, roleGrant(privilegeSet, account.ID))
}
+ }
+ return grants, nextPage, annos, nil
+}
- if isCustomPrivilege && matchesIndividualPrivilege(group.PrivilegeSet, &group.Privileges, resource.Id.Resource) {
- privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
- rv = append(rv, privilegeGrant)
- continue
- }
- if group.PrivilegeSet == resource.Id.Resource {
- privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
- rv = append(rv, privilegeGrant)
- }
+// customPrivilegeGrants grants each Custom account on the page the individual privileges it holds.
+func (o *roleResourceType) customPrivilegeGrants(ctx context.Context, store sessions.SessionStore, page string) ([]*v2.Grant, string, annotations.Annotations, error) {
+ // The accounts and detail reads below supply the annotations.
+ catalog, _, err := privilegeCatalogSet(ctx, store, o.client)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ accounts, nextPage, annos, err := o.client.GetProAccounts(ctx, "privilegeLevel==CUSTOM", page, customAccountsPageSize)
+ if err != nil {
+ return nil, "", nil, err
}
- for _, userAccount := range userAccounts {
- userAccountCopy := userAccount
- gr, err := userAccountResource(userAccountCopy, resource.Id)
+ var grants []*v2.Grant
+ for _, account := range accounts {
+ // The accounts list has no privileges; only the account detail does.
+ detail, detailAnnos, err := o.client.GetUserAccountDetails(ctx, account.ID)
+ if jamf.IsNotFoundError(err) {
+ ctxzap.Extract(ctx).Debug("jamf-connector: account deleted since it was listed; skipping its privileges",
+ zap.Int("account_id", account.ID))
+ continue
+ }
if err != nil {
- return nil, nil, err
+ return nil, "", nil, err
}
-
- if isCustomPrivilege && matchesIndividualPrivilege(userAccount.PrivilegeSet, &userAccount.Privileges, resource.Id.Resource) {
- privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
- rv = append(rv, privilegeGrant)
- continue
+ annos = detailAnnos
+ for _, privilege := range customPrivileges(detail.PrivilegeSet, &detail.Privileges, catalog) {
+ grants = append(grants, roleGrant(privilege, account.ID))
}
- if userAccount.PrivilegeSet == resource.Id.Resource {
- privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
- rv = append(rv, privilegeGrant)
+ }
+ return grants, nextPage, annos, nil
+}
+
+// customPrivileges returns the catalog privileges held individually; only a Custom privilege set confers them.
+func customPrivileges(privilegeSet string, privileges *jamf.Privileges, catalog map[string]struct{}) []string {
+ if privilegeSet != privilegeSetCustom {
+ return nil
+ }
+ var held []string
+ for _, privilege := range dedupeStrings(privileges.All()) {
+ if _, inCatalog := catalog[privilege]; inCatalog {
+ held = append(held, privilege)
}
}
- return rv, nil, nil
+ return held
+}
+
+// roleGrant is an account's member grant on a role.
+func roleGrant(role string, accountID int) *v2.Grant {
+ roleID := &v2.ResourceId{ResourceType: resourceTypeRole.Id, Resource: role}
+ account := &v2.ResourceId{ResourceType: resourceTypeUserAccount.Id, Resource: strconv.Itoa(accountID)}
+ return grant.NewGrant(&v2.Resource{Id: roleID}, memberEntitlement, account)
}
// rolePrincipal is the account or group whose role is being changed, as last
@@ -206,16 +281,16 @@ func (p *rolePrincipal) requireDirectPrivileges(verb string) error {
// getRolePrincipal reads the account/group backing rid. Client errors are
// returned unwrapped so callers can check jamf.IsNotFoundError.
-func (o *roleResourceType) getRolePrincipal(ctx context.Context, rid *v2.ResourceId) (*rolePrincipal, error) {
+func (o *roleResourceType) getRolePrincipal(ctx context.Context, rid *v2.ResourceId) (*rolePrincipal, annotations.Annotations, error) {
id, err := rolePrincipalID(rid)
if err != nil {
- return nil, err
+ return nil, nil, err
}
if rid.ResourceType == resourceTypeUserAccount.Id {
- account, err := o.client.GetUserAccountDetails(ctx, id)
+ account, annos, err := o.client.GetUserAccountDetails(ctx, id)
if err != nil {
- return nil, err
+ return nil, nil, err
}
return &rolePrincipal{
resourceType: rid.ResourceType,
@@ -224,12 +299,12 @@ func (o *roleResourceType) getRolePrincipal(ctx context.Context, rid *v2.Resourc
privilegeSet: account.PrivilegeSet,
privileges: account.Privileges,
accessLevel: account.AccessLevel,
- }, nil
+ }, annos, nil
}
- group, err := o.client.GetGroupDetails(ctx, id)
+ group, annos, err := o.client.GetGroupDetails(ctx, id)
if err != nil {
- return nil, err
+ return nil, nil, err
}
return &rolePrincipal{
resourceType: rid.ResourceType,
@@ -237,13 +312,13 @@ func (o *roleResourceType) getRolePrincipal(ctx context.Context, rid *v2.Resourc
name: group.Name,
privilegeSet: group.PrivilegeSet,
privileges: group.Privileges,
- }, nil
+ }, annos, nil
}
// updateRolePrincipal sends the minimal privilege-only PUT for p. A nil
// privileges omits the element entirely; a non-nil pointer — even
// to an all-empty Privileges — forces an explicit element.
-func (o *roleResourceType) updateRolePrincipal(ctx context.Context, p *rolePrincipal, privilegeSet string, privileges *jamf.Privileges) error {
+func (o *roleResourceType) updateRolePrincipal(ctx context.Context, p *rolePrincipal, privilegeSet string, privileges *jamf.Privileges) (annotations.Annotations, error) {
if p.resourceType == resourceTypeUserAccount.Id {
return o.client.UpdateAccountPrivileges(ctx, p.id, p.name, privilegeSet, privileges)
}
@@ -257,7 +332,7 @@ func (o *roleResourceType) updateRolePrincipal(ctx context.Context, p *rolePrinc
func (o *roleResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {
ctx = jamf.WithFreshReads(ctx)
- p, err := o.getRolePrincipal(ctx, principal.Id)
+ p, annos, err := o.getRolePrincipal(ctx, principal.Id)
if err != nil {
if status.Code(err) == codes.InvalidArgument {
return nil, nil, err
@@ -275,9 +350,9 @@ func (o *roleResourceType) Grant(ctx context.Context, principal *v2.Resource, en
target := entitlement.Resource.Id.Resource
if slices.Contains(privilegeSets, target) {
- return o.grantPrivilegeSet(ctx, p, target, principal.Id, entitlement)
+ return o.grantPrivilegeSet(ctx, p, annos, target, principal.Id, entitlement)
}
- return o.grantIndividualPrivilege(ctx, p, target, principal.Id, entitlement)
+ return o.grantIndividualPrivilege(ctx, p, annos, target, principal.Id, entitlement)
}
// grantPrivilegeSet handles Grant of a built-in privilege set. Single-valued
@@ -287,6 +362,7 @@ func (o *roleResourceType) Grant(ctx context.Context, principal *v2.Resource, en
func (o *roleResourceType) grantPrivilegeSet(
ctx context.Context,
p *rolePrincipal,
+ annos annotations.Annotations,
target string,
principalID *v2.ResourceId,
entitlement *v2.Entitlement,
@@ -295,7 +371,8 @@ func (o *roleResourceType) grantPrivilegeSet(
// Already holds this exact privilege set — nothing would be
// displaced, and re-sending the same PUT would only churn Jamf's
// audit log.
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
if p.privilegeSet == privilegeSetCustom {
@@ -303,12 +380,14 @@ func (o *roleResourceType) grantPrivilegeSet(
zap.String("principal", p.name))
}
- if err := o.updateRolePrincipal(ctx, p, target, nil); err != nil {
+ annos, err := o.updateRolePrincipal(ctx, p, target, nil)
+ if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: grant role: %w", err)
}
newGrant := grant.NewGrant(entitlement.Resource, memberEntitlement, principalID)
- annos := replacedPrivilegeSetAnnotation(ctx, p.privilegeSet, target, principalID, entitlement)
+ replaced := replacedPrivilegeSetAnnotation(ctx, p.privilegeSet, target, principalID, entitlement)
+ annos.Merge(replaced...)
return []*v2.Grant{newGrant}, annos, nil
}
@@ -324,6 +403,7 @@ func (o *roleResourceType) grantPrivilegeSet(
func (o *roleResourceType) grantIndividualPrivilege(
ctx context.Context,
p *rolePrincipal,
+ annos annotations.Annotations,
target string,
principalID *v2.ResourceId,
entitlement *v2.Entitlement,
@@ -335,16 +415,18 @@ func (o *roleResourceType) grantIndividualPrivilege(
}
if p.privileges.Contains(target) {
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
updated := addPrivilege(p.privileges, target)
- if err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &updated); err != nil {
+ annos, err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &updated)
+ if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: grant role: %w", err)
}
newGrant := grant.NewGrant(entitlement.Resource, memberEntitlement, principalID)
- return []*v2.Grant{newGrant}, nil, nil
+ return []*v2.Grant{newGrant}, annos, nil
}
// addPrivilege returns a deduped copy of current with target added under the
@@ -435,7 +517,7 @@ func replacedPrivilegeSetAnnotation(
func (o *roleResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotations.Annotations, error) {
ctx = jamf.WithFreshReads(ctx)
- p, err := o.getRolePrincipal(ctx, gr.Principal.Id)
+ p, annos, err := o.getRolePrincipal(ctx, gr.Principal.Id)
if err != nil {
if status.Code(err) == codes.InvalidArgument {
return nil, err
@@ -452,9 +534,9 @@ func (o *roleResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotation
target := gr.Entitlement.Resource.Id.Resource
if slices.Contains(privilegeSets, target) {
- return o.revokePrivilegeSet(ctx, p, target)
+ return o.revokePrivilegeSet(ctx, p, annos, target)
}
- return o.revokeIndividualPrivilege(ctx, p, target)
+ return o.revokeIndividualPrivilege(ctx, p, annos, target)
}
// revokePrivilegeSet handles Revoke of a built-in privilege set. There is no
@@ -466,26 +548,29 @@ func (o *roleResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotation
// may have displaced this grant's privilege_set already (see Grant's
// GrantReplaced annotation), and downgrading here would then wipe out
// whatever the principal currently, legitimately holds.
-func (o *roleResourceType) revokePrivilegeSet(ctx context.Context, p *rolePrincipal, target string) (annotations.Annotations, error) {
+func (o *roleResourceType) revokePrivilegeSet(ctx context.Context, p *rolePrincipal, annos annotations.Annotations, target string) (annotations.Annotations, error) {
if p.privilegeSet != target {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
empty := jamf.Privileges{}
- if err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &empty); err != nil {
+ annos, err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &empty)
+ if err != nil {
return nil, fmt.Errorf("jamf-connector: revoke role: %w", err)
}
- return nil, nil
+ return annos, nil
}
// revokeIndividualPrivilege handles Revoke of a single named privilege.
// "Read License Information" is rejected outright — Jamf requires it on
// every Custom privilege set and silently keeps it even if asked to remove
// it, so reporting success here would be a lie.
-func (o *roleResourceType) revokeIndividualPrivilege(ctx context.Context, p *rolePrincipal, target string) (annotations.Annotations, error) {
+func (o *roleResourceType) revokeIndividualPrivilege(ctx context.Context, p *rolePrincipal, annos annotations.Annotations, target string) (annotations.Annotations, error) {
if p.privilegeSet != privilegeSetCustom || !p.privileges.Contains(target) {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
if target == privilegeReadLicenseInformation {
@@ -493,16 +578,18 @@ func (o *roleResourceType) revokeIndividualPrivilege(ctx context.Context, p *rol
}
updated := removePrivilege(p.privileges, target)
- if err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &updated); err != nil {
+ annos, err := o.updateRolePrincipal(ctx, p, privilegeSetCustom, &updated)
+ if err != nil {
return nil, fmt.Errorf("jamf-connector: revoke role: %w", err)
}
- return nil, nil
+ return annos, nil
}
-func roleBuilder(client *jamf.Client) *roleResourceType {
+func roleBuilder(client *jamf.Client, syncUserAccounts bool) *roleResourceType {
return &roleResourceType{
- resourceType: resourceTypeRole,
- client: client,
+ resourceType: resourceTypeRole,
+ client: client,
+ syncUserAccounts: syncUserAccounts,
}
}
diff --git a/pkg/connector/role_test.go b/pkg/connector/role_test.go
index b555741f..6803ba31 100644
--- a/pkg/connector/role_test.go
+++ b/pkg/connector/role_test.go
@@ -5,7 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
- "strconv"
+ "slices"
"strings"
"testing"
@@ -18,39 +18,6 @@ import (
"google.golang.org/grpc/status"
)
-// TestMatchesIndividualPrivilege_CustomOnly guards against widening
-// Privileges.Contains to all 7 categories granting individual-privilege
-// roles to built-in-privilege-set accounts, even if their Privileges data
-// happens to be populated.
-func TestMatchesIndividualPrivilege_CustomOnly(t *testing.T) {
- populated := &jamf.Privileges{JSSObjects: []string{"Read User"}}
-
- tests := []struct {
- name string
- privilegeSet string
- privileges *jamf.Privileges
- privilege string
- want bool
- }{
- {"custom account with matching privilege", privilegeSetCustom, populated, "Read User", true},
- {"custom account without matching privilege", privilegeSetCustom, populated, "Update User", false},
- {"administrator account with populated privileges must not match", privilegeSetAdministrator, populated, "Read User", false},
- {"auditor account with populated privileges must not match", privilegeSetAuditor, populated, "Read User", false},
- {"enrollment only account with populated privileges must not match", privilegeSetEnrollmentOnly, populated, "Read User", false},
- {"custom account with empty privileges", privilegeSetCustom, &jamf.Privileges{}, "Read User", false},
- {"nil privileges", privilegeSetCustom, nil, "Read User", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := matchesIndividualPrivilege(tt.privilegeSet, tt.privileges, tt.privilege)
- if got != tt.want {
- t.Errorf("matchesIndividualPrivilege(%q, %+v, %q) = %v, want %v", tt.privilegeSet, tt.privileges, tt.privilege, got, tt.want)
- }
- })
- }
-}
-
func roleEntitlement(t *testing.T, privilegeSet string) *v2.Entitlement {
t.Helper()
resource, err := roleResource(context.Background(), privilegeSet, nil)
@@ -149,7 +116,7 @@ func TestRoleGrant_UserAccount_SetsPrivilegeSet(t *testing.T) {
{PrivilegeSet: privilegeSetAuditor},
{PrivilegeSet: privilegeSetAdministrator},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, privilegeSetAdministrator))
if err != nil {
@@ -193,7 +160,7 @@ func TestRoleGrant_Group_SetsPrivilegeSet(t *testing.T) {
{PrivilegeSet: privilegeSetAuditor},
{PrivilegeSet: privilegeSetAdministrator},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), groupPrincipal(t, 7), roleEntitlement(t, privilegeSetAdministrator))
if err != nil {
@@ -228,7 +195,7 @@ func TestRoleGrant_EnrollmentOnly_IsGrantable(t *testing.T) {
{PrivilegeSet: privilegeSetAdministrator},
{PrivilegeSet: privilegeSetEnrollmentOnly},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, _, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, privilegeSetEnrollmentOnly))
if err != nil {
@@ -250,7 +217,7 @@ func TestRoleGrant_NoGrantReplaced_WhenPreviouslyCustomOrUnassigned(t *testing.T
{PrivilegeSet: previous},
{PrivilegeSet: privilegeSetAdministrator},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, privilegeSetAdministrator))
if err != nil {
@@ -262,8 +229,8 @@ func TestRoleGrant_NoGrantReplaced_WhenPreviouslyCustomOrUnassigned(t *testing.T
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations when the previous privilege_set was %q, got %v", previous, annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations when the previous privilege_set was %q, got %v", previous, annos)
}
})
}
@@ -274,7 +241,7 @@ func TestRoleGrant_AlreadyHasPrivilegeSet_MapsToGrantAlreadyExists(t *testing.T)
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAdministrator},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, privilegeSetAdministrator))
if err != nil {
@@ -305,7 +272,7 @@ func TestRoleGrant_Deleted404_MapsToNotFound(t *testing.T) {
t.Fatalf("unexpected method %s", req.Method)
}
w.WriteHeader(http.StatusNotFound)
- }))
+ }), false)
_, _, err := r.Grant(context.Background(), tc.principal, roleEntitlement(t, privilegeSetAdministrator))
if err == nil {
@@ -323,7 +290,7 @@ func TestRoleGrant_GroupAccessAccount_Rejected(t *testing.T) {
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAuditor, AccessLevel: accessLevelGroupAccess},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
_, _, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, privilegeSetAdministrator))
if err == nil {
@@ -338,7 +305,7 @@ func TestRoleGrant_GroupAccessAccount_Rejected(t *testing.T) {
}
func TestRoleGrant_NonUserAccountOrGroupPrincipal_Rejected(t *testing.T) {
- r := roleBuilder(newTestJamfClient(t, failOnCallHandler(t)))
+ r := roleBuilder(newTestJamfClient(t, failOnCallHandler(t)), false)
_, _, err := r.Grant(context.Background(), userPrincipal(t, 42), roleEntitlement(t, privilegeSetAdministrator))
if err == nil {
t.Fatal("expected an error granting a role to a non-userAccount/group principal")
@@ -356,7 +323,7 @@ func TestRoleGrant_IndividualPrivilege_Success(t *testing.T) {
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User", "Update User"}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, "Update User"))
if err != nil {
@@ -375,8 +342,8 @@ func TestRoleGrant_IndividualPrivilege_Success(t *testing.T) {
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations on a fresh individual-privilege grant, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a fresh individual-privilege grant, got %v", annos)
}
}
@@ -386,7 +353,7 @@ func TestRoleGrant_IndividualPrivilege_Group_Success(t *testing.T) {
{PrivilegeSet: privilegeSetCustom},
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, _, err := r.Grant(context.Background(), groupPrincipal(t, 7), roleEntitlement(t, "Read User"))
if err != nil {
@@ -411,7 +378,7 @@ func TestRoleGrant_IndividualPrivilege_AlreadyHeld_MapsToGrantAlreadyExists(t *t
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
grants, annos, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, "Read User"))
if err != nil {
@@ -433,7 +400,7 @@ func TestRoleGrant_IndividualPrivilege_NotCustom_Rejected(t *testing.T) {
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAuditor},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
_, _, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, "Read User"))
if err == nil {
@@ -451,7 +418,7 @@ func TestRoleGrant_IndividualPrivilege_GroupAccessAccount_Rejected(t *testing.T)
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetCustom, AccessLevel: accessLevelGroupAccess},
}, &[][]byte{}))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
_, _, err := r.Grant(context.Background(), userAccountPrincipal(t, 42), roleEntitlement(t, "Read User"))
if err == nil {
@@ -470,7 +437,7 @@ func TestRoleRevoke_UserAccount_MovesToCustomWithExplicitEmptyBlock(t *testing.T
{PrivilegeSet: privilegeSetAdministrator},
{PrivilegeSet: privilegeSetCustom},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -485,8 +452,8 @@ func TestRoleRevoke_UserAccount_MovesToCustomWithExplicitEmptyBlock(t *testing.T
if got != want {
t.Errorf("PUT body = %s, want %s", got, want)
}
- if annos != nil {
- t.Errorf("expected no annotations on a plain revoke, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a plain revoke, got %v", annos)
}
}
@@ -496,7 +463,7 @@ func TestRoleRevoke_Group_MovesToCustomWithExplicitEmptyBlock(t *testing.T) {
{PrivilegeSet: privilegeSetAdministrator},
{PrivilegeSet: privilegeSetCustom},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, groupPrincipal(t, 7).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -511,8 +478,8 @@ func TestRoleRevoke_Group_MovesToCustomWithExplicitEmptyBlock(t *testing.T) {
if got != want {
t.Errorf("PUT body = %s, want %s", got, want)
}
- if annos != nil {
- t.Errorf("expected no annotations on a plain revoke, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a plain revoke, got %v", annos)
}
}
@@ -522,7 +489,7 @@ func TestRoleRevoke_EnrollmentOnly_IsRevocable(t *testing.T) {
{PrivilegeSet: privilegeSetEnrollmentOnly},
{PrivilegeSet: privilegeSetCustom},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetEnrollmentOnly).Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
if _, err := r.Revoke(context.Background(), gr); err != nil {
@@ -538,7 +505,7 @@ func TestRoleRevoke_StalePrivilegeSet_NoWrite(t *testing.T) {
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAuditor},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -567,7 +534,7 @@ func TestRoleRevoke_Deleted404_MapsToGrantAlreadyRevoked(t *testing.T) {
t.Fatalf("unexpected method %s", req.Method)
}
w.WriteHeader(http.StatusNotFound)
- }))
+ }), false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, tc.principal.Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -586,7 +553,7 @@ func TestRoleRevoke_GroupAccessAccount_Rejected(t *testing.T) {
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAdministrator, AccessLevel: accessLevelGroupAccess},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
_, err := r.Revoke(context.Background(), gr)
@@ -602,7 +569,7 @@ func TestRoleRevoke_GroupAccessAccount_Rejected(t *testing.T) {
}
func TestRoleRevoke_NonUserAccountOrGroupPrincipal_Rejected(t *testing.T) {
- r := roleBuilder(newTestJamfClient(t, failOnCallHandler(t)))
+ r := roleBuilder(newTestJamfClient(t, failOnCallHandler(t)), false)
gr := grant.NewGrant(roleEntitlement(t, privilegeSetAdministrator).Resource, memberEntitlement, userPrincipal(t, 42).Id)
_, err := r.Revoke(context.Background(), gr)
if err == nil {
@@ -621,7 +588,7 @@ func TestRoleRevoke_IndividualPrivilege_Success(t *testing.T) {
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User", "Update User"}}},
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, "Update User").Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -638,8 +605,8 @@ func TestRoleRevoke_IndividualPrivilege_Success(t *testing.T) {
if !strings.Contains(got, "Read User") {
t.Errorf("expected the remaining privilege to still be present, got: %s", got)
}
- if annos != nil {
- t.Errorf("expected no annotations on a fresh individual-privilege revoke, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a fresh individual-privilege revoke, got %v", annos)
}
}
@@ -654,7 +621,7 @@ func TestRoleRevoke_IndividualPrivilege_LastOne_ExplicitEmptyBlock(t *testing.T)
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
{PrivilegeSet: privilegeSetCustom},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, "Read User").Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
if _, err := r.Revoke(context.Background(), gr); err != nil {
@@ -674,7 +641,7 @@ func TestRoleRevoke_IndividualPrivilege_NotHeld_MapsToGrantAlreadyRevoked(t *tes
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{"Read User"}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, "Update User").Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -694,7 +661,7 @@ func TestRoleRevoke_IndividualPrivilege_NotCustom_MapsToGrantAlreadyRevoked(t *t
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetAdministrator},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, "Read User").Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
annos, err := r.Revoke(context.Background(), gr)
@@ -714,7 +681,7 @@ func TestRoleRevoke_ReadLicenseInformation_Rejected(t *testing.T) {
client := newTestJamfClient(t, accountRoleHandler(t, 42, "jappleseed", []jamf.UserAccount{
{PrivilegeSet: privilegeSetCustom, Privileges: jamf.Privileges{JSSObjects: []string{privilegeReadLicenseInformation}}},
}, &putBodies))
- r := roleBuilder(client)
+ r := roleBuilder(client, false)
gr := grant.NewGrant(roleEntitlement(t, privilegeReadLicenseInformation).Resource, memberEntitlement, userAccountPrincipal(t, 42).Id)
_, err := r.Revoke(context.Background(), gr)
@@ -734,150 +701,90 @@ func TestRoleRevoke_ReadLicenseInformation_Rejected(t *testing.T) {
// ── Entitlements ──────────────────────────────────────────────────────────
-// TestRoleEntitlements_AllGrantableToBothPrincipalTypes covers the design
-// change: all three built-in sets AND individual privileges are grantable to
-// both userAccount and group — there is no longer a restricted subset.
+// The single role entitlement is grantable to exactly userAccount and group.
func TestRoleEntitlements_AllGrantableToBothPrincipalTypes(t *testing.T) {
- r := roleBuilder(nil)
- for _, roleID := range append(append([]string{}, privilegeSets...), "Read User") {
- t.Run(roleID, func(t *testing.T) {
- res, err := roleResource(context.Background(), roleID, nil)
- if err != nil {
- t.Fatalf("roleResource: %v", err)
- }
- ents, _, err := r.Entitlements(context.Background(), res, rs.SyncOpAttrs{})
- if err != nil {
- t.Fatalf("Entitlements: %v", err)
- }
- if len(ents) != 1 {
- t.Fatalf("want 1 entitlement, got %d", len(ents))
- }
- grantableTo := ents[0].GetGrantableTo()
- if len(grantableTo) != 2 {
- t.Fatalf("expected %q to be grantable to exactly 2 resource types, got %v", roleID, grantableTo)
- }
- var sawUserAccount, sawGroup bool
- for _, rt := range grantableTo {
- switch rt.Id {
- case resourceTypeUserAccount.Id:
- sawUserAccount = true
- case resourceTypeGroup.Id:
- sawGroup = true
- }
- }
- if !sawUserAccount || !sawGroup {
- t.Errorf("expected %q to be grantable to both userAccount and group, got %v", roleID, grantableTo)
- }
- })
- }
-}
-
-// roleAccountsListHandler serves the full GetAccounts flow Grants() drives:
-// GET /JSSResource/accounts (the base list) followed by a detail GET per
-// account/group id. Used to test role.go's sync Grants(), as distinct from
-// the Grant/Revoke provisioning tests above.
-func roleAccountsListHandler(t *testing.T, accounts []jamf.UserAccount, groups []jamf.Group) http.HandlerFunc {
- t.Helper()
- return func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch {
- case r.URL.Path == "/JSSResource/accounts":
- baseUsers := make([]jamf.User, 0, len(accounts))
- for _, a := range accounts {
- baseUsers = append(baseUsers, jamf.User{BaseType: a.BaseType})
- }
- baseGroups := make([]jamf.Group, 0, len(groups))
- for _, g := range groups {
- baseGroups = append(baseGroups, jamf.Group{BaseType: g.BaseType})
- }
- _ = json.NewEncoder(w).Encode(jamf.AccountsResponse{Accounts: jamf.BaseAccount{Users: baseUsers, Groups: baseGroups}})
- case strings.Contains(r.URL.Path, "/accounts/userid/"):
- id := accountIDFromPath(t, r.URL.Path)
- for _, a := range accounts {
- if a.ID == id {
- _ = json.NewEncoder(w).Encode(jamf.UserAccountResponse{UserAccount: a})
- return
- }
- }
- t.Fatalf("unexpected account id %d", id)
- case strings.Contains(r.URL.Path, "/accounts/groupid/"):
- id := accountIDFromPath(t, r.URL.Path)
- for _, g := range groups {
- if g.ID == id {
- _ = json.NewEncoder(w).Encode(jamf.GroupResponse{Group: g})
- return
- }
- }
- t.Fatalf("unexpected group id %d", id)
- default:
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- }
-}
-
-func accountIDFromPath(t *testing.T, path string) int {
- t.Helper()
- parts := strings.Split(path, "/")
- id, err := strconv.Atoi(parts[len(parts)-1])
- if err != nil {
- t.Fatalf("parse id from path %q: %v", path, err)
- }
- return id
-}
-
-// TestRoleGrants_PrivilegeSet_BuiltFromAccountsAndGroups covers the sync
-// Grants() path for a built-in privilege set: it must emit a grant for every
-// account/group whose stored privilege_set matches the role, regardless of
-// access_level.
-func TestRoleGrants_PrivilegeSet_BuiltFromAccountsAndGroups(t *testing.T) {
- client := newTestJamfClient(t, roleAccountsListHandler(t,
- []jamf.UserAccount{
- {BaseType: jamf.BaseType{ID: 101, Name: "admin1"}, PrivilegeSet: privilegeSetAdministrator, AccessLevel: "Full Access"},
- {BaseType: jamf.BaseType{ID: 102, Name: "admin2"}, PrivilegeSet: privilegeSetAuditor, AccessLevel: "Full Access"},
- },
- []jamf.Group{
- {BaseType: jamf.BaseType{ID: 201, Name: "group-admins"}, PrivilegeSet: privilegeSetAdministrator},
- },
- ))
- o := roleBuilder(client)
-
- en := roleEntitlement(t, privilegeSetAdministrator)
- grants, _, err := o.Grants(context.Background(), en.Resource, rs.SyncOpAttrs{})
+ entitlements, _, err := roleBuilder(nil, false).StaticEntitlements(context.Background(), rs.SyncOpAttrs{})
if err != nil {
- t.Fatalf("Grants: %v", err)
- }
- if len(grants) != 2 {
- t.Fatalf("want 2 grants (account 101 and group 201), got %d", len(grants))
- }
-}
-
-// TestRoleGrants_GroupAccessAccountWithStalePrivilegeSet_EmitsFalseGrant_KnownGap
-// documents a known gap rather than desired behavior: Grants() has no
-// access_level check at all, unlike Grant/Revoke's explicit Group Access
-// guard. An account that was
-// switched to Group Access still carries whatever privilege_set it had
-// before the switch, and Grants() reports that stale value as a real role
-// grant even though the account's actual rights now come entirely from its
-// groups. There is currently no way to revoke this false grant through the
-// connector.
-func TestRoleGrants_GroupAccessAccountWithStalePrivilegeSet_EmitsFalseGrant_KnownGap(t *testing.T) {
- client := newTestJamfClient(t, roleAccountsListHandler(t,
- []jamf.UserAccount{
- {BaseType: jamf.BaseType{ID: 104, Name: "admin4"}, PrivilegeSet: privilegeSetAdministrator, AccessLevel: accessLevelGroupAccess},
- },
- nil,
- ))
- o := roleBuilder(client)
-
- en := roleEntitlement(t, privilegeSetAdministrator)
- grants, _, err := o.Grants(context.Background(), en.Resource, rs.SyncOpAttrs{})
- if err != nil {
- t.Fatalf("Grants: %v", err)
- }
- if len(grants) != 1 {
- t.Fatalf("current (known-gap) behavior emits a grant for the Group Access account's stale privilege_set; want 1, got %d", len(grants))
+ t.Fatalf("StaticEntitlements: %v", err)
+ }
+ if len(entitlements) != 1 || entitlements[0].GetSlug() != memberEntitlement {
+ t.Fatalf("entitlements = %v, want one %q entitlement", entitlements, memberEntitlement)
+ }
+
+ var got []string
+ for _, resourceType := range entitlements[0].GetGrantableTo() {
+ got = append(got, resourceType.GetId())
+ }
+ slices.Sort(got)
+ if want := []string{resourceTypeGroup.Id, resourceTypeUserAccount.Id}; !slices.Equal(got, want) {
+ t.Errorf("grantable to %v, want %v", got, want)
+ }
+}
+
+// Privilege-set grants come from every account; individual-privilege grants only from Custom accounts, 10 per page.
+func TestRoleGrantsForResourceType_EmitsAccountRoles(t *testing.T) {
+ accounts := []jamf.UserAccount{
+ testAccount(1, privilegeSetAdministrator),
+ testAccount(2, privilegeSetAuditor),
+ testAccount(3, privilegeSetEnrollmentOnly),
+ }
+ // Account 4 holds Read Users in two categories and a privilege outside the catalog; each yields at most one grant.
+ multiCategoryAccount := testAccount(4, privilegeSetCustom)
+ multiCategoryAccount.Privileges = jamf.Privileges{
+ JSSObjects: []string{"Read Computers", "Read Users"},
+ JSSSettings: []string{"Read Users", "Not In Catalog"},
+ }
+ accounts = append(accounts, multiCategoryAccount)
+ // Custom accounts 4-15 span two pages of 10.
+ for id := 5; id <= 15; id++ {
+ customAccount := testAccount(id, privilegeSetCustom)
+ customAccount.Privileges = jamf.Privileges{JSSObjects: []string{"Read Computers"}}
+ accounts = append(accounts, customAccount)
+ }
+
+ fixture := newAdminFixture(t, accounts, nil)
+ fixture.catalog = []string{"Delete Users", "Read Computers", "Read Users"}
+ fixture.deletedAccountIDs = []int{9} // deleted after it was listed
+ builder := roleBuilder(fixture.client(), true)
+ roleGrants := func(ctx context.Context, _ *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
+ return builder.GrantsForResourceType(ctx, resourceTypeRole.Id, attrs)
+ }
+
+ grants, _ := collectGrants(t, roleGrants, nil, newMemorySessionStore())
+
+ got := make([]string, 0, len(grants))
+ for _, memberGrant := range grants {
+ got = append(got, memberGrant.GetId())
+ }
+ slices.Sort(got)
+ want := []string{
+ "role:Administrator:member:userAccount:1",
+ "role:Auditor:member:userAccount:2",
+ "role:Enrollment Only:member:userAccount:3",
+ "role:Read Computers:member:userAccount:10",
+ "role:Read Computers:member:userAccount:11",
+ "role:Read Computers:member:userAccount:12",
+ "role:Read Computers:member:userAccount:13",
+ "role:Read Computers:member:userAccount:14",
+ "role:Read Computers:member:userAccount:15",
+ "role:Read Computers:member:userAccount:4",
+ "role:Read Computers:member:userAccount:5",
+ "role:Read Computers:member:userAccount:6",
+ "role:Read Computers:member:userAccount:7",
+ "role:Read Computers:member:userAccount:8",
+ "role:Read Users:member:userAccount:4",
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("grant IDs = %v, want %v", got, want)
+ }
+
+ var customPageSizes []int
+ for _, request := range fixture.accountsRequestsSeen() {
+ if request.filter == "privilegeLevel==CUSTOM" {
+ customPageSizes = append(customPageSizes, request.pageSize)
+ }
}
- if got := grants[0].GetPrincipal().GetId().GetResource(); got != "104" {
- t.Errorf("grant principal = %q, want %q", got, "104")
+ if !slices.Equal(customPageSizes, []int{10, 10}) {
+ t.Errorf("Custom accounts page sizes = %v, want [10 10]", customPageSizes)
}
}
diff --git a/pkg/connector/session_cache.go b/pkg/connector/session_cache.go
new file mode 100644
index 00000000..8e6bd187
--- /dev/null
+++ b/pkg/connector/session_cache.go
@@ -0,0 +1,131 @@
+package connector
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/conductorone/baton-jamf/pkg/jamf"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
+ "github.com/conductorone/baton-sdk/pkg/session"
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
+)
+
+const privilegeCatalogKey = "privilege_catalog"
+
+var userIDsNamespace = sessions.WithPrefix("user_ids:")
+
+var userGroupMembersNamespace = sessions.WithPrefix("user_group_members:")
+
+var groupDetailsNamespace = sessions.WithPrefix("group_details:")
+
+// userGroupMemberPage is one grants page of a user group's member ids, sorted.
+type userGroupMemberPage struct {
+ UserIDs []int `json:"user_ids"`
+ Last bool `json:"last"`
+}
+
+// privilegeCatalog returns the individual privileges Jamf offers, read once per sync.
+func privilegeCatalog(ctx context.Context, store sessions.SessionStore, client *jamf.Client) ([]string, annotations.Annotations, error) {
+ catalog, found, err := session.GetJSON[[]string](ctx, store, privilegeCatalogKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ if found {
+ return catalog, nil, nil
+ }
+
+ response, annos, err := client.GetPrivileges(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := session.SetJSON(ctx, store, privilegeCatalogKey, response.Privileges); err != nil {
+ return nil, nil, err
+ }
+ return response.Privileges, annos, nil
+}
+
+// privilegeCatalogSet returns the privilege catalog as a set, for checking a Custom account's or group's privileges.
+func privilegeCatalogSet(ctx context.Context, store sessions.SessionStore, client *jamf.Client) (map[string]struct{}, annotations.Annotations, error) {
+ privileges, annos, err := privilegeCatalog(ctx, store, client)
+ if err != nil {
+ return nil, nil, err
+ }
+ catalog := make(map[string]struct{}, len(privileges))
+ for _, privilege := range privileges {
+ catalog[privilege] = struct{}{}
+ }
+ return catalog, annos, nil
+}
+
+// cacheUserIDs stores each user's username and email -> user id.
+func cacheUserIDs(ctx context.Context, store sessions.SessionStore, users []jamf.ProUser) error {
+ userIDs := userIDsByLookupKey(users)
+
+ // Pages come in id order, so a key an earlier page stored keeps its lower id.
+ stored, err := cachedUserIDs(ctx, store, slices.Collect(maps.Keys(userIDs)))
+ if err != nil {
+ return err
+ }
+ for key := range stored {
+ delete(userIDs, key)
+ }
+
+ return session.SetManyJSON(ctx, store, userIDs, userIDsNamespace)
+}
+
+// cachedUserIDs returns the cached user id of each key that has one.
+func cachedUserIDs(ctx context.Context, store sessions.SessionStore, keys []string) (map[string]string, error) {
+ return session.GetManyJSON[string](ctx, store, keys, userIDsNamespace)
+}
+
+// userIDsByLookupKey maps each user's username and email to the user id. Users come ordered by id, so the lowest wins.
+func userIDsByLookupKey(users []jamf.ProUser) map[string]string {
+ userIDs := make(map[string]string, len(users)*2)
+ for _, user := range users {
+ for _, value := range []string{user.Username, user.Email} {
+ key := userLookupKey(value)
+ if _, taken := userIDs[key]; key != "" && !taken {
+ userIDs[key] = strconv.Itoa(user.ID)
+ }
+ }
+ }
+ return userIDs
+}
+
+// userLookupKey normalizes a username or email for the user id cache.
+func userLookupKey(value string) string {
+ return strings.ToLower(strings.TrimSpace(value))
+}
+
+// cacheUserGroupMemberPages stores each page after the first under its cursor, the last user id of the page before.
+func cacheUserGroupMemberPages(ctx context.Context, store sessions.SessionStore, userGroupID int, pages []userGroupMemberPage) error {
+ values := make(map[string]userGroupMemberPage, len(pages)-1)
+ for i := 1; i < len(pages); i++ {
+ previousIDs := pages[i-1].UserIDs
+ values[userGroupMemberPageKey(userGroupID, previousIDs[len(previousIDs)-1])] = pages[i]
+ }
+ return session.SetManyJSON(ctx, store, values, userGroupMembersNamespace)
+}
+
+// cachedUserGroupMemberPage returns the stored member page of a user group that follows cursor.
+func cachedUserGroupMemberPage(ctx context.Context, store sessions.SessionStore, userGroupID, cursor int) (userGroupMemberPage, bool, error) {
+ return session.GetJSON[userGroupMemberPage](ctx, store, userGroupMemberPageKey(userGroupID, cursor), userGroupMembersNamespace)
+}
+
+func userGroupMemberPageKey(userGroupID, cursor int) string {
+ return fmt.Sprintf("%d:%d", userGroupID, cursor)
+}
+
+// cacheGroupDetails stores an admin group's detail for its later grants pages.
+func cacheGroupDetails(ctx context.Context, store sessions.SessionStore, groupID int, group *jamf.Group) error {
+ return session.SetJSON(ctx, store, strconv.Itoa(groupID), group, groupDetailsNamespace)
+}
+
+// cachedGroupDetails returns the stored detail of an admin group.
+func cachedGroupDetails(ctx context.Context, store sessions.SessionStore, groupID int) (jamf.Group, bool, error) {
+ return session.GetJSON[jamf.Group](ctx, store, strconv.Itoa(groupID), groupDetailsNamespace)
+}
diff --git a/pkg/connector/sessionstore_test.go b/pkg/connector/sessionstore_test.go
new file mode 100644
index 00000000..d26f66c5
--- /dev/null
+++ b/pkg/connector/sessionstore_test.go
@@ -0,0 +1,105 @@
+package connector
+
+import (
+ "context"
+ "maps"
+ "slices"
+ "sync"
+
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
+)
+
+var _ sessions.SessionStore = (*memorySessionStore)(nil)
+
+// memorySessionStore is an in-memory sessions.SessionStore that honors sessions.WithPrefix.
+type memorySessionStore struct {
+ mu sync.Mutex
+ values map[string][]byte
+}
+
+func newMemorySessionStore() *memorySessionStore {
+ return &memorySessionStore{values: map[string][]byte{}}
+}
+
+func fullKey(ctx context.Context, key string, opts []sessions.SessionStoreOption) (string, error) {
+ bag := &sessions.SessionStoreBag{}
+ for _, opt := range opts {
+ if err := opt(ctx, bag); err != nil {
+ return "", err
+ }
+ }
+ return bag.Prefix + key, nil
+}
+
+func (store *memorySessionStore) Get(ctx context.Context, key string, opts ...sessions.SessionStoreOption) ([]byte, bool, error) {
+ storeKey, err := fullKey(ctx, key, opts)
+ if err != nil {
+ return nil, false, err
+ }
+
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ value, found := store.values[storeKey]
+ return value, found, nil
+}
+
+// GetMany returns only the keys it holds; the second result lists keys left
+// unprocessed (a size-limit overflow in the real store), never absent ones.
+func (store *memorySessionStore) GetMany(ctx context.Context, keys []string, opts ...sessions.SessionStoreOption) (map[string][]byte, []string, error) {
+ found := map[string][]byte{}
+ for _, key := range keys {
+ value, ok, err := store.Get(ctx, key, opts...)
+ if err != nil {
+ return nil, nil, err
+ }
+ if ok {
+ found[key] = value
+ }
+ }
+ return found, nil, nil
+}
+
+func (store *memorySessionStore) Set(ctx context.Context, key string, value []byte, opts ...sessions.SessionStoreOption) error {
+ return store.SetMany(ctx, map[string][]byte{key: value}, opts...)
+}
+
+func (store *memorySessionStore) SetMany(ctx context.Context, values map[string][]byte, opts ...sessions.SessionStoreOption) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ for key, value := range values {
+ storeKey, err := fullKey(ctx, key, opts)
+ if err != nil {
+ return err
+ }
+ store.values[storeKey] = slices.Clone(value)
+ }
+ return nil
+}
+
+func (store *memorySessionStore) Delete(ctx context.Context, key string, opts ...sessions.SessionStoreOption) error {
+ storeKey, err := fullKey(ctx, key, opts)
+ if err != nil {
+ return err
+ }
+
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ delete(store.values, storeKey)
+ return nil
+}
+
+func (store *memorySessionStore) Clear(_ context.Context, _ ...sessions.SessionStoreOption) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ clear(store.values)
+ return nil
+}
+
+func (store *memorySessionStore) GetAll(_ context.Context, _ string, _ ...sessions.SessionStoreOption) (map[string][]byte, string, error) {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ return maps.Clone(store.values), "", nil
+}
diff --git a/pkg/connector/site.go b/pkg/connector/site.go
index b5bc7486..7f6e96cc 100644
--- a/pkg/connector/site.go
+++ b/pkg/connector/site.go
@@ -9,6 +9,7 @@ import (
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
+ "github.com/conductorone/baton-sdk/pkg/pagination"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
@@ -16,9 +17,18 @@ import (
"google.golang.org/grpc/status"
)
+const (
+ sitePhaseUser = "user"
+ sitePhaseUserGroup = "userGroup"
+ sitePhaseAdmins = "admins"
+)
+
type siteResourceType struct {
- resourceType *v2.ResourceType
- client *jamf.Client
+ resourceType *v2.ResourceType
+ client *jamf.Client
+ syncUsers bool
+ syncUserGroups bool
+ syncUserAccounts bool
}
func (g *siteResourceType) ResourceType(_ context.Context) *v2.ResourceType {
@@ -40,126 +50,134 @@ func siteResource(site *jamf.Site, parentResourceID *v2.ResourceId) (*v2.Resourc
}
func (g *siteResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- sites, err := g.client.GetSites(ctx)
+ sites, annos, err := g.client.GetSites(ctx)
if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to list sites: %w", err)
}
var rv []*v2.Resource
for _, site := range *sites {
- siteCopy := site
- ur, err := siteResource(&siteCopy, parentId)
+ ur, err := siteResource(&site, parentId)
if err != nil {
return nil, nil, err
}
rv = append(rv, ur)
}
- return rv, nil, nil
+ return rv, &rs.SyncOpResults{Annotations: annos}, nil
}
-func (g *siteResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
- var rv []*v2.Entitlement
-
- // WithGrantableTo intentionally names only resourceTypeUser, even though
- // Grants() below emits grants for user, userGroup, userAccount, and group:
- // site membership is only genuinely multi-valued for user ( is a
- // real list); for the other three, "site" is a single-valued/exclusive
- // attribute, so Grant/Revoke provisioning is scoped to user only. Do not
- // widen this to match Grants() without also adding exclusive-entitlement
- // handling for the other three principal types.
- assignmentOptions := []ent.EntitlementOption{
- ent.WithGrantableTo(resourceTypeUser),
- ent.WithDescription(fmt.Sprintf("Member of %s Site in Jamf", resource.DisplayName)),
- ent.WithDisplayName(fmt.Sprintf("%s Site %s", resource.DisplayName, memberEntitlement)),
- }
-
- en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assignmentOptions...)
- rv = append(rv, en)
+// Entitlements is unused: StaticEntitlements declares the member entitlement.
+func (g *siteResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ return nil, nil, nil
+}
- return rv, nil, nil
+func (g *siteResourceType) StaticEntitlements(_ context.Context, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ // Only a user's site membership is multi-valued; for the other principal types
+ // site is single-valued, so provisioning is user-only.
+ return []*v2.Entitlement{
+ ent.NewAssignmentEntitlement(nil, memberEntitlement,
+ ent.WithDisplayName("Member"),
+ ent.WithDescription("Member of the Jamf site"),
+ ent.WithGrantableTo(resourceTypeUser),
+ ),
+ }, nil, nil
}
+// Grants emits the site's members in one phase per synced principal type: users, user groups and admin accounts.
func (g *siteResourceType) Grants(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
- var rv []*v2.Grant
-
- users, err := g.client.GetUsers(ctx)
+ siteID, err := strconv.Atoi(resource.Id.Resource)
if err != nil {
- return rv, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: invalid site id %q: %w", resource.Id.Resource, err)
}
- for _, user := range users {
- userCopy := user
- ur, err := userResource(userCopy, resource.Id)
- if err != nil {
- return nil, nil, err
+ bag := &pagination.Bag{}
+ if err := bag.Unmarshal(attrs.PageToken.Token); err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: parse site %s grants page token: %w", resource.Id.Resource, err)
+ }
+ if bag.Current() == nil {
+ // Each phase grants one principal type, so a type the sync leaves out is not read.
+ if g.syncUserAccounts {
+ bag.Push(pagination.PageState{ResourceTypeID: sitePhaseAdmins})
+ }
+ if g.syncUserGroups {
+ bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUserGroup})
}
- for _, site := range user.Sites {
- stringId := strconv.Itoa(site.ID)
- if stringId == resource.Id.Resource {
- userMembershipGrant := grant.NewGrant(resource, memberEntitlement, ur.Id)
- rv = append(rv, userMembershipGrant)
- }
+ if g.syncUsers {
+ bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUser})
}
}
- userGroups, err := g.client.GetUserGroups(ctx)
+ phase := bag.Current()
+ if phase == nil {
+ return nil, &rs.SyncOpResults{}, nil
+ }
+ var grants []*v2.Grant
+ var nextPage string
+ var annos annotations.Annotations
+ switch phase.ResourceTypeID {
+ case sitePhaseUser:
+ grants, nextPage, annos, err = g.siteObjectGrants(ctx, resource, siteID, "User", resourceTypeUser, phase.Token)
+ case sitePhaseUserGroup:
+ grants, nextPage, annos, err = g.siteObjectGrants(ctx, resource, siteID, "User Group", resourceTypeUserGroup, phase.Token)
+ case sitePhaseAdmins:
+ grants, nextPage, annos, err = g.adminGrants(ctx, resource, siteID, phase.Token)
+ default:
+ err = fmt.Errorf("unknown phase %q", phase.ResourceTypeID)
+ }
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: list site %s %s grants: %w", resource.Id.Resource, phase.ResourceTypeID, err)
}
- for _, userGroup := range userGroups {
- userGroupCopy := userGroup
- ugr, err := userGroupResource(userGroupCopy, resource.Id)
- if err != nil {
- return nil, nil, err
- }
- stringId := strconv.Itoa(userGroup.Site.ID)
- if stringId == resource.Id.Resource {
- userGroupMembershipGrant := grant.NewGrant(resource, memberEntitlement, ugr.Id)
- rv = append(rv, userGroupMembershipGrant)
- }
+ nextToken, err := bag.NextToken(nextPage)
+ if err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: build site %s grants page token: %w", resource.Id.Resource, err)
}
+ return grants, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil
+}
- userAccounts, groups, err := g.client.GetAccounts(ctx)
+// siteObjectGrants grants the site's objects of objectType on one page.
+func (g *siteResourceType) siteObjectGrants(
+ ctx context.Context,
+ site *v2.Resource,
+ siteID int,
+ objectType string,
+ principalType *v2.ResourceType,
+ page string,
+) ([]*v2.Grant, string, annotations.Annotations, error) {
+ objects, nextPage, annos, err := g.client.GetSiteObjects(ctx, siteID, objectType, page)
if err != nil {
- return nil, nil, err
+ return nil, "", nil, err
}
- for _, userAccount := range userAccounts {
- userAccountCopy := userAccount
- uar, err := userAccountResource(userAccountCopy, resource.Id)
- if err != nil {
- return nil, nil, err
- }
- stringId := strconv.Itoa(userAccount.Site.ID)
- if stringId == resource.Id.Resource {
- userGroupMembershipGrant := grant.NewGrant(resource, memberEntitlement, uar.Id)
- rv = append(rv, userGroupMembershipGrant)
- }
+ grants := make([]*v2.Grant, 0, len(objects))
+ for _, object := range objects {
+ principal := &v2.ResourceId{ResourceType: principalType.Id, Resource: strconv.Itoa(object.ObjectID)}
+ grants = append(grants, grant.NewGrant(site, memberEntitlement, principal))
}
+ return grants, nextPage, annos, nil
+}
- for _, group := range groups {
- groupCopy := group
- gr, err := groupResource(groupCopy, resource.Id)
- if err != nil {
- return nil, nil, err
- }
- stringId := strconv.Itoa(group.Site.ID)
- if stringId == resource.Id.Resource {
- userGroupMembershipGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
- rv = append(rv, userGroupMembershipGrant)
- }
+// adminGrants grants the admin accounts assigned to the site on one page. Groups assigned to it come from the group Grants.
+func (g *siteResourceType) adminGrants(ctx context.Context, site *v2.Resource, siteID int, page string) ([]*v2.Grant, string, annotations.Annotations, error) {
+ accounts, nextPage, annos, err := g.client.GetProAccounts(ctx, fmt.Sprintf("siteId==%d", siteID), page, jamf.AccountsPageSize)
+ if err != nil {
+ return nil, "", nil, err
}
- return rv, nil, nil
+ grants := make([]*v2.Grant, 0, len(accounts))
+ for _, account := range accounts {
+ principal := &v2.ResourceId{ResourceType: resourceTypeUserAccount.Id, Resource: strconv.Itoa(account.ID)}
+ grants = append(grants, grant.NewGrant(site, memberEntitlement, principal))
+ }
+ return grants, nextPage, annos, nil
}
// Grant adds principal (a Jamf user) to the multi-valued list of the
// site backing entitlement's resource. The principal-type guard is defense-
-// in-depth: Grants() emits site grants for four principal types, but only
+// in-depth: site grants are emitted for four principal types, but only
// user is genuinely multi-valued/grantable — see the WithGrantableTo note in
-// Entitlements above.
+// StaticEntitlements above.
func (g *siteResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {
ctx = jamf.WithFreshReads(ctx)
@@ -168,7 +186,7 @@ func (g *siteResourceType) Grant(ctx context.Context, principal *v2.Resource, en
return nil, nil, err
}
- user, err := g.client.GetUserDetails(ctx, userID)
+ user, annos, err := g.client.GetUserDetails(ctx, userID)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant site member: user %d not found", userID)
@@ -176,14 +194,16 @@ func (g *siteResourceType) Grant(ctx context.Context, principal *v2.Resource, en
return nil, nil, fmt.Errorf("jamf-connector: grant site member: %w", err)
}
if slices.ContainsFunc(user.Sites, func(s jamf.BaseType) bool { return s.ID == siteID }) {
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
newSites := append(append(jamf.UserSites{}, user.Sites...), jamf.BaseType{ID: siteID})
- if err := g.client.UpdateUserSites(ctx, userID, newSites); err != nil {
+ annos, err = g.client.UpdateUserSites(ctx, userID, newSites)
+ if err != nil {
return nil, nil, membershipWriteError("grant site member", "the user or site", err)
}
- return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, nil, nil
+ return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, annos, nil
}
// Revoke removes gr's principal (a Jamf user) from the list of the
@@ -197,7 +217,7 @@ func (g *siteResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotation
return nil, err
}
- user, err := g.client.GetUserDetails(ctx, userID)
+ user, annos, err := g.client.GetUserDetails(ctx, userID)
if err != nil {
if jamf.IsNotFoundError(err) {
// The user has been deleted — a deleted user trivially has no
@@ -217,18 +237,23 @@ func (g *siteResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotation
remaining = append(remaining, s)
}
if !found {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
- if err := g.client.UpdateUserSites(ctx, userID, remaining); err != nil {
+ annos, err = g.client.UpdateUserSites(ctx, userID, remaining)
+ if err != nil {
return nil, membershipWriteError("revoke site member", "the user or site", err)
}
- return nil, nil
+ return annos, nil
}
-func siteBuilder(client *jamf.Client) *siteResourceType {
+func siteBuilder(client *jamf.Client, syncUsers, syncUserGroups, syncUserAccounts bool) *siteResourceType {
return &siteResourceType{
- resourceType: resourceTypeSite,
- client: client,
+ resourceType: resourceTypeSite,
+ client: client,
+ syncUsers: syncUsers,
+ syncUserGroups: syncUserGroups,
+ syncUserAccounts: syncUserAccounts,
}
}
diff --git a/pkg/connector/site_test.go b/pkg/connector/site_test.go
index 31af512c..9d22c277 100644
--- a/pkg/connector/site_test.go
+++ b/pkg/connector/site_test.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"io"
"net/http"
+ "slices"
+ "strconv"
"strings"
"testing"
@@ -12,7 +14,6 @@ import (
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
- rs "github.com/conductorone/baton-sdk/pkg/types/resource"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -83,7 +84,7 @@ func jamfUserSitesStatefulHandler(t *testing.T, sitesBeforePut, sitesAfterPut []
func TestSiteGrant_UserNotYetMember_IssuesPUT(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesHandler(t, []int{1}, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
grants, annos, err := s.Grant(context.Background(), userPrincipal(t, 42), siteEntitlement(t, 2))
if err != nil {
@@ -92,8 +93,8 @@ func TestSiteGrant_UserNotYetMember_IssuesPUT(t *testing.T) {
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations — Sites absorbs idempotency client-side, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations — Sites absorbs idempotency client-side, got %v", annos)
}
if len(putBody) == 0 {
t.Fatal("expected a PUT to be issued to add the new site")
@@ -110,7 +111,7 @@ func TestSiteGrant_UserNotYetMember_IssuesPUT(t *testing.T) {
func TestSiteGrant_UserAlreadyMember_MapsToGrantAlreadyExists(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesHandler(t, []int{2}, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
grants, annos, err := s.Grant(context.Background(), userPrincipal(t, 42), siteEntitlement(t, 2))
if err != nil {
@@ -130,15 +131,15 @@ func TestSiteGrant_UserAlreadyMember_MapsToGrantAlreadyExists(t *testing.T) {
func TestSiteRevoke_UserIsMember_IssuesPUT(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesHandler(t, []int{1, 2}, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userPrincipal(t, 42).Id)
annos, err := s.Revoke(context.Background(), gr)
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations, got %v", annos)
}
if len(putBody) == 0 {
t.Fatal("expected a PUT to be issued to remove the site")
@@ -156,7 +157,7 @@ func TestSiteRevoke_UserIsMember_IssuesPUT(t *testing.T) {
func TestSiteRevoke_UserNotMember_MapsToGrantAlreadyRevoked(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesHandler(t, []int{1}, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userPrincipal(t, 42).Id)
annos, err := s.Revoke(context.Background(), gr)
@@ -180,7 +181,7 @@ func TestSiteRevoke_UserDeleted_MapsToGrantAlreadyRevoked(t *testing.T) {
client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userPrincipal(t, 42).Id)
annos, err := s.Revoke(context.Background(), gr)
@@ -193,7 +194,7 @@ func TestSiteRevoke_UserDeleted_MapsToGrantAlreadyRevoked(t *testing.T) {
}
func TestSiteGrant_NonUserPrincipal_Errors(t *testing.T) {
- s := siteBuilder(nil)
+ s := siteBuilder(nil, true, true, true)
_, _, err := s.Grant(context.Background(), userGroupPrincipal(t, 7), siteEntitlement(t, 2))
if err == nil {
t.Fatal("expected an error granting site membership to a non-user principal")
@@ -201,7 +202,7 @@ func TestSiteGrant_NonUserPrincipal_Errors(t *testing.T) {
}
func TestSiteRevoke_NonUserPrincipal_Errors(t *testing.T) {
- s := siteBuilder(nil)
+ s := siteBuilder(nil, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userGroupPrincipal(t, 7).Id)
_, err := s.Revoke(context.Background(), gr)
if err == nil {
@@ -219,7 +220,7 @@ func TestSiteGrant_UserDeleted_ReturnsNotFound(t *testing.T) {
}
w.WriteHeader(http.StatusNotFound)
})
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
_, _, err := s.Grant(context.Background(), userPrincipal(t, 42), siteEntitlement(t, 2))
if err == nil {
@@ -237,7 +238,7 @@ func TestSiteGrant_UserDeleted_ReturnsNotFound(t *testing.T) {
func TestSiteGrant_Conflict_ReturnsFailedPrecondition(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesStatefulHandler(t, []int{1}, []int{1}, http.StatusConflict, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
_, _, err := s.Grant(context.Background(), userPrincipal(t, 42), siteEntitlement(t, 2))
if err == nil {
@@ -258,15 +259,15 @@ func TestSiteGrant_Conflict_ReturnsFailedPrecondition(t *testing.T) {
func TestSiteRevoke_LastSite_SendsExplicitEmptySitesElement(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesHandler(t, []int{2}, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userPrincipal(t, 42).Id)
annos, err := s.Revoke(context.Background(), gr)
if err != nil {
t.Fatalf("Revoke: %v", err)
}
- if annos != nil {
- t.Errorf("expected no annotations, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations, got %v", annos)
}
if want := ""; string(putBody) != want {
t.Errorf("PUT body = %q, want %q", string(putBody), want)
@@ -279,7 +280,7 @@ func TestSiteRevoke_LastSite_SendsExplicitEmptySitesElement(t *testing.T) {
func TestSiteRevoke_Conflict_ReturnsFailedPrecondition(t *testing.T) {
var putBody []byte
client := newTestJamfClient(t, jamfUserSitesStatefulHandler(t, []int{1, 2}, []int{1, 2}, http.StatusConflict, &putBody))
- s := siteBuilder(client)
+ s := siteBuilder(client, true, true, true)
gr := grant.NewGrant(siteEntitlement(t, 2).Resource, memberEntitlement, userPrincipal(t, 42).Id)
_, err := s.Revoke(context.Background(), gr)
@@ -294,57 +295,97 @@ func TestSiteRevoke_Conflict_ReturnsFailedPrecondition(t *testing.T) {
}
}
-// TestSiteGrants_UserWithTwoSites_EmitsGrantForEachSite covers sync's Grants
-// method: a user belonging to two sites must produce a grant from each
-// site's Grants() call, not just the first (regression coverage for the
-// flat-vs-wrapped decoding bug).
-func TestSiteGrants_UserWithTwoSites_EmitsGrantForEachSite(t *testing.T) {
- client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch {
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users":
- _ = json.NewEncoder(w).Encode(map[string]any{
- "users": []map[string]any{{"id": 42, "name": "jappleseed"}},
- })
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/users/id/42":
- _ = json.NewEncoder(w).Encode(map[string]any{
- "user": map[string]any{
- "id": 42, "name": "jappleseed",
- "sites": []map[string]any{{"id": 1, "name": "HQ"}, {"id": 2, "name": "Remote"}},
- },
- })
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/usergroups":
- _ = json.NewEncoder(w).Encode(map[string]any{"user_groups": []any{}})
- case r.Method == http.MethodGet && r.URL.Path == "/JSSResource/accounts":
- _ = json.NewEncoder(w).Encode(map[string]any{"accounts": map[string]any{"users": []any{}, "groups": []any{}}})
- default:
- t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
- }
- })
- s := siteBuilder(client)
+// Site grants walk the site's users, then its user groups, then the accounts assigned to it, one page per call.
+func TestSiteGrants_WalksUsersUserGroupsThenAdmins(t *testing.T) {
+ siteAuditor := testAccount(7, privilegeSetAuditor)
+ siteAuditor.Site = jamf.BaseType{ID: 1}
+ siteCustom := testAccount(8, privilegeSetCustom)
+ siteCustom.Site = jamf.BaseType{ID: 1}
+ otherSiteAuditor := testAccount(9, privilegeSetAuditor)
+ otherSiteAuditor.Site = jamf.BaseType{ID: 2}
+ noSiteAuditor := testAccount(10, privilegeSetAuditor)
- site1, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 1, Name: "HQ"}}, nil)
+ userIDs := idRange(1, jamf.SiteObjectsPageSize+50) // a full page and a short one
+ userGroupIDs := idRange(500, 30)
+ fixture := newAdminFixture(t, []jamf.UserAccount{siteAuditor, siteCustom, otherSiteAuditor, noSiteAuditor}, nil)
+ fixture.siteObjects = map[string][]int{
+ "1/User": userIDs,
+ "1/User Group": userGroupIDs,
+ "2/User": {999},
+ }
+ builder := siteBuilder(fixture.client(), true, true, true)
+ site, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 1, Name: "Site 1"}}, nil)
if err != nil {
t.Fatalf("siteResource: %v", err)
}
- site2, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 2, Name: "Remote"}}, nil)
+
+ grants, calls := collectGrants(t, builder.Grants, site, newMemorySessionStore())
+
+ // Two user pages, one user group page, one admin page.
+ if calls != 4 {
+ t.Errorf("Grants calls = %d, want 4", calls)
+ }
+ if got := fixture.requestCount("/api/v1/sites/1/objects"); got != 3 {
+ t.Errorf("site objects requests = %d, want 3", got)
+ }
+ if got := fixture.accountsRequestsSeen(); len(got) != 1 || got[0].filter != "siteId==1" {
+ t.Errorf("accounts requests = %v, want one with filter siteId==1", got)
+ }
+ if got := fixture.requestCount("/JSSResource"); got != 0 {
+ t.Errorf("Classic requests = %d, want 0", got)
+ }
+
+ var want []string
+ for _, userID := range userIDs {
+ want = append(want, "user:"+strconv.Itoa(userID))
+ }
+ for _, userGroupID := range userGroupIDs {
+ want = append(want, "userGroup:"+strconv.Itoa(userGroupID))
+ }
+ want = append(want, "userAccount:7", "userAccount:8")
+ slices.Sort(want)
+ if got := grantPrincipals(t, grants); !slices.Equal(got, want) {
+ t.Errorf("principals = %v, want %v", got, want)
+ }
+}
+
+// A sync without userAccount reads no accounts, so a missing Read Accounts can't fail it.
+func TestSiteGrants_SkipsAdminsWhenUserAccountNotSynced(t *testing.T) {
+ siteAuditor := testAccount(7, privilegeSetAuditor)
+ siteAuditor.Site = jamf.BaseType{ID: 1}
+ fixture := newAdminFixture(t, []jamf.UserAccount{siteAuditor}, nil)
+ fixture.siteObjects = map[string][]int{"1/User": {1}, "1/User Group": {500}}
+ builder := siteBuilder(fixture.client(), true, true, false)
+ site, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 1, Name: "Site 1"}}, nil)
if err != nil {
t.Fatalf("siteResource: %v", err)
}
- grants1, _, err := s.Grants(context.Background(), site1, rs.SyncOpAttrs{})
- if err != nil {
- t.Fatalf("Grants(site 1): %v", err)
+ grants, _ := collectGrants(t, builder.Grants, site, newMemorySessionStore())
+
+ if got := fixture.accountsRequestsSeen(); len(got) != 0 {
+ t.Errorf("accounts requests = %v, want none", got)
}
- if len(grants1) != 1 {
- t.Fatalf("want 1 grant for site 1, got %d", len(grants1))
+ if got := grantPrincipals(t, grants); !slices.Equal(got, []string{"user:1", "userGroup:500"}) {
+ t.Errorf("principals = %v, want [user:1 userGroup:500]", got)
}
+}
- grants2, _, err := s.Grants(context.Background(), site2, rs.SyncOpAttrs{})
+// With none of its principal types synced, site Grants pushes no phase and must end without a read, not panic.
+func TestSiteGrants_ReadsNothingWhenNoPrincipalTypeSynced(t *testing.T) {
+ fixture := newAdminFixture(t, nil, nil)
+ builder := siteBuilder(fixture.client(), false, false, false)
+ site, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 1, Name: "Site 1"}}, nil)
if err != nil {
- t.Fatalf("Grants(site 2): %v", err)
+ t.Fatalf("siteResource: %v", err)
+ }
+
+ grants, calls := collectGrants(t, builder.Grants, site, newMemorySessionStore())
+
+ if len(grants) != 0 || calls != 1 {
+ t.Errorf("grants = %d over %d calls, want 0 over 1", len(grants), calls)
}
- if len(grants2) != 1 {
- t.Fatalf("want 1 grant for site 2, got %d", len(grants2))
+ if got := fixture.requestCount(""); got != 0 {
+ t.Errorf("Jamf requests = %d, want 0", got)
}
}
diff --git a/pkg/connector/user.go b/pkg/connector/user.go
index 49ed12a0..92e77195 100644
--- a/pkg/connector/user.go
+++ b/pkg/connector/user.go
@@ -3,18 +3,20 @@ package connector
import (
"context"
"fmt"
- "strconv"
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
)
type userResourceType struct {
- resourceType *v2.ResourceType
- client *jamf.Client
+ resourceType *v2.ResourceType
+ client *jamf.Client
+ syncManagedDevices bool
}
// Account-creation profile field names, shared between the "user" and
@@ -30,11 +32,11 @@ func (o *userResourceType) ResourceType(_ context.Context) *v2.ResourceType {
}
// requireLogin returns AccountInfo.Login, or an error if it's empty. Shared
-// between userResourceType.CreateAccount and userAccountResourceType.CreateAccount.
+// between provisionableUserType.CreateAccount and provisionableUserAccountType.CreateAccount.
func requireLogin(accountInfo *v2.AccountInfo) (string, error) {
name := accountInfo.GetLogin()
if name == "" {
- return "", fmt.Errorf("jamf-connector: create account: login is required")
+ return "", status.Errorf(codes.InvalidArgument, "jamf-connector: create account: login is required")
}
return name, nil
}
@@ -55,8 +57,17 @@ func userResource(user *jamf.User, parentResourceID *v2.ResourceId) (*v2.Resourc
rs.WithEmail(user.Email, true),
}
+ // Jamf users may have no full name; fall back to the username, then the email.
+ displayName := user.FullName
+ if displayName == "" {
+ displayName = user.Name
+ }
+ if displayName == "" {
+ displayName = user.Email
+ }
+
ret, err := rs.NewUserResource(
- user.FullName,
+ displayName,
resourceTypeUser,
user.ID,
userTraitOptions,
@@ -72,22 +83,28 @@ func userResource(user *jamf.User, parentResourceID *v2.ResourceId) (*v2.Resourc
}
func (o *userResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- users, err := o.client.GetUsers(ctx)
+ users, nextToken, annos, err := o.client.GetProUsers(ctx, attrs.PageToken.Token, jamf.UsersPageSize)
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: list users: %w", err)
}
var rv []*v2.Resource
- for _, baseUser := range users {
- baseUserCopy := baseUser
- ur, err := userResource(baseUserCopy, parentId)
+ for _, user := range users {
+ resource, err := userResource(user.User(), parentId)
if err != nil {
return nil, nil, err
}
- rv = append(rv, ur)
+ rv = append(rv, resource)
}
- return rv, nil, nil
+ // The device Grants resolve each owner from this cache.
+ if o.syncManagedDevices {
+ if err := cacheUserIDs(ctx, attrs.Session, users); err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: list users: %w", err)
+ }
+ }
+
+ return rv, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil
}
func (o *userResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
@@ -172,14 +189,14 @@ func (o *provisionableUserType) CreateAccount(
fullName, _ := profileMap[profileFieldFullName].(string)
email, _ := profileMap[profileFieldEmail].(string)
- err = o.client.CreateUser(ctx, name, fullName, email)
+ writeAnnos, err := o.client.CreateUser(ctx, name, fullName, email)
alreadyExists := err != nil && jamf.IsAlreadyExistsError(err)
if err != nil && !alreadyExists {
return nil, nil, nil, fmt.Errorf("jamf-connector: create account %s: %w", name, err)
}
// Fetch the user, whether just created or already existing.
- fetched, err := o.client.GetUserByName(ctx, name)
+ fetched, annos, err := o.client.GetUserByName(ctx, name)
if err != nil {
return nil, nil, nil, fmt.Errorf("jamf-connector: create account %s: fetch failed: %w", name, err)
}
@@ -190,9 +207,9 @@ func (o *provisionableUserType) CreateAccount(
}
if alreadyExists {
- return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: resource}, nil, nil, nil
+ return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: resource}, nil, annos, nil
}
- return &v2.CreateAccountResponse_SuccessResult{Resource: resource}, nil, nil, nil
+ return &v2.CreateAccountResponse_SuccessResult{Resource: resource}, nil, writeAnnos, nil
}
// Delete removes a Jamf directory user. Not gated by create-account-resource-type —
@@ -201,24 +218,25 @@ func (o *provisionableUserType) CreateAccount(
func (o *userResourceType) Delete(ctx context.Context, resourceID *v2.ResourceId, _ *v2.ResourceId) (annotations.Annotations, error) {
ctx = jamf.WithFreshReads(ctx)
- id, err := strconv.Atoi(resourceID.Resource)
+ id, err := parseResourceID(resourceID.Resource, "jamf-connector: delete user: invalid resource id")
if err != nil {
- return nil, fmt.Errorf("jamf-connector: delete user: invalid resource id %q: %w", resourceID.Resource, err)
+ return nil, err
}
- err = o.client.DeleteUser(ctx, id)
+ annos, err := o.client.DeleteUser(ctx, id)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil
}
return nil, fmt.Errorf("jamf-connector: delete user %d: %w", id, err)
}
- return nil, nil
+ return annos, nil
}
-func userBuilder(client *jamf.Client) *userResourceType {
+func userBuilder(client *jamf.Client, syncManagedDevices bool) *userResourceType {
return &userResourceType{
- resourceType: resourceTypeUser,
- client: client,
+ resourceType: resourceTypeUser,
+ client: client,
+ syncManagedDevices: syncManagedDevices,
}
}
diff --git a/pkg/connector/userAccount.go b/pkg/connector/userAccount.go
index 74b23c6e..43a2b6fb 100644
--- a/pkg/connector/userAccount.go
+++ b/pkg/connector/userAccount.go
@@ -12,6 +12,8 @@ import (
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
"github.com/conductorone/baton-sdk/pkg/crypto"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
)
type userAccountResourceType struct {
@@ -85,14 +87,15 @@ func resolvePrivileges(profileMap map[string]interface{}, privilegeSet string) (
switch {
case privilegeSet == privilegeSetCustom && provided.IsEmpty():
- return nil, fmt.Errorf("jamf-connector: privilege_set is %q but no privileges were set — set at least one of the Privileges fields", privilegeSetCustom)
+ return nil, status.Errorf(codes.InvalidArgument, "jamf-connector: privilege_set is %q but no privileges were set — set at least one of the Privileges fields", privilegeSetCustom)
case privilegeSet == privilegeSetCustom:
return provided, nil
case !provided.IsEmpty():
// Privileges fields are only meaningful for a Custom privilege_set — reject rather
// than silently discarding them, which would leave the caller with an account
// that has none of the access it asked for and no indication why.
- return nil, fmt.Errorf(
+ return nil, status.Errorf(
+ codes.InvalidArgument,
"jamf-connector: privileges were set but privilege_set is %q, not %q — Privileges fields only apply to %q accounts",
privilegeSet, privilegeSetCustom, privilegeSetCustom,
)
@@ -123,12 +126,19 @@ func (o *userAccountResourceType) ResourceType(_ context.Context) *v2.ResourceTy
// userAccountResource creates a new connector resource for a Jamf user account.
func userAccountResource(account *jamf.UserAccount, parentResourceID *v2.ResourceId) (*v2.Resource, error) {
+ // first_name and last_name keep the username split they shipped with; the real name is full_name.
firstName, lastName := rs.SplitFullName(account.Name)
profile := map[string]interface{}{
- "first_name": firstName,
- "last_name": lastName,
- "login": account.Email,
- "user_id": fmt.Sprintf("account:%d", account.ID),
+ "first_name": firstName,
+ "last_name": lastName,
+ "login": account.Email,
+ "user_id": fmt.Sprintf("account:%d", account.ID),
+ profileFieldFullName: account.FullName,
+ }
+
+ displayName := account.FullName
+ if displayName == "" {
+ displayName = account.Name
}
var resourceStatus v2.Status_ResourceStatus
@@ -143,7 +153,7 @@ func userAccountResource(account *jamf.UserAccount, parentResourceID *v2.Resourc
}
ret, err := rs.NewUserResource(
- account.Name,
+ displayName,
resourceTypeUserAccount,
account.ID,
userTraitOptions,
@@ -159,23 +169,21 @@ func userAccountResource(account *jamf.UserAccount, parentResourceID *v2.Resourc
}
func (o *userAccountResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- userAccounts, _, err := o.client.GetAccounts(ctx)
+ accounts, nextToken, annos, err := o.client.GetProAccounts(ctx, "", attrs.PageToken.Token, jamf.AccountsPageSize)
if err != nil {
- return nil, nil, fmt.Errorf("jamf-connector: failed to list accounts: %w", err)
+ return nil, nil, fmt.Errorf("jamf-connector: list accounts: %w", err)
}
var rv []*v2.Resource
-
- for _, user := range userAccounts {
- userCopy := user
- ur, err := userAccountResource(userCopy, parentId)
+ for _, account := range accounts {
+ resource, err := userAccountResource(account.UserAccount(), parentId)
if err != nil {
return nil, nil, err
}
- rv = append(rv, ur)
+ rv = append(rv, resource)
}
- return rv, nil, nil
+ return rv, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil
}
func (o *userAccountResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
@@ -289,7 +297,7 @@ func (o *provisionableUserAccountType) CreateAccount(
privilegeSet := defaultPrivilegeSet
if raw, ok := profileMap[profileFieldPrivilegeSet].(string); ok && raw != "" {
if !isKnownPrivilegeSet(raw) {
- return nil, nil, nil, fmt.Errorf("jamf-connector: unknown privilege_set %q (valid: %v)", raw, knownPrivilegeSets)
+ return nil, nil, nil, status.Errorf(codes.InvalidArgument, "jamf-connector: unknown privilege_set %q (valid: %v)", raw, knownPrivilegeSets)
}
privilegeSet = raw
}
@@ -304,7 +312,7 @@ func (o *provisionableUserAccountType) CreateAccount(
return nil, nil, nil, err
}
- err = o.client.CreateUserAccount(ctx, jamf.UserAccountCreateBody{
+ writeAnnos, err := o.client.CreateUserAccount(ctx, jamf.UserAccountCreateBody{
Name: name,
Password: password,
FullName: fullName,
@@ -320,7 +328,7 @@ func (o *provisionableUserAccountType) CreateAccount(
}
// Fetch the account, whether just created or already existing.
- fetched, err := o.client.GetUserAccountByName(ctx, name)
+ fetched, annos, err := o.client.GetUserAccountByName(ctx, name)
if err != nil {
return nil, nil, nil, fmt.Errorf("jamf-connector: create account %s: fetch failed: %w", name, err)
}
@@ -331,7 +339,7 @@ func (o *provisionableUserAccountType) CreateAccount(
}
if alreadyExists {
- return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: resource}, nil, nil, nil
+ return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: resource}, nil, annos, nil
}
plaintextData := []*v2.PlaintextData{
@@ -340,7 +348,7 @@ func (o *provisionableUserAccountType) CreateAccount(
Bytes: []byte(password),
},
}
- return &v2.CreateAccountResponse_SuccessResult{Resource: resource}, plaintextData, nil, nil
+ return &v2.CreateAccountResponse_SuccessResult{Resource: resource}, plaintextData, writeAnnos, nil
}
// Delete removes a Jamf console admin account. Not gated by
@@ -349,19 +357,28 @@ func (o *provisionableUserAccountType) CreateAccount(
func (o *userAccountResourceType) Delete(ctx context.Context, resourceID *v2.ResourceId, _ *v2.ResourceId) (annotations.Annotations, error) {
ctx = jamf.WithFreshReads(ctx)
- id, err := strconv.Atoi(resourceID.Resource)
+ id, err := parseResourceID(resourceID.Resource, "jamf-connector: delete userAccount: invalid resource id")
+ if err != nil {
+ return nil, err
+ }
+
+ tokenDetails, annos, err := o.client.GetTokenDetails(ctx)
if err != nil {
- return nil, fmt.Errorf("jamf-connector: delete userAccount: invalid resource id %q: %w", resourceID.Resource, err)
+ return nil, fmt.Errorf("jamf-connector: delete userAccount %d: read token details: %w", id, err)
+ }
+ if tokenDetails.Account.ID == strconv.Itoa(id) {
+ return nil, status.Errorf(codes.FailedPrecondition,
+ "jamf-connector: refusing to delete userAccount %d: it is the account the connector authenticates with", id)
}
- err = o.client.DeleteUserAccount(ctx, id)
+ writeAnnos, err := o.client.DeleteUserAccount(ctx, id)
if err != nil {
if jamf.IsNotFoundError(err) {
- return nil, nil
+ return annos, nil
}
return nil, fmt.Errorf("jamf-connector: delete userAccount %d: %w", id, err)
}
- return nil, nil
+ return writeAnnos, nil
}
func userAccountBuilder(client *jamf.Client) *userAccountResourceType {
diff --git a/pkg/connector/userAccount_test.go b/pkg/connector/userAccount_test.go
index 11ddec0e..e7898e8b 100644
--- a/pkg/connector/userAccount_test.go
+++ b/pkg/connector/userAccount_test.go
@@ -1,7 +1,13 @@
package connector
import (
+ "context"
+ "net/http"
"testing"
+
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
)
func TestResolvePrivileges_CustomWithPrivileges(t *testing.T) {
@@ -63,3 +69,30 @@ func TestResolvePrivileges_DiscardsMalformedEntries(t *testing.T) {
t.Fatalf("expected malformed/empty entries filtered out, got %v", got.JSSObjects)
}
}
+
+// Deleting the account the connector authenticates with would cut its own access, so Delete refuses it.
+func TestUserAccountDelete_RefusesTheConnectorsOwnAccount(t *testing.T) {
+ var deletes []string
+ client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/auth":
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"account": map[string]any{"id": "7", "username": "connector"}})
+ case r.Method == http.MethodDelete:
+ deletes = append(deletes, r.URL.Path)
+ w.WriteHeader(http.StatusOK)
+ default:
+ t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ })
+ builder := userAccountBuilder(client)
+
+ _, err := builder.Delete(context.Background(), &v2.ResourceId{ResourceType: resourceTypeUserAccount.Id, Resource: "7"}, nil)
+
+ if status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("Delete own account err = %v, want FailedPrecondition", err)
+ }
+ if len(deletes) != 0 {
+ t.Fatalf("want no DELETE for the connector's own account, got %v", deletes)
+ }
+}
diff --git a/pkg/connector/userGroup.go b/pkg/connector/userGroup.go
index 886de13a..ac70d5ee 100644
--- a/pkg/connector/userGroup.go
+++ b/pkg/connector/userGroup.go
@@ -12,6 +12,7 @@ import (
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/conductorone/baton-sdk/pkg/types/sessions"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -48,64 +49,114 @@ func userGroupResource(group *jamf.UserGroup, parentResourceID *v2.ResourceId) (
}
func (g *userGroupResourceType) List(ctx context.Context, parentId *v2.ResourceId, attrs rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
- userGroups, err := g.client.GetUserGroups(ctx)
+ userGroups, annos, err := g.client.GetBaseUserGroups(ctx)
if err != nil {
return nil, nil, fmt.Errorf("jamf-connector: failed to list user groups: %w", err)
}
var rv []*v2.Resource
for _, userGroup := range userGroups {
- userGroupCopy := userGroup
- ur, err := userGroupResource(userGroupCopy, parentId)
+ resource, err := userGroupResource(&userGroup, parentId)
if err != nil {
return nil, nil, err
}
- rv = append(rv, ur)
+ rv = append(rv, resource)
}
- return rv, nil, nil
+ return rv, &rs.SyncOpResults{Annotations: annos}, nil
}
-func (g *userGroupResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
- var rv []*v2.Entitlement
-
- assignmentOptions := []ent.EntitlementOption{
- ent.WithGrantableTo(resourceTypeUser),
- ent.WithDescription(fmt.Sprintf("Member of %s User Group in Jamf", resource.DisplayName)),
- ent.WithDisplayName(fmt.Sprintf("%s User Group %s", resource.DisplayName, memberEntitlement)),
- }
-
- en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assignmentOptions...)
- rv = append(rv, en)
+// Entitlements is unused: StaticEntitlements declares the member entitlement.
+func (g *userGroupResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ return nil, nil, nil
+}
- return rv, nil, nil
+func (g *userGroupResourceType) StaticEntitlements(_ context.Context, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ return []*v2.Entitlement{
+ ent.NewAssignmentEntitlement(nil, memberEntitlement,
+ ent.WithDisplayName("Member"),
+ ent.WithDescription("Member of the Jamf user group"),
+ ent.WithGrantableTo(resourceTypeUser),
+ ),
+ }, nil, nil
}
func (g *userGroupResourceType) Grants(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
- var rv []*v2.Grant
-
userGroupId, err := strconv.Atoi(resource.Id.Resource)
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: invalid user group id %q: %w", resource.Id.Resource, err)
+ }
+ cursor := 0
+ if attrs.PageToken.Token != "" {
+ cursor, err = strconv.Atoi(attrs.PageToken.Token)
+ if err != nil {
+ return nil, nil, fmt.Errorf("jamf-connector: invalid user group %d grants page token %q: %w", userGroupId, attrs.PageToken.Token, err)
+ }
}
- group, err := g.client.GetUserGroupDetails(ctx, userGroupId)
+ members, annos, err := g.memberPage(ctx, attrs.Session, userGroupId, cursor)
if err != nil {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("jamf-connector: list user group %d grants: %w", userGroupId, err)
+ }
+
+ grants := make([]*v2.Grant, 0, len(members.UserIDs))
+ for _, userID := range members.UserIDs {
+ principal := &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: strconv.Itoa(userID)}
+ grants = append(grants, grant.NewGrant(resource, memberEntitlement, principal))
}
- for _, user := range group.Users {
- userCopy := user
- ur, err := userResource(&userCopy, resource.Id)
+ var nextPage string
+ if !members.Last {
+ nextPage = strconv.Itoa(members.UserIDs[len(members.UserIDs)-1])
+ }
+ return grants, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil
+}
+
+// memberPage returns the grants page of the user group's members after cursor, a user id. Classic returns every
+// member at once, so a read from Jamf stores the later pages by cursor; a page missing from the store is read again.
+func (g *userGroupResourceType) memberPage(ctx context.Context, store sessions.SessionStore, userGroupId, cursor int) (userGroupMemberPage, annotations.Annotations, error) {
+ if cursor > 0 {
+ stored, found, err := cachedUserGroupMemberPage(ctx, store, userGroupId, cursor)
if err != nil {
- return nil, nil, err
+ return userGroupMemberPage{}, nil, err
+ }
+ if found {
+ return stored, nil, nil
}
+ }
- grant := grant.NewGrant(resource, memberEntitlement, ur.Id)
- rv = append(rv, grant)
+ group, annos, err := g.client.GetUserGroupDetails(ctx, userGroupId)
+ if err != nil {
+ return userGroupMemberPage{}, nil, err
}
+ pages := memberPages(group.Users, cursor)
+ if len(pages) == 0 {
+ return userGroupMemberPage{Last: true}, annos, nil
+ }
+ if err := cacheUserGroupMemberPages(ctx, store, userGroupId, pages); err != nil {
+ return userGroupMemberPage{}, nil, err
+ }
+ return pages[0], annos, nil
+}
- return rv, nil, nil
+// memberPages splits the members after cursor into grants pages of sorted user ids; the last one is marked.
+func memberPages(users []jamf.User, cursor int) []userGroupMemberPage {
+ userIDs := make([]int, 0, len(users))
+ for _, user := range users {
+ if user.ID > cursor {
+ userIDs = append(userIDs, user.ID)
+ }
+ }
+ slices.Sort(userIDs)
+
+ var pages []userGroupMemberPage
+ for pageIDs := range slices.Chunk(userIDs, grantsPageSize) {
+ pages = append(pages, userGroupMemberPage{UserIDs: pageIDs})
+ }
+ if len(pages) > 0 {
+ pages[len(pages)-1].Last = true
+ }
+ return pages
}
// isUserGroupMember reports whether userID is in users, the membership list
@@ -125,7 +176,7 @@ func (g *userGroupResourceType) Grant(ctx context.Context, principal *v2.Resourc
return nil, nil, err
}
- group, err := g.client.GetUserGroupDetails(ctx, groupID)
+ group, annos, err := g.client.GetUserGroupDetails(ctx, groupID)
if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant user group member: group %d not found", groupID)
@@ -136,17 +187,19 @@ func (g *userGroupResourceType) Grant(ctx context.Context, principal *v2.Resourc
return nil, nil, status.Errorf(codes.FailedPrecondition, "jamf-connector: cannot grant membership on smart user group %d — membership is computed from criteria, not assignable", groupID)
}
if isUserGroupMember(group.Users, userID) {
- return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
+ annos.Update(&v2.GrantAlreadyExists{})
+ return nil, annos, nil
}
- if err := g.client.AddUserGroupMembers(ctx, groupID, []int{userID}); err != nil {
+ annos, err = g.client.AddUserGroupMembers(ctx, groupID, []int{userID})
+ if err != nil {
if jamf.IsNotFoundError(err) {
return nil, nil, status.Errorf(codes.NotFound, "jamf-connector: grant user group member: group %d not found", groupID)
}
return nil, nil, membershipWriteError("grant user group member", "the user or group", err)
}
- return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, nil, nil
+ return []*v2.Grant{grant.NewGrant(entitlement.Resource, memberEntitlement, principal.Id)}, annos, nil
}
// Revoke removes gr's principal (a Jamf user) from the static user group
@@ -160,7 +213,7 @@ func (g *userGroupResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annot
return nil, err
}
- group, err := g.client.GetUserGroupDetails(ctx, groupID)
+ group, annos, err := g.client.GetUserGroupDetails(ctx, groupID)
if err != nil {
// A 404 here means the group itself has been deleted. A deleted
// group trivially has no membership left to revoke.
@@ -173,18 +226,21 @@ func (g *userGroupResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annot
return nil, status.Errorf(codes.FailedPrecondition, "jamf-connector: cannot revoke membership on smart user group %d", groupID)
}
if !isUserGroupMember(group.Users, userID) {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
- if err := g.client.RemoveUserGroupMembers(ctx, groupID, []int{userID}); err != nil {
+ writeAnnos, err := g.client.RemoveUserGroupMembers(ctx, groupID, []int{userID})
+ if err != nil {
// A 404 here means the group was deleted between the GET and this
// PUT — same as the GET 404 case above.
if jamf.IsNotFoundError(err) {
- return annotations.New(&v2.GrantAlreadyRevoked{}), nil
+ annos.Update(&v2.GrantAlreadyRevoked{})
+ return annos, nil
}
return nil, membershipWriteError("revoke user group member", "the user or group", err)
}
- return nil, nil
+ return writeAnnos, nil
}
func userGroupBuilder(client *jamf.Client) *userGroupResourceType {
diff --git a/pkg/connector/userGroup_test.go b/pkg/connector/userGroup_test.go
index 2e9f8d8f..8d707341 100644
--- a/pkg/connector/userGroup_test.go
+++ b/pkg/connector/userGroup_test.go
@@ -4,12 +4,16 @@ import (
"context"
"encoding/json"
"encoding/xml"
+ "fmt"
"io"
"net/http"
+ "strings"
+ "sync/atomic"
"testing"
"github.com/conductorone/baton-jamf/pkg/jamf"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/pagination"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
@@ -106,8 +110,8 @@ func TestUserGroupGrant_StaticGroup_Succeeds(t *testing.T) {
if len(grants) != 1 {
t.Fatalf("want 1 grant, got %d", len(grants))
}
- if annos != nil {
- t.Errorf("expected no annotations on a plain success, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a plain success, got %v", annos)
}
}
@@ -159,8 +163,8 @@ func TestUserGroupRevoke_StaticGroup_Succeeds(t *testing.T) {
if !putCalled {
t.Error("expected PUT to be called for a static group")
}
- if annos != nil {
- t.Errorf("expected no annotations on a plain success, got %v", annos)
+ if hasMarkerAnnotation(annos) {
+ t.Errorf("expected no marker annotations on a plain success, got %v", annos)
}
}
@@ -435,3 +439,59 @@ func TestUserGroupGrants_SingleRead_BuildsPrincipalIDsFromMembers(t *testing.T)
t.Errorf("want exactly 1 GET, got %d", getCalls)
}
}
+
+// A group of 1500 members spans two Grants pages, each member a distinct user principal.
+func TestUserGroupGrants_PagesPastOneThousandMembers(t *testing.T) {
+ const memberCount = 1500
+ users := make([]map[string]any, 0, memberCount)
+ for _, id := range idRange(1, memberCount) {
+ users = append(users, map[string]any{"id": id, "name": fmt.Sprintf("user%d", id)})
+ }
+ var jamfReads atomic.Int32
+ client := newTestJamfClient(t, func(w http.ResponseWriter, _ *http.Request) {
+ jamfReads.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ encodeJSON(t, w, map[string]any{"user_group": map[string]any{"id": 5, "name": "Test Group", "users": users}})
+ })
+ builder := userGroupBuilder(client)
+ resource, err := userGroupResource(&jamf.UserGroup{BaseType: jamf.BaseType{ID: 5, Name: "Test Group"}}, nil)
+ if err != nil {
+ t.Fatalf("userGroupResource: %v", err)
+ }
+
+ // Fresh reads keep the HTTP cache from hiding the Jamf reads this test counts.
+ freshGrants := func(ctx context.Context, resource *v2.Resource, attrs rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
+ return builder.Grants(jamf.WithFreshReads(ctx), resource, attrs)
+ }
+
+ grants, calls := collectGrants(t, freshGrants, resource, newMemorySessionStore())
+
+ if calls != 2 {
+ t.Errorf("Grants calls = %d, want 2", calls)
+ }
+ principals := grantPrincipals(t, grants)
+ if len(principals) != memberCount || !strings.HasPrefix(principals[0], resourceTypeUser.Id+":") {
+ t.Errorf("principals = %d, want %d unique %s principals", len(principals), memberCount, resourceTypeUser.Id)
+ }
+ if reads := jamfReads.Load(); reads != 1 {
+ t.Errorf("Jamf reads = %d, want 1: the session store serves page 1", reads)
+ }
+
+ // A page missing from the store is read from Jamf again and still starts after the cursor,
+ // even when a member with a lower id left in between.
+ users = users[1:]
+ secondPage, results, err := freshGrants(context.Background(), resource, rs.SyncOpAttrs{
+ Session: newMemorySessionStore(),
+ PageToken: pagination.Token{Token: "1000"},
+ })
+ if err != nil {
+ t.Fatalf("Grants page 1 on a store miss: %v", err)
+ }
+ if len(secondPage) != memberCount-grantsPageSize || results.NextPageToken != "" || secondPage[0].GetPrincipal().GetId().GetResource() != "1001" {
+ t.Errorf("page 1 = %d grants from user %s, next token %q; want %d grants from user 1001 and no token",
+ len(secondPage), secondPage[0].GetPrincipal().GetId().GetResource(), results.NextPageToken, memberCount-grantsPageSize)
+ }
+ if reads := jamfReads.Load(); reads != 2 {
+ t.Errorf("Jamf reads after the store miss = %d, want 2", reads)
+ }
+}
diff --git a/pkg/jamf/auth_test.go b/pkg/jamf/auth_test.go
index 3bb88017..1d1866b0 100644
--- a/pkg/jamf/auth_test.go
+++ b/pkg/jamf/auth_test.go
@@ -88,7 +88,7 @@ func TestBasicAuth_ExpiredTokenIsReMinted(t *testing.T) {
ctx := WithFreshReads(context.Background())
for i := 0; i < 3; i++ {
- if _, err := client.GetUserDetails(ctx, 42); err != nil {
+ if _, _, err := client.GetUserDetails(ctx, 42); err != nil {
t.Fatalf("GetUserDetails call %d: %v", i+1, err)
}
}
@@ -118,7 +118,7 @@ func TestBasicAuth_ConcurrentRequestsShareTokens(t *testing.T) {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
- if _, err := client.GetUserDetails(ctx, 42); err != nil {
+ if _, _, err := client.GetUserDetails(ctx, 42); err != nil {
errs <- err
}
}()
@@ -156,7 +156,7 @@ func TestDoRequest_RejectedTokenIsRenewed(t *testing.T) {
})
client := newAuthTestClient(t, mux, NewBasicTokenSource, "user", "pass")
- if err := client.UpdateUserSites(context.Background(), 42, nil); err != nil {
+ if _, err := client.UpdateUserSites(context.Background(), 42, nil); err != nil {
t.Fatalf("UpdateUserSites: %v", err)
}
if got := mints.Load(); got != 2 {
@@ -217,7 +217,7 @@ func TestAPIClientAuth_SendsCredentialsInForm(t *testing.T) {
mux.HandleFunc("/JSSResource/users/id/42", bearerHandler(t, &mints, UserResponse{}))
client := newAuthTestClient(t, mux, NewAPIClientTokenSource, "client-id", "client-secret")
- if _, err := client.GetUserDetails(context.Background(), 42); err != nil {
+ if _, _, err := client.GetUserDetails(context.Background(), 42); err != nil {
t.Fatalf("GetUserDetails: %v", err)
}
}
@@ -252,7 +252,7 @@ func TestAuth_TokenErrors(t *testing.T) {
})
client := newAuthTestClient(t, mux, testCase.constructor, "id", "wrong")
- _, err := client.GetTokenDetails(context.Background())
+ _, _, err := client.GetTokenDetails(context.Background())
if got := status.Code(err); got != testCase.want {
t.Fatalf("code = %v, want %v (err: %v)", got, testCase.want, err)
}
@@ -284,18 +284,24 @@ func TestDoRequest_401Mapping(t *testing.T) {
path: "/JSSResource/users/id/7",
contentType: "text/html;charset=utf-8",
body: "Status pageUnauthorized
",
- call: func(client *Client) error { return client.UpdateUserSites(context.Background(), 7, nil) },
- want: codes.PermissionDenied,
- wantCalls: 1,
+ call: func(client *Client) error {
+ _, err := client.UpdateUserSites(context.Background(), 7, nil)
+ return err
+ },
+ want: codes.PermissionDenied,
+ wantCalls: 1,
},
{
name: "classic API rejected token",
path: "/JSSResource/users/id/7",
contentType: "application/json;charset=utf-8",
body: `{"httpStatus":401,"errors":[{"code":"INVALID_TOKEN","description":"Unauthorized"}]}`,
- call: func(client *Client) error { return client.UpdateUserSites(context.Background(), 7, nil) },
- want: codes.Unauthenticated,
- wantCalls: 2,
+ call: func(client *Client) error {
+ _, err := client.UpdateUserSites(context.Background(), 7, nil)
+ return err
+ },
+ want: codes.Unauthenticated,
+ wantCalls: 2,
},
{
name: "pro API HTML 401",
@@ -303,7 +309,7 @@ func TestDoRequest_401Mapping(t *testing.T) {
contentType: "text/html;charset=utf-8",
body: "Status pageUnauthorized
",
call: func(client *Client) error {
- _, err := client.GetTokenDetails(context.Background())
+ _, _, err := client.GetTokenDetails(context.Background())
return err
},
want: codes.Unauthenticated,
@@ -363,7 +369,7 @@ func TestDoRequest_RenewFailure(t *testing.T) {
})
client := newAuthTestClient(t, mux, NewBasicTokenSource, "user", "pass")
- err := client.UpdateUserSites(context.Background(), 7, nil)
+ _, err := client.UpdateUserSites(context.Background(), 7, nil)
if got := status.Code(err); got != testCase.want {
t.Fatalf("code = %v, want %v (err: %v)", got, testCase.want, err)
}
@@ -386,7 +392,7 @@ func TestDoRequest_DecodeErrorOmitsBody(t *testing.T) {
})
client := newAuthTestClient(t, mux, NewBasicTokenSource, "user", "pass")
- _, err := client.GetUserDetails(context.Background(), 42)
+ _, _, err := client.GetUserDetails(context.Background(), 42)
if err == nil {
t.Fatal("GetUserDetails: want a decode error")
}
diff --git a/pkg/jamf/client.go b/pkg/jamf/client.go
index af07d08d..56fc7ef0 100644
--- a/pkg/jamf/client.go
+++ b/pkg/jamf/client.go
@@ -9,8 +9,11 @@ import (
"net"
"net/http"
liburl "net/url"
+ "strconv"
"strings"
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
@@ -20,20 +23,29 @@ import (
)
const (
- accountUrlPath = "/JSSResource/accounts/userid/%d"
- accountUsernameUrlPath = "/JSSResource/accounts/username/%s"
- accountsUrlPath = "/JSSResource/accounts"
- authUrlPath = "/api/v1/auth"
- groupUrlPath = "/JSSResource/accounts/groupid/%d"
- sitesUrlPath = "/JSSResource/sites"
- tokenUrlPath = "/api/v1/auth/token" //nolint:golint,gosec // not a token
- userGroupUrlPath = "/JSSResource/usergroups/id/%d"
- userGroupsUrlPath = "/JSSResource/usergroups"
- userUrlPath = "/JSSResource/users/id/%d"
- userNameUrlPath = "/JSSResource/users/name/%s"
- usersUrlPath = "/JSSResource/users"
- oauthTokenUrlPath = "/api/v1/oauth/token" //nolint:gosec // not a token
- privilegesUrlPath = "/api/v1/api-role-privileges"
+ accountUrlPath = "/JSSResource/accounts/userid/%d" // https://developer.jamf.com/jamf-pro/reference/findaccountsbyid
+ accountUsernameUrlPath = "/JSSResource/accounts/username/%s" // https://developer.jamf.com/jamf-pro/reference/findaccountsbyname
+ accountsUrlPath = "/JSSResource/accounts" // https://developer.jamf.com/jamf-pro/reference/findaccounts
+ authUrlPath = "/api/v1/auth" // https://developer.jamf.com/jamf-pro/reference/get_v1-auth
+ groupUrlPath = "/JSSResource/accounts/groupid/%d" // https://developer.jamf.com/jamf-pro/reference/findgroupsbyid
+ sitesUrlPath = "/JSSResource/sites" // https://developer.jamf.com/jamf-pro/reference/findsites
+ tokenUrlPath = "/api/v1/auth/token" //nolint:golint,gosec // not a token, https://developer.jamf.com/jamf-pro/reference/post_v1-auth-token
+ userGroupUrlPath = "/JSSResource/usergroups/id/%d" // https://developer.jamf.com/jamf-pro/reference/findusergroupsbyid
+ userGroupsUrlPath = "/JSSResource/usergroups" // https://developer.jamf.com/jamf-pro/reference/findusergroups
+ userUrlPath = "/JSSResource/users/id/%d" // https://developer.jamf.com/jamf-pro/reference/findusersbyid
+ userNameUrlPath = "/JSSResource/users/name/%s" // https://developer.jamf.com/jamf-pro/reference/findusersbyname
+ oauthTokenUrlPath = "/api/v1/oauth/token" //nolint:gosec // not a token, https://developer.jamf.com/jamf-pro/docs/client-credentials
+ privilegesUrlPath = "/api/v1/api-role-privileges" // https://developer.jamf.com/jamf-pro/reference/get_v1-api-role-privileges
+ proUsersUrlPath = "/api/v1/users" // https://developer.jamf.com/jamf-pro/reference/get_v1-users
+ proAccountsUrlPath = "/api/v1/accounts" // https://developer.jamf.com/jamf-pro/reference/get_v1-accounts
+ siteObjectsUrlPath = "/api/v1/sites/%d/objects" // https://developer.jamf.com/jamf-pro/reference/get_v1-sites-id-objects
+
+ // UsersPageSize is the documented maximum page-size of GET /api/v1/users.
+ UsersPageSize = 1000
+ // AccountsPageSize is the documented default page-size of GET /api/v1/accounts; no maximum is documented.
+ AccountsPageSize = 100
+ // SiteObjectsPageSize is the page-size used for GET /api/v1/sites/{id}/objects; no maximum is documented.
+ SiteObjectsPageSize = 1000
classicAPIPathPrefix = "/JSSResource/"
@@ -112,165 +124,269 @@ func (c *Client) getUrl(path string) (*liburl.URL, error) {
}
// GetTokenDetails gets authorization details associated with the current api token.
-func (c *Client) GetTokenDetails(ctx context.Context) (*TokenDetails, error) {
+func (c *Client) GetTokenDetails(ctx context.Context) (*TokenDetails, annotations.Annotations, error) {
url, err := c.getUrl(authUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target TokenDetails
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
- }
-
- return &target, nil
-}
-
-func (c *Client) getBaseUsers(ctx context.Context) ([]BaseType, error) {
- url, err := c.getUrl(usersUrlPath)
+ annos, err := c.doRequest(ctx, url, &target)
if err != nil {
- return nil, err
- }
-
- var target UsersResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ return nil, nil, err
}
- return target.Users, nil
+ return &target, annos, nil
}
// GetUserDetails returns Jamf user details for a single directory user.
-func (c *Client) GetUserDetails(ctx context.Context, userId int) (*User, error) {
+func (c *Client) GetUserDetails(ctx context.Context, userId int) (*User, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userUrlPath, userId))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.User, nil
+ return &target.User, annos, nil
}
-func (c *Client) getBaseAccounts(ctx context.Context) (*BaseAccount, error) {
+// GetBaseAccounts returns the Classic admin accounts (users and groups, id and name only).
+// GET /JSSResource/accounts, not paginated, needs the Read Accounts privilege.
+func (c *Client) GetBaseAccounts(ctx context.Context) (*BaseAccount, annotations.Annotations, error) {
url, err := c.getUrl(accountsUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target AccountsResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.Accounts, nil
+ return &target.Accounts, annos, nil
}
// GetGroupDetails returns Jamf group details.
-func (c *Client) GetGroupDetails(ctx context.Context, groupId int) (*Group, error) {
+func (c *Client) GetGroupDetails(ctx context.Context, groupId int) (*Group, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(groupUrlPath, groupId))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target GroupResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.Group, nil
+ return &target.Group, annos, nil
}
-// GetUserAccountDetails returns Jamf user account details.
-func (c *Client) GetUserAccountDetails(ctx context.Context, userId int) (*UserAccount, error) {
+// GetUserAccountDetails returns Jamf user account details; a 404 (IsNotFoundError) means the account does not exist.
+func (c *Client) GetUserAccountDetails(ctx context.Context, userId int) (*UserAccount, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(accountUrlPath, userId))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserAccountResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.UserAccount, nil
+ return &target.UserAccount, annos, nil
}
// GetSites returns all Jamf sites.
-func (c *Client) GetSites(ctx context.Context) (*[]Site, error) {
+func (c *Client) GetSites(ctx context.Context) (*[]Site, annotations.Annotations, error) {
url, err := c.getUrl(sitesUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target SitesResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.Sites, nil
+ return &target.Sites, annos, nil
}
-func (c *Client) getBaseUserGroups(ctx context.Context) ([]UserGroup, error) {
+// GetBaseUserGroups returns the Classic user groups (id and name only).
+// GET /JSSResource/usergroups, not paginated, needs the Read Static User Groups privilege.
+func (c *Client) GetBaseUserGroups(ctx context.Context) ([]UserGroup, annotations.Annotations, error) {
url, err := c.getUrl(userGroupsUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserGroupsResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return target.UserGroups, nil
+ return target.UserGroups, annos, nil
}
// GetUserGroupDetails returns Jamf user group details.
-func (c *Client) GetUserGroupDetails(ctx context.Context, userGroupId int) (*UserGroup, error) {
+func (c *Client) GetUserGroupDetails(ctx context.Context, userGroupId int) (*UserGroup, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userGroupUrlPath, userGroupId))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserGroupResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.UserGroup, nil
+ return &target.UserGroup, annos, nil
}
-// GetUsers returns all Jamf users.
-func (c *Client) GetUsers(ctx context.Context) ([]*User, error) {
- var users []*User
- baseUsers, err := c.getBaseUsers(ctx)
+// GetProUsers returns one page of users ordered by id and the next page token, "" after the last page.
+// GET /api/v1/users, needs the Read User privilege.
+func (c *Client) GetProUsers(ctx context.Context, pageToken string, pageSize int) ([]ProUser, string, annotations.Annotations, error) {
+ page, err := parsePageToken(pageToken)
if err != nil {
- return nil, err
+ return nil, "", nil, err
}
- for _, baseUser := range baseUsers {
- user, err := c.GetUserDetails(ctx, baseUser.ID)
- if err != nil {
- return nil, err
- }
- users = append(users, user)
+ url, err := c.getUrl(proUsersUrlPath)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ query := liburl.Values{}
+ query.Set("page", strconv.Itoa(page))
+ query.Set("page-size", strconv.Itoa(pageSize))
+ query.Set("sort", "id:asc")
+ url.RawQuery = query.Encode()
+
+ var response ProUsersResponse
+ annos, err := c.doRequest(ctx, url, &response)
+ if err != nil {
+ return nil, "", nil, proEndpointError(proUsersUrlPath, err)
}
- return users, nil
+ nextPage := nextPageToken(page, pageSize, len(response.Results), response.TotalCount)
+ return response.Results, nextPage, annos, nil
+}
+
+// rsqlValueEscaper escapes a value placed inside a double-quoted RSQL string.
+var rsqlValueEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
+
+// GetProUsersByUsernameOrEmail returns up to UsersPageSize users, ordered by id, whose username or email equals
+// any of values. GET /api/v1/users with an RSQL filter, needs the Read User privilege.
+// RSQL treats `*` as a wildcard, so the caller must re-check each match.
+func (c *Client) GetProUsersByUsernameOrEmail(ctx context.Context, values []string) ([]ProUser, annotations.Annotations, error) {
+ url, err := c.getUrl(proUsersUrlPath)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ terms := make([]string, 0, len(values)*2)
+ for _, value := range values {
+ quoted := `"` + rsqlValueEscaper.Replace(value) + `"`
+ terms = append(terms, "username=="+quoted, "email=="+quoted)
+ }
+ query := liburl.Values{}
+ query.Set("filter", strings.Join(terms, ","))
+ query.Set("page", "0")
+ query.Set("page-size", strconv.Itoa(UsersPageSize))
+ query.Set("sort", "id:asc")
+ url.RawQuery = query.Encode()
+
+ var response ProUsersResponse
+ annos, err := c.doRequest(ctx, url, &response)
+ if err != nil {
+ return nil, nil, proEndpointError(proUsersUrlPath, err)
+ }
+
+ return response.Results, annos, nil
+}
+
+// GetProAccounts returns one page of admin accounts ordered by id and the next page token, "" after the last page.
+// filter is an optional RSQL expression. GET /api/v1/accounts, needs the Read Accounts privilege.
+func (c *Client) GetProAccounts(ctx context.Context, filter, pageToken string, pageSize int) ([]ProAccount, string, annotations.Annotations, error) {
+ page, err := parsePageToken(pageToken)
+ if err != nil {
+ return nil, "", nil, err
+ }
+
+ url, err := c.getUrl(proAccountsUrlPath)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ query := liburl.Values{}
+ if filter != "" {
+ query.Set("filter", filter)
+ }
+ query.Set("page", strconv.Itoa(page))
+ query.Set("page-size", strconv.Itoa(pageSize))
+ query.Set("sort", "id:asc")
+ url.RawQuery = query.Encode()
+
+ var response ProAccountsResponse
+ annos, err := c.doRequest(ctx, url, &response)
+ if err != nil {
+ return nil, "", nil, proEndpointError(proAccountsUrlPath, err)
+ }
+
+ nextPage := nextPageToken(page, pageSize, len(response.Results), response.TotalCount)
+ return response.Results, nextPage, annos, nil
+}
+
+// GetSiteObjects returns one page of the site's objects of objectType (for example "User") and the next page
+// token, "" after the last page. GET /api/v1/sites/{id}/objects, needs the Read Sites privilege.
+// An unknown site answers an empty list, so a 404 means the instance lacks the endpoint.
+func (c *Client) GetSiteObjects(ctx context.Context, siteID int, objectType, pageToken string) ([]SiteObject, string, annotations.Annotations, error) {
+ page, err := parsePageToken(pageToken)
+ if err != nil {
+ return nil, "", nil, err
+ }
+
+ url, err := c.getUrl(fmt.Sprintf(siteObjectsUrlPath, siteID))
+ if err != nil {
+ return nil, "", nil, err
+ }
+ query := liburl.Values{}
+ query.Set("filter", `objectType=="`+rsqlValueEscaper.Replace(objectType)+`"`)
+ query.Set("page", strconv.Itoa(page))
+ query.Set("page-size", strconv.Itoa(SiteObjectsPageSize))
+ query.Set("sort", "objectId:asc")
+ url.RawQuery = query.Encode()
+
+ var objects []SiteObject
+ annos, err := c.doRequest(ctx, url, &objects)
+ if err != nil {
+ return nil, "", nil, proEndpointError(url.Path, err)
+ }
+
+ // The response has no total count: a full page may be followed by another.
+ if len(objects) == SiteObjectsPageSize {
+ return objects, strconv.Itoa(page + 1), annos, nil
+ }
+ return objects, "", annos, nil
}
// AddUserGroupMembers adds the given user IDs to a static Jamf user group via
// the additions verb (PUT .../usergroups/id/{id} ).
// Atomic — no read-modify-write. Returns a gRPC NotFound error if the group
// doesn't exist (surfaced via IsNotFoundError).
-func (c *Client) AddUserGroupMembers(ctx context.Context, groupID int, userIDs []int) error {
+func (c *Client) AddUserGroupMembers(ctx context.Context, groupID int, userIDs []int) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userGroupUrlPath, groupID))
if err != nil {
- return err
+ return nil, err
}
reqBody := UserGroupMemberMutation{Additions: &memberUsers{Users: baseTypesFromIDs(userIDs)}}
@@ -281,10 +397,10 @@ func (c *Client) AddUserGroupMembers(ctx context.Context, groupID int, userIDs [
// group via the deletions verb (). Atomic.
// Returns a gRPC NotFound error if the group doesn't exist (surfaced via
// IsNotFoundError).
-func (c *Client) RemoveUserGroupMembers(ctx context.Context, groupID int, userIDs []int) error {
+func (c *Client) RemoveUserGroupMembers(ctx context.Context, groupID int, userIDs []int) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userGroupUrlPath, groupID))
if err != nil {
- return err
+ return nil, err
}
reqBody := UserGroupMemberMutation{Deletions: &memberUsers{Users: baseTypesFromIDs(userIDs)}}
@@ -302,76 +418,28 @@ func baseTypesFromIDs(ids []int) []BaseType {
return out
}
-// GetUserGroups returns all Jamf user groups.
-func (c *Client) GetUserGroups(ctx context.Context) ([]*UserGroup, error) {
- var userGroups []*UserGroup
- baseUserGroup, err := c.getBaseUserGroups(ctx)
- if err != nil {
- return nil, err
- }
-
- for _, userGroup := range baseUserGroup {
- userGroupInfo, err := c.GetUserGroupDetails(ctx, userGroup.ID)
- if err != nil {
- return nil, err
- }
- userGroups = append(userGroups, userGroupInfo)
- }
-
- return userGroups, nil
-}
-
-// GetAccounts returns all Jamf accounts.
-// TODO(marcos): The Jamf API doesn't have pagination, but this method could
-// benefit from parallelization.
-func (c *Client) GetAccounts(ctx context.Context) ([]*UserAccount, []*Group, error) {
- var userAccounts []*UserAccount
- var groups []*Group
- baseAccounts, err := c.getBaseAccounts(ctx)
- if err != nil {
- return nil, nil, err
- }
-
- for _, user := range baseAccounts.Users {
- userAccountInfo, err := c.GetUserAccountDetails(ctx, user.ID)
- if err != nil {
- return nil, nil, err
- }
- userAccounts = append(userAccounts, userAccountInfo)
- }
-
- for _, group := range baseAccounts.Groups {
- groupInfo, err := c.GetGroupDetails(ctx, group.ID)
- if err != nil {
- return nil, nil, err
- }
- groups = append(groups, groupInfo)
- }
-
- return userAccounts, groups, nil
-}
-
// GetUserByName returns the Jamf user with the given username (login name).
-func (c *Client) GetUserByName(ctx context.Context, name string) (*User, error) {
+func (c *Client) GetUserByName(ctx context.Context, name string) (*User, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userNameUrlPath, liburl.PathEscape(name)))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.User, nil
+ return &target.User, annos, nil
}
// CreateUser creates a new Jamf user. Returns a gRPC AlreadyExists error
// (surfaced via IsAlreadyExistsError) if a user with this name already exists.
-func (c *Client) CreateUser(ctx context.Context, name, fullName, email string) error {
+func (c *Client) CreateUser(ctx context.Context, name, fullName, email string) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userUrlPath, newResourceID))
if err != nil {
- return err
+ return nil, err
}
reqBody := UserCreateBody{
@@ -385,37 +453,38 @@ func (c *Client) CreateUser(ctx context.Context, name, fullName, email string) e
// DeleteUser deletes the Jamf user with the given ID. Returns a gRPC NotFound
// error (surfaced via IsNotFoundError) if the user doesn't exist.
-func (c *Client) DeleteUser(ctx context.Context, userID int) error {
+func (c *Client) DeleteUser(ctx context.Context, userID int) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userUrlPath, userID))
if err != nil {
- return err
+ return nil, err
}
return c.doRequestWithMethod(ctx, http.MethodDelete, url, nil, nil)
}
// GetUserAccountByName returns the Jamf admin account with the given username.
-func (c *Client) GetUserAccountByName(ctx context.Context, name string) (*UserAccount, error) {
+func (c *Client) GetUserAccountByName(ctx context.Context, name string) (*UserAccount, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(accountUsernameUrlPath, liburl.PathEscape(name)))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target UserAccountResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target.UserAccount, nil
+ return &target.UserAccount, annos, nil
}
// CreateUserAccount creates a new Jamf Pro console admin account. Returns a
// gRPC AlreadyExists error (surfaced via IsAlreadyExistsError) if an account
// with this name already exists.
-func (c *Client) CreateUserAccount(ctx context.Context, account UserAccountCreateBody) error {
+func (c *Client) CreateUserAccount(ctx context.Context, account UserAccountCreateBody) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(accountUrlPath, newResourceID))
if err != nil {
- return err
+ return nil, err
}
return c.doRequestWithMethod(ctx, http.MethodPost, url, account, nil)
@@ -423,10 +492,10 @@ func (c *Client) CreateUserAccount(ctx context.Context, account UserAccountCreat
// DeleteUserAccount deletes the Jamf admin account with the given ID. Returns
// a gRPC NotFound error (surfaced via IsNotFoundError) if the account doesn't exist.
-func (c *Client) DeleteUserAccount(ctx context.Context, accountID int) error {
+func (c *Client) DeleteUserAccount(ctx context.Context, accountID int) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(accountUrlPath, accountID))
if err != nil {
- return err
+ return nil, err
}
return c.doRequestWithMethod(ctx, http.MethodDelete, url, nil, nil)
@@ -437,10 +506,10 @@ func (c *Client) DeleteUserAccount(ctx context.Context, accountID int) error {
// /, with root as the XML root element name
// ("account" or "group") — see privilegesUpdateBody for the body shape and
// the Custom-write caveat.
-func (c *Client) updatePrivileges(ctx context.Context, path, root string, id int, name, privilegeSet string, privileges *Privileges) error {
+func (c *Client) updatePrivileges(ctx context.Context, path, root string, id int, name, privilegeSet string, privileges *Privileges) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(path, id))
if err != nil {
- return err
+ return nil, err
}
reqBody := privilegesUpdateBody{
@@ -455,14 +524,14 @@ func (c *Client) updatePrivileges(ctx context.Context, path, root string, id int
// UpdateAccountPrivileges updates a Jamf admin account's privilege_set
// and/or individual privileges. See
// https://developer.jamf.com/jamf-pro/reference/updateaccountbyid.
-func (c *Client) UpdateAccountPrivileges(ctx context.Context, accountID int, name, privilegeSet string, privileges *Privileges) error {
+func (c *Client) UpdateAccountPrivileges(ctx context.Context, accountID int, name, privilegeSet string, privileges *Privileges) (annotations.Annotations, error) {
return c.updatePrivileges(ctx, accountUrlPath, "account", accountID, name, privilegeSet, privileges)
}
// UpdateGroupPrivileges is UpdateAccountPrivileges's counterpart for an
// admin account group. See
// https://developer.jamf.com/jamf-pro/reference/updategroupbyid.
-func (c *Client) UpdateGroupPrivileges(ctx context.Context, groupID int, name, privilegeSet string, privileges *Privileges) error {
+func (c *Client) UpdateGroupPrivileges(ctx context.Context, groupID int, name, privilegeSet string, privileges *Privileges) (annotations.Annotations, error) {
return c.updatePrivileges(ctx, groupUrlPath, "group", groupID, name, privilegeSet, privileges)
}
@@ -472,10 +541,10 @@ func (c *Client) UpdateGroupPrivileges(ctx context.Context, groupID int, name, p
// empty slice to clear the last member. Returns a gRPC NotFound error
// (IsNotFoundError) if the group doesn't exist. See
// https://developer.jamf.com/jamf-pro/reference/updategroupbyid.
-func (c *Client) UpdateGroupMembers(ctx context.Context, groupID int, name string, members []BaseType) error {
+func (c *Client) UpdateGroupMembers(ctx context.Context, groupID int, name string, members []BaseType) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(groupUrlPath, groupID))
if err != nil {
- return err
+ return nil, err
}
reqBody := GroupMembersUpdateBody{
@@ -492,10 +561,10 @@ func (c *Client) UpdateGroupMembers(ctx context.Context, groupID int, name strin
// Concurrency note: no locking/versioning guard — concurrent calls for the
// same userID can race and silently drop one side's change; serialize calls
// per user if that matters.
-func (c *Client) UpdateUserSites(ctx context.Context, userID int, sites UserSites) error {
+func (c *Client) UpdateUserSites(ctx context.Context, userID int, sites UserSites) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(userUrlPath, userID))
if err != nil {
- return err
+ return nil, err
}
ids := make([]int, 0, len(sites))
@@ -511,7 +580,7 @@ func (c *Client) doRequest(
ctx context.Context,
url *liburl.URL,
target interface{},
-) error {
+) (annotations.Annotations, error) {
return c.doRequestWithMethod(ctx, http.MethodGet, url, nil, target)
}
@@ -526,7 +595,7 @@ func (c *Client) doRequestWithMethod(
url *liburl.URL,
reqBody interface{},
target interface{},
-) error {
+) (annotations.Annotations, error) {
// The Classic API only accepts XML for POST/PUT request bodies (JSON is
// GET-response-only); see
// https://developer.jamf.com/jamf-pro/docs/getting-started-2.
@@ -543,7 +612,7 @@ func (c *Client) doRequestWithJSONMethod(
url *liburl.URL,
reqBody interface{},
target interface{},
-) error {
+) (annotations.Annotations, error) {
return c.doRequestWithMethodAndBodyEncoding(ctx, method, url, reqBody, target, uhttp.WithJSONBody)
}
@@ -561,12 +630,12 @@ func (c *Client) doRequestWithMethodAndBodyEncoding(
reqBody interface{},
target interface{},
bodyEncoding func(interface{}) uhttp.RequestOption,
-) error {
+) (annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
token, err := c.token()
if err != nil {
- return err
+ return nil, err
}
requestOpts := []uhttp.RequestOption{
@@ -587,12 +656,15 @@ func (c *Client) doRequestWithMethodAndBodyEncoding(
requestOpts...,
)
if err != nil {
- return err
+ return nil, err
}
- response, err := c.wrapper.Do(request)
+ var rateLimit v2.RateLimitDescription
+ var errorResponse ErrorResponse
+ doOptions := []uhttp.DoOption{uhttp.WithRatelimitData(&rateLimit), uhttp.WithErrorResponse(&errorResponse)}
+ response, err := c.wrapper.Do(request, doOptions...)
if response != nil && response.StatusCode == http.StatusUnauthorized && !missingPrivilege(response, url.Path) {
// Jamf can reject a token before it expires, so get a new one and retry once.
- response, err = c.retryWithNewToken(request, token.AccessToken)
+ response, err = c.retryWithNewToken(request, token.AccessToken, doOptions...)
}
if err != nil {
// uhttp already logs the underlying HTTP failure at the appropriate
@@ -602,28 +674,30 @@ func (c *Client) doRequestWithMethodAndBodyEncoding(
// Error would misrepresent expected idempotent outcomes as failures.
l.Debug("failed to perform request", zap.Error(err))
if missingPrivilege(response, url.Path) {
- return uhttp.WrapErrors(codes.PermissionDenied,
+ return nil, uhttp.WrapErrors(codes.PermissionDenied,
fmt.Sprintf("jamf rejected %s %s with 401: the account or API client lacks the Jamf privilege this operation needs", method, url.Path), err)
}
- return err
+ return nil, err
}
defer response.Body.Close()
+ annos := annotations.Annotations{}
+ annos.WithRateLimiting(&rateLimit)
if target == nil {
- return nil
+ return annos, nil
}
body, err := io.ReadAll(response.Body)
if err != nil {
- return fmt.Errorf("failed to read response body: %w", err)
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
err = json.Unmarshal(body, target)
if err != nil {
- return fmt.Errorf("failed to unmarshal json response: %w. status code: %d. content type: %q. body omitted (%d bytes)",
+ return nil, fmt.Errorf("failed to unmarshal json response: %w. status code: %d. content type: %q. body omitted (%d bytes)",
err, response.StatusCode, response.Header.Get("Content-Type"), len(body))
}
- return nil
+ return annos, nil
}
func (c *Client) token() (*oauth2.Token, error) {
@@ -647,7 +721,7 @@ func missingPrivilege(response *http.Response, path string) bool {
}
// retryWithNewToken drops the rejected token and resends the request with a new one.
-func (c *Client) retryWithNewToken(request *http.Request, rejected string) (*http.Response, error) {
+func (c *Client) retryWithNewToken(request *http.Request, rejected string, options ...uhttp.DoOption) (*http.Response, error) {
c.tokenSource.Renew(rejected)
token, err := c.token()
if err != nil {
@@ -662,19 +736,20 @@ func (c *Client) retryWithNewToken(request *http.Request, rejected string) (*htt
}
}
retry.Header.Set("Authorization", "Bearer "+token.AccessToken)
- return c.wrapper.Do(retry)
+ return c.wrapper.Do(retry, options...)
}
-func (c *Client) GetPrivileges(ctx context.Context) (*PrivilegesResponse, error) {
+func (c *Client) GetPrivileges(ctx context.Context) (*PrivilegesResponse, annotations.Annotations, error) {
url, err := c.getUrl(privilegesUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target PrivilegesResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, proEndpointError(privilegesUrlPath, err)
}
- return &target, nil
+ return &target, annos, nil
}
diff --git a/pkg/jamf/client_test.go b/pkg/jamf/client_test.go
index be76d62e..b1a7ddda 100644
--- a/pkg/jamf/client_test.go
+++ b/pkg/jamf/client_test.go
@@ -59,7 +59,7 @@ func TestUpdateAccountPrivileges_MinimalBody_NoPrivileges(t *testing.T) {
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Administrator", nil); err != nil {
+ if _, err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Administrator", nil); err != nil {
t.Fatalf("UpdateAccountPrivileges: %v", err)
}
@@ -87,7 +87,7 @@ func TestUpdateAccountPrivileges_ExplicitEmptyPrivileges(t *testing.T) {
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Custom", &Privileges{}); err != nil {
+ if _, err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Custom", &Privileges{}); err != nil {
t.Fatalf("UpdateAccountPrivileges: %v", err)
}
@@ -105,7 +105,7 @@ func TestUpdateAccountPrivileges_WithPrivileges_OnlyPopulatedCategories(t *testi
client := newTestClient(t, putOnlyHandler(t, &putBody))
privileges := &Privileges{JSSObjects: []string{"Read User", "Update User"}}
- if err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Custom", privileges); err != nil {
+ if _, err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "Custom", privileges); err != nil {
t.Fatalf("UpdateAccountPrivileges: %v", err)
}
@@ -123,7 +123,7 @@ func TestUpdateAccountPrivileges_NoPrivilegeSet_OmitsElement(t *testing.T) {
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "", nil); err != nil {
+ if _, err := client.UpdateAccountPrivileges(context.Background(), 42, "jappleseed", "", nil); err != nil {
t.Fatalf("UpdateAccountPrivileges: %v", err)
}
@@ -141,7 +141,7 @@ func TestUpdateGroupPrivileges_MinimalBody_NeverSendsMembersOrSite(t *testing.T)
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateGroupPrivileges(context.Background(), 7, "Test Group", "Auditor", nil); err != nil {
+ if _, err := client.UpdateGroupPrivileges(context.Background(), 7, "Test Group", "Auditor", nil); err != nil {
t.Fatalf("UpdateGroupPrivileges: %v", err)
}
@@ -163,7 +163,7 @@ func TestUpdateGroupPrivileges_ExplicitEmptyPrivileges(t *testing.T) {
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateGroupPrivileges(context.Background(), 7, "Test Group", "Custom", &Privileges{}); err != nil {
+ if _, err := client.UpdateGroupPrivileges(context.Background(), 7, "Test Group", "Custom", &Privileges{}); err != nil {
t.Fatalf("UpdateGroupPrivileges: %v", err)
}
@@ -183,7 +183,7 @@ func TestUpdateGroupMembers_NonEmpty_MinimalBody(t *testing.T) {
client := newTestClient(t, putOnlyHandler(t, &putBody))
members := []BaseType{{ID: 10, Name: "jsmith"}, {ID: 11, Name: "jdoe"}}
- if err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", members); err != nil {
+ if _, err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", members); err != nil {
t.Fatalf("UpdateGroupMembers: %v", err)
}
@@ -208,7 +208,7 @@ func TestUpdateGroupMembers_EmptySlice_EmitsExplicitEmptyMembersElement(t *testi
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", []BaseType{}); err != nil {
+ if _, err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", []BaseType{}); err != nil {
t.Fatalf("UpdateGroupMembers: %v", err)
}
@@ -227,7 +227,7 @@ func TestUpdateGroupMembers_NilSlice_AlsoEmitsExplicitEmptyMembersElement(t *tes
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", nil); err != nil {
+ if _, err := client.UpdateGroupMembers(context.Background(), 7, "Test Group", nil); err != nil {
t.Fatalf("UpdateGroupMembers: %v", err)
}
@@ -261,11 +261,11 @@ func TestDoRequest_GETsAreCachedByDefault(t *testing.T) {
var hits int
client := newTestClient(t, userDetailsHandler(t, &hits))
- first, err := client.GetUserDetails(context.Background(), 42)
+ first, _, err := client.GetUserDetails(context.Background(), 42)
if err != nil {
t.Fatalf("GetUserDetails: %v", err)
}
- second, err := client.GetUserDetails(context.Background(), 42)
+ second, _, err := client.GetUserDetails(context.Background(), 42)
if err != nil {
t.Fatalf("GetUserDetails: %v", err)
}
@@ -286,11 +286,11 @@ func TestDoRequest_WithFreshReads_BypassesCache(t *testing.T) {
client := newTestClient(t, userDetailsHandler(t, &hits))
ctx := WithFreshReads(context.Background())
- first, err := client.GetUserDetails(ctx, 42)
+ first, _, err := client.GetUserDetails(ctx, 42)
if err != nil {
t.Fatalf("GetUserDetails: %v", err)
}
- second, err := client.GetUserDetails(ctx, 42)
+ second, _, err := client.GetUserDetails(ctx, 42)
if err != nil {
t.Fatalf("GetUserDetails: %v", err)
}
@@ -310,7 +310,7 @@ func TestUpdateUserSites_MinimalBody(t *testing.T) {
client := newTestClient(t, putOnlyHandler(t, &putBody))
sites := UserSites{{ID: 1, Name: "HQ"}, {ID: 2, Name: "Remote"}}
- if err := client.UpdateUserSites(context.Background(), 42, sites); err != nil {
+ if _, err := client.UpdateUserSites(context.Background(), 42, sites); err != nil {
t.Fatalf("UpdateUserSites: %v", err)
}
@@ -328,7 +328,7 @@ func TestUpdateUserSites_EmptySlice_EmitsExplicitEmptySitesElement(t *testing.T)
var putBody []byte
client := newTestClient(t, putOnlyHandler(t, &putBody))
- if err := client.UpdateUserSites(context.Background(), 42, UserSites{}); err != nil {
+ if _, err := client.UpdateUserSites(context.Background(), 42, UserSites{}); err != nil {
t.Fatalf("UpdateUserSites: %v", err)
}
diff --git a/pkg/jamf/device_client.go b/pkg/jamf/device_client.go
index 199207ed..4573c9f6 100644
--- a/pkg/jamf/device_client.go
+++ b/pkg/jamf/device_client.go
@@ -6,6 +6,8 @@ import (
"net/http"
liburl "net/url"
"strconv"
+
+ "github.com/conductorone/baton-sdk/pkg/annotations"
)
const (
@@ -38,10 +40,10 @@ func (c *Client) GetComputersInventory(
page int,
pageSize int,
sections []string,
-) (*ComputersInventoryResponse, error) {
+) (*ComputersInventoryResponse, annotations.Annotations, error) {
url, err := c.getUrl(computersInventoryUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
query := liburl.Values{}
@@ -53,11 +55,12 @@ func (c *Client) GetComputersInventory(
url.RawQuery = query.Encode()
var target ComputersInventoryResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target, nil
+ return &target, annos, nil
}
// GetMobileDevices returns a single page of mobile devices from the v2 list
@@ -67,10 +70,10 @@ func (c *Client) GetMobileDevices(
ctx context.Context,
page int,
pageSize int,
-) (*MobileDevicesResponse, error) {
+) (*MobileDevicesResponse, annotations.Annotations, error) {
url, err := c.getUrl(mobileDevicesUrlPath)
if err != nil {
- return nil, err
+ return nil, nil, err
}
query := liburl.Values{}
@@ -79,28 +82,30 @@ func (c *Client) GetMobileDevices(
url.RawQuery = query.Encode()
var target MobileDevicesResponse
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target, nil
+ return &target, annos, nil
}
// GetComputerInventoryDetail fetches a computer's current inventory detail.
// Grant and Revoke read it first since the device may have been reassigned
// since last sync; Grant reports a displaced assignee via GrantReplaced.
-func (c *Client) GetComputerInventoryDetail(ctx context.Context, computerID string) (*ComputerInventory, error) {
+func (c *Client) GetComputerInventoryDetail(ctx context.Context, computerID string) (*ComputerInventory, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(computerInventoryDetailUrlPath, computerID))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target ComputerInventory
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target, nil
+ return &target, annos, nil
}
// GetMobileDeviceDetail fetches a mobile device's detail record, used by
@@ -108,27 +113,28 @@ func (c *Client) GetComputerInventoryDetail(ctx context.Context, computerID stri
// GetComputerInventoryDetail. Unlike the plain GET endpoint (flat top-level
// username), the detail response nests it under location.username; the two
// values agree.
-func (c *Client) GetMobileDeviceDetail(ctx context.Context, deviceID string) (*MobileDeviceDetail, error) {
+func (c *Client) GetMobileDeviceDetail(ctx context.Context, deviceID string) (*MobileDeviceDetail, annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(mobileDeviceDetailUrlPath, deviceID))
if err != nil {
- return nil, err
+ return nil, nil, err
}
var target MobileDeviceDetail
- if err := c.doRequest(ctx, url, &target); err != nil {
- return nil, err
+ annos, err := c.doRequest(ctx, url, &target)
+ if err != nil {
+ return nil, nil, err
}
- return &target, nil
+ return &target, annos, nil
}
// SetComputerAssignedUser sets (Grant) or clears (Revoke, zero-value fields)
// the assigned user on a computer's inventory record — see
// ComputerAssignedUserFields for the field semantics.
-func (c *Client) SetComputerAssignedUser(ctx context.Context, computerID string, fields ComputerAssignedUserFields) error {
+func (c *Client) SetComputerAssignedUser(ctx context.Context, computerID string, fields ComputerAssignedUserFields) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(computerInventoryDetailUrlPath, computerID))
if err != nil {
- return err
+ return nil, err
}
reqBody := computerAssignedUserUpdate{UserAndLocation: fields}
@@ -139,10 +145,10 @@ func (c *Client) SetComputerAssignedUser(ctx context.Context, computerID string,
// location.username — see MobileDeviceLocation for the field semantics. The
// Classic PUT is deliberately not used here: it writes the device's
// leftover location values back into the directory user's own record.
-func (c *Client) SetMobileDeviceAssignedUser(ctx context.Context, deviceID string, username string) error {
+func (c *Client) SetMobileDeviceAssignedUser(ctx context.Context, deviceID string, username string) (annotations.Annotations, error) {
url, err := c.getUrl(fmt.Sprintf(mobileDeviceUrlPath, deviceID))
if err != nil {
- return err
+ return nil, err
}
reqBody := mobileDeviceAssignedUserUpdate{Location: MobileDeviceLocation{Username: username}}
diff --git a/pkg/jamf/device_client_test.go b/pkg/jamf/device_client_test.go
index fdf6cbaa..b41d4d10 100644
--- a/pkg/jamf/device_client_test.go
+++ b/pkg/jamf/device_client_test.go
@@ -17,7 +17,7 @@ func TestGetComputersInventory_QueryEncoding(t *testing.T) {
_ = json.NewEncoder(w).Encode(ComputersInventoryResponse{TotalCount: 0})
})
- _, err := client.GetComputersInventory(context.Background(), 2, 50, ComputerInventorySections)
+ _, _, err := client.GetComputersInventory(context.Background(), 2, 50, ComputerInventorySections)
if err != nil {
t.Fatalf("GetComputersInventory: %v", err)
}
@@ -51,7 +51,7 @@ func TestGetMobileDevices_QueryEncoding(t *testing.T) {
_ = json.NewEncoder(w).Encode(MobileDevicesResponse{TotalCount: 0})
})
- _, err := client.GetMobileDevices(context.Background(), 3, 25)
+ _, _, err := client.GetMobileDevices(context.Background(), 3, 25)
if err != nil {
t.Fatalf("GetMobileDevices: %v", err)
}
@@ -76,7 +76,7 @@ func TestGetComputerInventoryDetail_IssuesGET(t *testing.T) {
_ = json.NewEncoder(w).Encode(ComputerInventory{ID: "17", UserAndLocation: &ComputerUserAndLocation{Username: "jappleseed"}})
})
- detail, err := client.GetComputerInventoryDetail(context.Background(), "17")
+ detail, _, err := client.GetComputerInventoryDetail(context.Background(), "17")
if err != nil {
t.Fatalf("GetComputerInventoryDetail: %v", err)
}
@@ -99,7 +99,7 @@ func TestGetMobileDeviceDetail_IssuesGET(t *testing.T) {
_ = json.NewEncoder(w).Encode(MobileDeviceDetail{ID: "3", Location: &MobileDeviceLocation{Username: "jappleseed"}})
})
- detail, err := client.GetMobileDeviceDetail(context.Background(), "3")
+ detail, _, err := client.GetMobileDeviceDetail(context.Background(), "3")
if err != nil {
t.Fatalf("GetMobileDeviceDetail: %v", err)
}
@@ -132,7 +132,7 @@ func TestSetComputerAssignedUser_PATCHBody_AllFiveKeysIncludingEmpty(t *testing.
w.WriteHeader(http.StatusNoContent)
})
- err := client.SetComputerAssignedUser(context.Background(), "17", ComputerAssignedUserFields{Username: "jappleseed"})
+ _, err := client.SetComputerAssignedUser(context.Background(), "17", ComputerAssignedUserFields{Username: "jappleseed"})
if err != nil {
t.Fatalf("SetComputerAssignedUser: %v", err)
}
@@ -157,7 +157,7 @@ func TestSetComputerAssignedUser_204NoBody_TreatedAsSuccess(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
})
- if err := client.SetComputerAssignedUser(context.Background(), "17", ComputerAssignedUserFields{}); err != nil {
+ if _, err := client.SetComputerAssignedUser(context.Background(), "17", ComputerAssignedUserFields{}); err != nil {
t.Fatalf("SetComputerAssignedUser: %v", err)
}
}
@@ -181,7 +181,7 @@ func TestSetMobileDeviceAssignedUser_PATCHBody_LocationUsernameOnly(t *testing.T
_, _ = w.Write([]byte(`{"id":"3"}`))
})
- if err := client.SetMobileDeviceAssignedUser(context.Background(), "3", "jappleseed"); err != nil {
+ if _, err := client.SetMobileDeviceAssignedUser(context.Background(), "3", "jappleseed"); err != nil {
t.Fatalf("SetMobileDeviceAssignedUser: %v", err)
}
if gotMethod != http.MethodPatch {
@@ -210,7 +210,7 @@ func TestSetMobileDeviceAssignedUser_EmptyUsername_ClearsExplicitly(t *testing.T
w.WriteHeader(http.StatusOK)
})
- if err := client.SetMobileDeviceAssignedUser(context.Background(), "3", ""); err != nil {
+ if _, err := client.SetMobileDeviceAssignedUser(context.Background(), "3", ""); err != nil {
t.Fatalf("SetMobileDeviceAssignedUser: %v", err)
}
want := `{"location":{"username":""}}` + "\n"
diff --git a/pkg/jamf/errors.go b/pkg/jamf/errors.go
index b286c615..374b4389 100644
--- a/pkg/jamf/errors.go
+++ b/pkg/jamf/errors.go
@@ -1,6 +1,9 @@
package jamf
import (
+ "fmt"
+
+ "github.com/conductorone/baton-sdk/pkg/uhttp"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -21,3 +24,12 @@ func IsNotFoundError(err error) bool {
func IsAlreadyExistsError(err error) bool {
return status.Code(err) == codes.AlreadyExists
}
+
+// proEndpointError turns a 404 from a Jamf Pro API list endpoint into FailedPrecondition:
+// the instance lacks the endpoint, and NotFound would make the SDK skip the sync action.
+func proEndpointError(path string, err error) error {
+ if IsNotFoundError(err) {
+ return uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("jamf pro api %s is not available on this Jamf Pro version", path), err)
+ }
+ return err
+}
diff --git a/pkg/jamf/helpers.go b/pkg/jamf/helpers.go
new file mode 100644
index 00000000..5d61db99
--- /dev/null
+++ b/pkg/jamf/helpers.go
@@ -0,0 +1,26 @@
+package jamf
+
+import (
+ "fmt"
+ "strconv"
+)
+
+// parsePageToken decodes a page token: the zero-indexed page number, "" meaning page 0.
+func parsePageToken(pageToken string) (int, error) {
+ if pageToken == "" {
+ return 0, nil
+ }
+ page, err := strconv.Atoi(pageToken)
+ if err != nil {
+ return 0, fmt.Errorf("invalid page token %q: %w", pageToken, err)
+ }
+ return page, nil
+}
+
+// nextPageToken returns the token of the page after page, or "" when page was the last one.
+func nextPageToken(page, pageSize, count, totalCount int) string {
+ if count == 0 || (page+1)*pageSize >= totalCount {
+ return ""
+ }
+ return strconv.Itoa(page + 1)
+}
diff --git a/pkg/jamf/models.go b/pkg/jamf/models.go
index ca31937c..1526b900 100644
--- a/pkg/jamf/models.go
+++ b/pkg/jamf/models.go
@@ -3,9 +3,31 @@ package jamf
import (
"encoding/json"
"encoding/xml"
+ "net/http"
"slices"
+ "strings"
)
+// ErrorResponse is the Jamf Pro API error body. The Classic API answers errors with an HTML page.
+type ErrorResponse struct {
+ HTTPStatus int `json:"httpStatus"`
+ Errors []struct {
+ Code string `json:"code"`
+ Description string `json:"description"`
+ } `json:"errors"`
+}
+
+func (e *ErrorResponse) Message() string {
+ if len(e.Errors) == 0 {
+ return http.StatusText(e.HTTPStatus)
+ }
+ messages := make([]string, 0, len(e.Errors))
+ for _, apiError := range e.Errors {
+ messages = append(messages, apiError.Code+": "+apiError.Description)
+ }
+ return strings.Join(messages, "; ")
+}
+
type BaseType struct {
ID int `json:"id" xml:"id"`
Name string `json:"name" xml:"name,omitempty"`
@@ -151,6 +173,14 @@ func (p *Privileges) Contains(privilege string) bool {
slices.Contains(p.CasperImaging, privilege)
}
+// All returns every privilege name of p.
+func (p *Privileges) All() []string {
+ if p == nil {
+ return nil
+ }
+ return slices.Concat(p.JSSObjects, p.JSSSettings, p.JSSActions, p.Recon, p.CasperAdmin, p.CasperRemote, p.CasperImaging)
+}
+
// MarshalXML emits only the privilege categories that are populated.
// encoding/xml's built-in "omitempty" does not apply to a nil/empty slice
// nested behind a ">"-chained struct tag (e.g. "jss_objects>privilege") — it
@@ -242,10 +272,6 @@ type TokenResponse struct {
Expires string `json:"expires"`
}
-type UsersResponse struct {
- Users []BaseType `json:"users"`
-}
-
type UserResponse struct {
User User `json:"user"`
}
@@ -378,3 +404,65 @@ type UserSitesUpdateBody struct {
XMLName xml.Name `xml:"user"`
Sites []BaseType `xml:"sites>site"`
}
+
+// ProUser is a user in the Jamf Pro API users list (GET /api/v1/users).
+type ProUser struct {
+ ID int `json:"id,string"`
+ Username string `json:"username"`
+ Realname string `json:"realname"`
+ Email string `json:"email"`
+}
+
+// User returns u in the Classic User shape.
+func (u ProUser) User() *User {
+ return &User{
+ BaseType: BaseType{ID: u.ID, Name: u.Username},
+ FullName: u.Realname,
+ Email: u.Email,
+ }
+}
+
+type ProUsersResponse struct {
+ TotalCount int `json:"totalCount"`
+ Results []ProUser `json:"results"`
+}
+
+// ProAccount is an admin account in the Jamf Pro API accounts list (GET /api/v1/accounts).
+type ProAccount struct {
+ ID int `json:"id,string"`
+ Username string `json:"username"`
+ Realname string `json:"realname"`
+ Email string `json:"email"`
+ AccountStatus string `json:"accountStatus"`
+ PrivilegeLevel string `json:"privilegeLevel"`
+}
+
+// proPrivilegeLevels maps the Pro API privilegeLevel enum to the Classic privilege_set names.
+var proPrivilegeLevels = map[string]string{
+ "ADMINISTRATOR": "Administrator",
+ "AUDITOR": "Auditor",
+ "ENROLLMENT": "Enrollment Only",
+ "CUSTOM": "Custom",
+}
+
+// UserAccount returns a in the Classic UserAccount shape.
+func (a ProAccount) UserAccount() *UserAccount {
+ return &UserAccount{
+ BaseType: BaseType{ID: a.ID, Name: a.Username},
+ FullName: a.Realname,
+ Email: a.Email,
+ Enabled: a.AccountStatus,
+ PrivilegeSet: proPrivilegeLevels[a.PrivilegeLevel],
+ }
+}
+
+type ProAccountsResponse struct {
+ TotalCount int `json:"totalCount"`
+ Results []ProAccount `json:"results"`
+}
+
+// SiteObject is an object (user, user group, ...) assigned to a site (GET /api/v1/sites/{id}/objects).
+type SiteObject struct {
+ ObjectType string `json:"objectType"`
+ ObjectID int `json:"objectId,string"`
+}
diff --git a/pkg/jamf/pro_client_test.go b/pkg/jamf/pro_client_test.go
new file mode 100644
index 00000000..83922693
--- /dev/null
+++ b/pkg/jamf/pro_client_test.go
@@ -0,0 +1,128 @@
+package jamf
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// An instance without a Pro list endpoint answers 404; NotFound would make the SDK skip the
+// List or Grants action, and C1 would delete what it synced before.
+func TestProListEndpoints_MissingEndpointIsFailedPrecondition(t *testing.T) {
+ client := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ })
+ ctx := context.Background()
+
+ calls := map[string]func() error{
+ "GetProUsers": func() error {
+ _, _, _, err := client.GetProUsers(ctx, "", UsersPageSize)
+ return err
+ },
+ "GetProUsersByUsernameOrEmail": func() error {
+ _, _, err := client.GetProUsersByUsernameOrEmail(ctx, []string{"jdoe"})
+ return err
+ },
+ "GetProAccounts": func() error {
+ _, _, _, err := client.GetProAccounts(ctx, "", "", AccountsPageSize)
+ return err
+ },
+ "GetSiteObjects": func() error {
+ _, _, _, err := client.GetSiteObjects(ctx, 3, "User", "")
+ return err
+ },
+ "GetPrivileges": func() error {
+ _, _, err := client.GetPrivileges(ctx)
+ return err
+ },
+ }
+ for name, call := range calls {
+ if code := status.Code(call()); code != codes.FailedPrecondition {
+ t.Errorf("%s: code = %v, want FailedPrecondition", name, code)
+ }
+ }
+}
+
+// jsonHandler serves body as JSON and records the request URL.
+func jsonHandler(t *testing.T, gotURL **url.URL, body string) http.HandlerFunc {
+ t.Helper()
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ *gotURL = r.URL
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write([]byte(body)); err != nil {
+ t.Fatalf("write response: %v", err)
+ }
+ }
+}
+
+func TestGetProUsers_SendsPagingAndSort_DecodesStringIDs(t *testing.T) {
+ var gotURL *url.URL
+ client := newTestClient(t, jsonHandler(t, &gotURL, `{
+ "totalCount": 2,
+ "results": [
+ {"id": "44", "username": "jappleseed", "realname": "John Appleseed", "email": "john@example.com"},
+ {"id": "45", "username": "jdoe", "realname": "", "email": ""}
+ ]
+ }`))
+
+ users, _, _, err := client.GetProUsers(context.Background(), "2", 50)
+ if err != nil {
+ t.Fatalf("GetProUsers: %v", err)
+ }
+
+ if gotURL.Path != "/api/v1/users" {
+ t.Errorf("path = %q, want /api/v1/users", gotURL.Path)
+ }
+ query := gotURL.Query()
+ for key, want := range map[string]string{"page": "2", "page-size": "50", "sort": "id:asc"} {
+ if got := query.Get(key); got != want {
+ t.Errorf("query %s = %q, want %q", key, got, want)
+ }
+ }
+
+ if len(users) != 2 {
+ t.Fatalf("got %d users, want 2", len(users))
+ }
+ if users[0].ID != 44 || users[1].ID != 45 {
+ t.Errorf("user ids = %d, %d, want 44, 45", users[0].ID, users[1].ID)
+ }
+}
+
+func TestGetProUsersByUsernameOrEmail_SendsFilter(t *testing.T) {
+ var gotURL *url.URL
+ client := newTestClient(t, jsonHandler(t, &gotURL, `{
+ "totalCount": 1,
+ "results": [{"id": "5", "username": "c@d", "realname": "", "email": "c@d"}]
+ }`))
+
+ users, _, err := client.GetProUsersByUsernameOrEmail(context.Background(), []string{`a"b`, "c@d"})
+ if err != nil {
+ t.Fatalf("GetProUsersByUsernameOrEmail: %v", err)
+ }
+
+ if gotURL.Path != "/api/v1/users" {
+ t.Errorf("path = %q, want /api/v1/users", gotURL.Path)
+ }
+ query := gotURL.Query()
+ for key, want := range map[string]string{
+ "filter": `username=="a\"b",email=="a\"b",username=="c@d",email=="c@d"`,
+ "page": "0",
+ "page-size": "1000",
+ "sort": "id:asc",
+ } {
+ if got := query.Get(key); got != want {
+ t.Errorf("query %s = %q, want %q", key, got, want)
+ }
+ }
+
+ if len(users) != 1 || users[0].ID != 5 || users[0].Username != "c@d" {
+ t.Errorf("users = %+v, want one user with ID 5 and username c@d", users)
+ }
+}
diff --git a/pkg/jamf/provisioning_test.go b/pkg/jamf/provisioning_test.go
index 40218ab2..ccfb3168 100644
--- a/pkg/jamf/provisioning_test.go
+++ b/pkg/jamf/provisioning_test.go
@@ -23,7 +23,7 @@ func TestAddUserGroupMembers_RequestWiring(t *testing.T) {
w.WriteHeader(http.StatusCreated)
})
- if err := client.AddUserGroupMembers(context.Background(), 301, []int{10, 11}); err != nil {
+ if _, err := client.AddUserGroupMembers(context.Background(), 301, []int{10, 11}); err != nil {
t.Fatalf("AddUserGroupMembers: %v", err)
}
if gotMethod != http.MethodPut {
@@ -54,7 +54,7 @@ func TestRemoveUserGroupMembers_RequestWiring(t *testing.T) {
w.WriteHeader(http.StatusCreated)
})
- if err := client.RemoveUserGroupMembers(context.Background(), 301, []int{10}); err != nil {
+ if _, err := client.RemoveUserGroupMembers(context.Background(), 301, []int{10}); err != nil {
t.Fatalf("RemoveUserGroupMembers: %v", err)
}
if gotMethod != http.MethodPut {
@@ -77,7 +77,7 @@ func TestRemoveUserGroupMembers_NotFound_SurfacesNotFoundError(t *testing.T) {
w.WriteHeader(http.StatusNotFound)
})
- err := client.RemoveUserGroupMembers(context.Background(), 999, []int{10})
+ _, err := client.RemoveUserGroupMembers(context.Background(), 999, []int{10})
if !IsNotFoundError(err) {
t.Errorf("want a NotFound error, got %v", err)
}
diff --git a/specs/openapi.json b/specs/openapi.json
index b62562f7..e76700b4 100644
--- a/specs/openapi.json
+++ b/specs/openapi.json
@@ -31,43 +31,6 @@
}
],
"paths": {
- "/JSSResource/users": {
- "get": {
- "operationId": "listUsers",
- "summary": "List users (id and name only)",
- "tags": [
- "classic"
- ],
- "security": [
- {
- "bearerAuth": []
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "users": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/BaseType"
- }
- }
- }
- }
- }
- }
- },
- "4XX": {
- "description": "Error (a 401 on a /JSSResource path with Jamf's HTML status page is a missing privilege, reported as PermissionDenied; any other 401 renews the token and retries once; 404 NotFound and 409 Conflict/AlreadyExists are treated as idempotent outcomes by callers)"
- }
- }
- }
- },
"/JSSResource/users/id/{id}": {
"get": {
"operationId": "getUserById",
@@ -893,6 +856,258 @@
}
}
},
+ "/api/v1/users": {
+ "get": {
+ "operationId": "listProUsers",
+ "summary": "List directory users (one page, Read User privilege)",
+ "description": "https://developer.jamf.com/jamf-pro/reference/get_v1-users. Used by User sync and by the managed device Grant owner lookup.",
+ "tags": [
+ "pro"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "description": "Zero-indexed page number.",
+ "schema": {
+ "type": "integer",
+ "default": 0,
+ "minimum": 0
+ }
+ },
+ {
+ "name": "page-size",
+ "in": "query",
+ "required": false,
+ "description": "Page size; documented maximum is 1000. The connector requests up to 1000.",
+ "schema": {
+ "type": "integer",
+ "default": 100,
+ "minimum": 1,
+ "maximum": 1000
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "required": false,
+ "description": "Sort criteria, property[:asc|desc], comma-separated. The connector sends id:asc.",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "id:asc"
+ ]
+ },
+ "style": "form",
+ "explode": false
+ },
+ {
+ "name": "filter",
+ "in": "query",
+ "required": false,
+ "description": "RSQL filter. The managed device Grant lookup sends username==\"\" and email==\"\" terms joined by commas; User sync sends none.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProUsersResponse"
+ }
+ }
+ }
+ },
+ "4XX": {
+ "description": "Error (401 triggers one token re-creation and retry; 404 means this Jamf Pro version lacks the endpoint, so the client reports FailedPrecondition; 409 Conflict/AlreadyExists is treated as an idempotent outcome by callers)"
+ }
+ }
+ }
+ },
+ "/api/v1/accounts": {
+ "get": {
+ "operationId": "listProAccounts",
+ "summary": "List admin accounts (one page, Read Accounts privilege)",
+ "description": "https://developer.jamf.com/jamf-pro/reference/get_v1-accounts. Used by User Account sync and by the admin account scan behind role and site grants.",
+ "tags": [
+ "pro"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "description": "Zero-indexed page number.",
+ "schema": {
+ "type": "integer",
+ "default": 0,
+ "minimum": 0
+ }
+ },
+ {
+ "name": "page-size",
+ "in": "query",
+ "required": false,
+ "description": "Page size; no maximum is documented, so the connector requests the documented default of 100.",
+ "schema": {
+ "type": "integer",
+ "default": 100
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "required": false,
+ "description": "Sort criteria, property[:asc|desc], comma-separated. The connector sends id:asc.",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "username:asc"
+ ]
+ },
+ "style": "form",
+ "explode": false
+ },
+ {
+ "name": "filter",
+ "in": "query",
+ "required": false,
+ "description": "RSQL filter. The connector sends privilegeLevel==CUSTOM or siteId==, or none.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProAccountsResponse"
+ }
+ }
+ }
+ },
+ "4XX": {
+ "description": "Error (401 triggers one token re-creation and retry; 404 means this Jamf Pro version lacks the endpoint, so the client reports FailedPrecondition; 409 Conflict/AlreadyExists is treated as an idempotent outcome by callers)"
+ }
+ }
+ }
+ },
+ "/api/v1/sites/{id}/objects": {
+ "get": {
+ "operationId": "listSiteObjects",
+ "summary": "List objects in a site (one page, Read Sites privilege)",
+ "description": "https://developer.jamf.com/jamf-pro/reference/get_v1-sites-id-objects. Returns a bare JSON array with no total count: the last page is the first one shorter than page-size.",
+ "tags": [
+ "pro"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Site ID.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "description": "Zero-indexed page number.",
+ "schema": {
+ "type": "integer",
+ "default": 0,
+ "minimum": 0
+ }
+ },
+ {
+ "name": "page-size",
+ "in": "query",
+ "required": false,
+ "description": "Page size; no maximum is documented. The connector requests 1000, which Jamf honors.",
+ "schema": {
+ "type": "integer",
+ "default": 100
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "required": false,
+ "description": "Sort criteria, property[:asc|desc] over objectType and objectId. The connector sends objectId:asc.",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "objectType:asc"
+ ]
+ },
+ "style": "form",
+ "explode": false
+ },
+ {
+ "name": "filter",
+ "in": "query",
+ "required": false,
+ "description": "RSQL filter over objectType and objectId; objectType values are case-insensitive. The connector always sends objectType==\"User\" or objectType==\"User Group\".",
+ "schema": {
+ "type": "string",
+ "default": "objectType==\"User\""
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SiteObject"
+ }
+ }
+ }
+ }
+ },
+ "4XX": {
+ "description": "Error (401 triggers one token re-creation and retry; 404 means this Jamf Pro version lacks the endpoint, so the client reports FailedPrecondition; 409 Conflict/AlreadyExists is treated as an idempotent outcome by callers)"
+ }
+ }
+ }
+ },
"/api/v4/computers-inventory": {
"get": {
"operationId": "listComputersInventory",
@@ -1995,6 +2210,145 @@
}
}
},
+ "ProUser": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Numeric directory user ID as a string; the connector parses it to an integer."
+ },
+ "username": {
+ "type": "string"
+ },
+ "realname": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "phone": {
+ "type": "string",
+ "nullable": true
+ },
+ "position": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "ProUsersResponse": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer"
+ },
+ "pageSize": {
+ "type": "integer"
+ },
+ "totalCount": {
+ "type": "integer"
+ },
+ "totalPages": {
+ "type": "integer"
+ },
+ "hasNext": {
+ "type": "boolean"
+ },
+ "hasPrevious": {
+ "type": "boolean"
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProUser"
+ }
+ }
+ }
+ },
+ "ProAccount": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Numeric admin account ID as a string; the connector parses it to an integer."
+ },
+ "username": {
+ "type": "string"
+ },
+ "realname": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "siteId": {
+ "type": "integer",
+ "description": "-1 when the account has no site."
+ },
+ "accessLevel": {
+ "type": "string",
+ "enum": [
+ "FullAccess",
+ "SiteAccess",
+ "GroupBasedAccess"
+ ]
+ },
+ "privilegeLevel": {
+ "type": "string",
+ "enum": [
+ "ADMINISTRATOR",
+ "AUDITOR",
+ "ENROLLMENT",
+ "CUSTOM"
+ ]
+ },
+ "accountStatus": {
+ "type": "string",
+ "enum": [
+ "Enabled",
+ "Disabled"
+ ]
+ },
+ "accountType": {
+ "type": "string",
+ "enum": [
+ "DEFAULT",
+ "FEDERATED",
+ "MIGRATED"
+ ]
+ }
+ }
+ },
+ "ProAccountsResponse": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProAccount"
+ }
+ }
+ }
+ },
+ "SiteObject": {
+ "type": "object",
+ "properties": {
+ "siteId": {
+ "type": "string"
+ },
+ "objectType": {
+ "type": "string",
+ "description": "For example User or User Group."
+ },
+ "objectId": {
+ "type": "string",
+ "description": "Numeric object ID as a string; the connector parses it to an integer."
+ }
+ }
+ },
"MobileDevice": {
"type": "object",
"properties": {
diff --git a/test-server/main.go b/test-server/main.go
index b9a1c76d..dffd882f 100644
--- a/test-server/main.go
+++ b/test-server/main.go
@@ -1,6 +1,7 @@
-// Package main implements a mock Jamf Pro server (Classic API + the token
-// endpoints /api/v1/auth/token and /api/v1/oauth/token) for testing the
-// baton-jamf connector without a real Jamf Pro tenant.
+// Package main implements a mock Jamf Pro server (Classic API, the
+// /api/v1/auth and /api/v1/oauth/token endpoints, and the Pro users, accounts
+// and site objects endpoints) for testing the baton-jamf connector without a
+// real Jamf Pro tenant.
//
// Environment Variables:
// - PORT: Server port (default: 8090, use 0 for a random port)
@@ -25,7 +26,7 @@
// --instance-url http://localhost:8090
//
// Seeded data:
-// - Sites: 1 "Headquarters", 2 "Remote"
+// - Sites: 1 "Headquarters", 2 "Remote", 3 "Large Site"
// - Directory users (trait: user): john.appleseed (site 1), jane.doe,
// carol.smith, dave.jones, eve.miller (site 2) — jane and dave overlap in
// usergroup-sales below.
@@ -37,7 +38,13 @@
// (admin2+admin3 — admin2 overlaps two groups), group-custom (admin3,
// custom privilege).
// - User groups: usergroup-eng (john+jane), usergroup-sales (jane+dave —
-// jane overlaps two groups), usergroup-empty (no members).
+// jane overlaps two groups), usergroup-empty (no members), usergroup-bulk
+// (id 304, site 3, 1,200 bulk users).
+// - Bulk directory users: 2,100 users (ids 1001-3100, bulk.userNNNN@example.com)
+// so /api/v1/users crosses its 1,000 page size; the first 1,100 belong to
+// site 3 (more than one 1,000-object page of /api/v1/sites/{id}/objects)
+// and the first 1,200 are the members of usergroup-bulk (more than one
+// 1,000-grant page).
// - Privileges (surfaced as custom roles): "Read Advanced Computer
// Searches", "Update Advanced Computer Searches", "Read User", "Update User".
//
@@ -45,6 +52,10 @@
// responses are JSON, but POST/PUT request bodies must be XML — a JSON POST
// body is rejected with 415, exactly like the real API.
//
+// The Pro endpoints GET /api/v1/users, GET /api/v1/accounts and GET
+// /api/v1/sites/{id}/objects are mocked in proapi.go from the same seeded
+// users, admin accounts and sites the Classic endpoints serve.
+//
// Managed devices (computers and mobile devices) are mocked in devices.go:
// the v4 computers-inventory list/detail/PATCH and the v2 mobile-devices
// list/detail/PATCH endpoints. Seeded devices: computer 1 (john.appleseed),
@@ -212,6 +223,7 @@ func newServer(username, password, token, clientID, clientSecret string) *server
userGroups: make(map[int]*jamf.UserGroup),
}
s.seedData()
+ s.seedBulkDirectory()
s.seedDevices()
return s
}
@@ -429,24 +441,6 @@ func (s *server) requireBearer(w http.ResponseWriter, r *http.Request) bool {
// ── Directory users (/JSSResource/users) ────────────────────────────────────
-// Doc URL: https://developer.jamf.com/jamf-pro/reference/findusers
-func (s *server) handleListUsers(w http.ResponseWriter, r *http.Request) {
- if !s.requireBearer(w, r) {
- return
- }
- if r.Method != http.MethodGet {
- writeJSONError(w, http.StatusMethodNotAllowed, "method must be GET")
- return
- }
- s.mu.Lock()
- minimal := make([]jamf.BaseType, 0, len(s.userList))
- for _, u := range s.userList {
- minimal = append(minimal, jamf.BaseType{ID: u.ID, Name: u.Name})
- }
- s.mu.Unlock()
- writeJSON(w, http.StatusOK, jamf.UsersResponse{Users: minimal})
-}
-
// handleUserByID dispatches GET (by numeric ID) / POST (create, ID must be
// 0) / DELETE (by numeric ID) on /JSSResource/users/id/{id}.
//
@@ -1409,8 +1403,10 @@ func run() error {
mux.HandleFunc("/api/v1/oauth/token", s.handleOAuthToken)
mux.HandleFunc("/api/v1/auth", s.handleTokenDetails)
mux.HandleFunc("/api/v1/api-role-privileges", s.handleListPrivileges)
+ mux.HandleFunc("/api/v1/users", s.handleProListUsers)
+ mux.HandleFunc("/api/v1/accounts", s.handleProListAccounts)
+ mux.HandleFunc("/api/v1/sites/{id}/objects", s.handleProSiteObjects)
- mux.HandleFunc("/JSSResource/users", s.handleListUsers)
mux.HandleFunc("/JSSResource/users/id/", s.handleUserByID)
mux.HandleFunc("/JSSResource/users/name/", s.handleUserByName)
diff --git a/test-server/proapi.go b/test-server/proapi.go
new file mode 100644
index 00000000..4aa79b1a
--- /dev/null
+++ b/test-server/proapi.go
@@ -0,0 +1,483 @@
+package main
+
+import (
+ "cmp"
+ "fmt"
+ "maps"
+ "net/http"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/conductorone/baton-jamf/pkg/jamf"
+)
+
+const (
+ // Documented page-size maximum of GET /api/v1/users.
+ proUsersMaxPageSize = 1000
+
+ siteIDLarge = 3
+ siteNameLarge = "Large Site"
+
+ userGroupIDBulk = 304
+ userGroupNameBulk = "usergroup-bulk"
+
+ // The bulk users cross the users page size; the bulk group and the large site
+ // hold more members than one grants page and one site objects page (1000 each).
+ bulkUserIDBase = 1000
+ bulkUserCount = 2100
+ bulkGroupMemberCount = 1200
+ bulkSiteUserCount = 1100
+
+ siteObjectTypeUser = "User"
+ siteObjectTypeUserGroup = "User Group"
+
+ // Values the Pro accounts schema documents for an unset field.
+ proAccessLevelDefault = "FullAccess"
+ proPrivilegeLevelDefault = "ADMINISTRATOR"
+ proAccountStatusDefault = "Enabled"
+
+ // Accounts without a site report -1.
+ proNoSiteID = -1
+)
+
+// proUser is one entry of GET /api/v1/users results.
+type proUser struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Realname string `json:"realname"`
+ Email string `json:"email"`
+ Phone *string `json:"phone"`
+ Position *string `json:"position"`
+ EnableCustomPhotoURL bool `json:"enableCustomPhotoUrl"`
+ CustomPhotoURL string `json:"customPhotoUrl"`
+ ManagedAppleID string `json:"managedAppleId"`
+}
+
+type proPagedUsers struct {
+ Page int `json:"page"`
+ PageSize int `json:"pageSize"`
+ TotalCount int `json:"totalCount"`
+ TotalPages int `json:"totalPages"`
+ HasNext bool `json:"hasNext"`
+ HasPrevious bool `json:"hasPrevious"`
+ Results []proUser `json:"results"`
+}
+
+// proAccount is one entry of GET /api/v1/accounts results.
+type proAccount struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Realname string `json:"realname"`
+ Email string `json:"email"`
+ SiteID int `json:"siteId"`
+ AccessLevel string `json:"accessLevel"`
+ PrivilegeLevel string `json:"privilegeLevel"`
+ AccountStatus string `json:"accountStatus"`
+ AccountType string `json:"accountType"`
+}
+
+type proAccountSearchResults struct {
+ TotalCount int `json:"totalCount"`
+ Results []proAccount `json:"results"`
+}
+
+// proSiteObject is one entry of the bare array GET /api/v1/sites/{id}/objects returns.
+type proSiteObject struct {
+ SiteID string `json:"siteId"`
+ ObjectType string `json:"objectType"`
+ ObjectID string `json:"objectId"`
+}
+
+// siteObject is an object assigned to a site, before it is written as a proSiteObject.
+type siteObject struct {
+ objectType string
+ objectID int
+}
+
+// seedBulkDirectory adds users past the users page size, a user group with more
+// members than one grants page, and a site with more users than one site objects page.
+func (s *server) seedBulkDirectory() {
+ largeSite := jamf.BaseType{ID: siteIDLarge, Name: siteNameLarge}
+ s.sites = append(s.sites, jamf.Site{BaseType: largeSite})
+
+ bulkUsers := make([]*jamf.User, 0, bulkUserCount)
+ for i := 1; i <= bulkUserCount; i++ {
+ user := &jamf.User{
+ BaseType: jamf.BaseType{ID: bulkUserIDBase + i, Name: fmt.Sprintf("bulk.user%04d", i)},
+ FullName: fmt.Sprintf("Bulk User %04d", i),
+ Email: fmt.Sprintf("bulk.user%04d@example.com", i),
+ }
+ if i <= bulkSiteUserCount {
+ user.Sites = jamf.UserSites{largeSite}
+ }
+ bulkUsers = append(bulkUsers, user)
+ s.users[user.ID] = user
+ s.userList = append(s.userList, user)
+ }
+ s.nextUserID = bulkUserIDBase + bulkUserCount
+
+ members := make([]jamf.User, 0, bulkGroupMemberCount)
+ for _, user := range bulkUsers[:bulkGroupMemberCount] {
+ members = append(members, *user)
+ }
+ bulkGroup := &jamf.UserGroup{
+ BaseType: jamf.BaseType{ID: userGroupIDBulk, Name: userGroupNameBulk},
+ Site: jamf.Site{BaseType: largeSite},
+ Users: members,
+ }
+ s.userGroups[bulkGroup.ID] = bulkGroup
+ s.userGroupList = append(s.userGroupList, bulkGroup)
+}
+
+// handleProListUsers serves one page of GET /api/v1/users. The only filter it
+// accepts is username=="" and email=="" terms joined by ",".
+//
+// Doc URL: https://developer.jamf.com/jamf-pro/reference/get_v1-users
+func (s *server) handleProListUsers(w http.ResponseWriter, r *http.Request) {
+ if !s.requireBearer(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ writeJSONError(w, http.StatusMethodNotAllowed, "method must be GET")
+ return
+ }
+
+ page, pageSize, ok := parsePaging(w, r)
+ if !ok {
+ return
+ }
+ if pageSize > proUsersMaxPageSize {
+ writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("page-size must be at most %d", proUsersMaxPageSize))
+ return
+ }
+ matches, err := parseUserFilter(r.URL.Query().Get("filter"))
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if _, ok := requestSort(w, r, "id:asc", "id:asc"); !ok {
+ return
+ }
+
+ s.mu.Lock()
+ users := slices.DeleteFunc(slices.Clone(s.userList), func(user *jamf.User) bool { return !matches(user) })
+ slices.SortFunc(users, func(left, right *jamf.User) int { return cmp.Compare(left.ID, right.ID) })
+ pageUsers := pageOf(users, page, pageSize)
+ results := make([]proUser, 0, len(pageUsers))
+ for _, user := range pageUsers {
+ results = append(results, proUser{
+ ID: strconv.Itoa(user.ID),
+ Username: user.Name,
+ Realname: user.FullName,
+ Email: user.PrimaryEmail(),
+ Phone: nullableString(user.PhoneNumber),
+ Position: nullableString(user.Position),
+ })
+ }
+ s.mu.Unlock()
+
+ totalPages := ceilDiv(len(users), pageSize)
+ writeJSON(w, http.StatusOK, proPagedUsers{
+ Page: page,
+ PageSize: pageSize,
+ TotalCount: len(users),
+ TotalPages: totalPages,
+ HasNext: page+1 < totalPages,
+ HasPrevious: page > 0,
+ Results: results,
+ })
+}
+
+// handleProListAccounts serves one page of GET /api/v1/accounts. The only filter
+// it accepts is a single privilegeLevel== or siteId== term.
+//
+// Doc URL: https://developer.jamf.com/jamf-pro/reference/get_v1-accounts
+func (s *server) handleProListAccounts(w http.ResponseWriter, r *http.Request) {
+ if !s.requireBearer(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ writeJSONError(w, http.StatusMethodNotAllowed, "method must be GET")
+ return
+ }
+
+ page, pageSize, ok := parsePaging(w, r)
+ if !ok {
+ return
+ }
+ matches, err := parseAccountsFilter(r.URL.Query().Get("filter"))
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ sortValue, ok := requestSort(w, r, "username:asc", "username:asc", "id:asc")
+ if !ok {
+ return
+ }
+
+ s.mu.Lock()
+ accounts := slices.DeleteFunc(slices.Clone(s.accountList), func(account *jamf.UserAccount) bool { return !matches(account) })
+ if sortValue == "id:asc" {
+ slices.SortFunc(accounts, func(left, right *jamf.UserAccount) int { return cmp.Compare(left.ID, right.ID) })
+ } else {
+ slices.SortFunc(accounts, func(left, right *jamf.UserAccount) int {
+ return cmp.Or(strings.Compare(left.Name, right.Name), cmp.Compare(left.ID, right.ID))
+ })
+ }
+ pageAccounts := pageOf(accounts, page, pageSize)
+ results := make([]proAccount, 0, len(pageAccounts))
+ for _, account := range pageAccounts {
+ results = append(results, proAccount{
+ ID: strconv.Itoa(account.ID),
+ Username: account.Name,
+ Realname: account.FullName,
+ Email: account.Email,
+ SiteID: proAccountSiteID(account),
+ AccessLevel: cmp.Or(proAccessLevels[account.AccessLevel], proAccessLevelDefault),
+ PrivilegeLevel: proAccountPrivilegeLevel(account),
+ AccountStatus: cmp.Or(account.Enabled, proAccountStatusDefault),
+ AccountType: "DEFAULT",
+ })
+ }
+ s.mu.Unlock()
+
+ writeJSON(w, http.StatusOK, proAccountSearchResults{TotalCount: len(accounts), Results: results})
+}
+
+// handleProSiteObjects serves one page of GET /api/v1/sites/{id}/objects as a bare
+// array. An unknown site answers an empty array, as Jamf does; no filter returns every object.
+//
+// Doc URL: https://developer.jamf.com/jamf-pro/reference/get_v1-sites-id-objects
+func (s *server) handleProSiteObjects(w http.ResponseWriter, r *http.Request) {
+ if !s.requireBearer(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ writeJSONError(w, http.StatusMethodNotAllowed, "method must be GET")
+ return
+ }
+
+ siteID, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid site id")
+ return
+ }
+ page, pageSize, ok := parsePaging(w, r)
+ if !ok {
+ return
+ }
+ sortValue, ok := requestSort(w, r, "objectType:asc", "objectType:asc", "objectId:asc")
+ if !ok {
+ return
+ }
+ matches, err := parseObjectTypeFilter(r.URL.Query().Get("filter"))
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ s.mu.Lock()
+ objects := s.siteObjectsLocked(siteID)
+ s.mu.Unlock()
+
+ objects = slices.DeleteFunc(objects, func(object siteObject) bool { return !matches(object) })
+ slices.SortFunc(objects, func(left, right siteObject) int {
+ if sortValue == "objectId:asc" {
+ return cmp.Or(cmp.Compare(left.objectID, right.objectID), strings.Compare(left.objectType, right.objectType))
+ }
+ return cmp.Or(strings.Compare(left.objectType, right.objectType), cmp.Compare(left.objectID, right.objectID))
+ })
+ pageObjects := pageOf(objects, page, pageSize)
+ results := make([]proSiteObject, 0, len(pageObjects))
+ for _, object := range pageObjects {
+ results = append(results, proSiteObject{
+ SiteID: strconv.Itoa(siteID),
+ ObjectType: object.objectType,
+ ObjectID: strconv.Itoa(object.objectID),
+ })
+ }
+ writeJSON(w, http.StatusOK, results)
+}
+
+// siteObjectsLocked returns the users of a site and the user groups it owns, none
+// for an unknown site. Assumes the caller already holds s.mu.
+func (s *server) siteObjectsLocked(siteID int) []siteObject {
+ if _, found := s.findSiteByIDLocked(siteID); !found {
+ return nil
+ }
+
+ var objects []siteObject
+ for _, user := range s.userList {
+ if containsBaseTypeID(user.Sites, siteID) {
+ objects = append(objects, siteObject{objectType: siteObjectTypeUser, objectID: user.ID})
+ }
+ }
+ for _, userGroup := range s.userGroupList {
+ if userGroup.Site.ID == siteID {
+ objects = append(objects, siteObject{objectType: siteObjectTypeUserGroup, objectID: userGroup.ID})
+ }
+ }
+ return objects
+}
+
+var (
+ userFilterTermPattern = regexp.MustCompile(`^(username|email)=="((?:[^"\\]|\\.)*)"(?:,|$)`)
+ userFilterUnescaper = strings.NewReplacer(`\"`, `"`, `\\`, `\`)
+)
+
+// userFilterTerm is one username=="" or email=="" term of a users filter.
+type userFilterTerm struct {
+ field string
+ value string
+}
+
+// parseUserFilter reads username=="" / email=="" terms joined by ",".
+// The predicate matches a user equal to any term, ignoring case; no filter matches every user.
+func parseUserFilter(filter string) (func(*jamf.User) bool, error) {
+ if filter == "" {
+ return func(*jamf.User) bool { return true }, nil
+ }
+
+ var terms []userFilterTerm
+ for rest := filter; rest != ""; {
+ match := userFilterTermPattern.FindStringSubmatch(rest)
+ if match == nil {
+ return nil, fmt.Errorf("unsupported filter %q: the mock only accepts username==\"\" and email==\"\" terms joined by \",\"", filter)
+ }
+ terms = append(terms, userFilterTerm{field: match[1], value: userFilterUnescaper.Replace(match[2])})
+ rest = rest[len(match[0]):]
+ }
+
+ return func(user *jamf.User) bool {
+ return slices.ContainsFunc(terms, func(term userFilterTerm) bool {
+ if term.field == "username" {
+ return strings.EqualFold(user.Name, term.value)
+ }
+ return strings.EqualFold(user.PrimaryEmail(), term.value)
+ })
+ }, nil
+}
+
+var (
+ // Classic access_level / privilege_set values and their Pro enums.
+ proAccessLevels = map[string]string{
+ accessLevelFullAccess: "FullAccess",
+ "Site Access": "SiteAccess",
+ accessLevelGroupAccess: "GroupBasedAccess",
+ }
+ proPrivilegeLevels = map[string]string{
+ privilegeSetAdministrator: "ADMINISTRATOR",
+ privilegeSetAuditor: "AUDITOR",
+ "Enrollment Only": "ENROLLMENT",
+ privilegeSetCustom: "CUSTOM",
+ }
+
+ accountsFilterPattern = regexp.MustCompile(`^\s*(privilegeLevel|siteId)\s*==\s*(?:"([^"]*)"|([^\s"]+))\s*$`)
+)
+
+// parseAccountsFilter reads privilegeLevel== (the enum may be quoted) or siteId==.
+// The predicate matches the accounts that satisfy it; no filter matches every account.
+func parseAccountsFilter(filter string) (func(*jamf.UserAccount) bool, error) {
+ if filter == "" {
+ return func(*jamf.UserAccount) bool { return true }, nil
+ }
+
+ match := accountsFilterPattern.FindStringSubmatch(filter)
+ if match == nil {
+ return nil, fmt.Errorf("unsupported filter %q: the mock only accepts privilegeLevel== and siteId==", filter)
+ }
+ field, value := match[1], cmp.Or(match[2], match[3])
+
+ if field == "siteId" {
+ siteID, err := strconv.Atoi(value)
+ if err != nil {
+ return nil, fmt.Errorf("invalid siteId %q: %w", value, err)
+ }
+ return func(account *jamf.UserAccount) bool { return proAccountSiteID(account) == siteID }, nil
+ }
+
+ if !slices.Contains(slices.Collect(maps.Values(proPrivilegeLevels)), value) {
+ return nil, fmt.Errorf("invalid privilegeLevel %q", value)
+ }
+ return func(account *jamf.UserAccount) bool { return proAccountPrivilegeLevel(account) == value }, nil
+}
+
+func proAccountSiteID(account *jamf.UserAccount) int {
+ if account.Site.ID == 0 {
+ return proNoSiteID
+ }
+ return account.Site.ID
+}
+
+func proAccountPrivilegeLevel(account *jamf.UserAccount) string {
+ return cmp.Or(proPrivilegeLevels[account.PrivilegeSet], proPrivilegeLevelDefault)
+}
+
+var (
+ // objectType values the sites objects filter accepts, matched case-insensitively.
+ siteObjectTypes = []string{
+ "Computer", "Peripheral", "Licensed Software", "Licensed Software Template", "Policy",
+ "macOS Configuration Profile", "Restricted Software", "Managed Preference Profile", "Computer Group",
+ "Mobile Device", "Apple TV", "Android Device", siteObjectTypeUserGroup, "iOS Configuration Profile",
+ "Mobile Device App", "E-book", "Mobile Device Group", "Classroom", "Advanced Computer Search",
+ "Advanced Mobile Search", "Advanced User Search", "Advanced User Content Search", "Computer Invitation",
+ "Mobile Device Invitation", "Mobile Device Enrollment Profile", "Device Enrollment Program Instance",
+ "Mobile Device Prestage", "Computer DEP Prestage", "Enrollment Customization", "VPP Location",
+ "VPP Subscription", "VPP Invitation", "VPP Assignment", siteObjectTypeUser, "Network Integration",
+ "Mac App", "App Installer", "Self Service Plugin", "Software Title", "Patch Software Title Summary",
+ "Patch Policy", "Patch Software Title Configuration", "Change Password", "Mobile Device Inventory",
+ "Computer Inventory", "Change Management", "Licensed Software License",
+ }
+
+ objectTypeFilterPattern = regexp.MustCompile(`^\s*objectType\s*==\s*(?:"([^"]*)"|([^\s"]+))\s*$`)
+)
+
+// parseObjectTypeFilter reads objectType=="". The predicate matches the objects of
+// that type; no filter matches every object.
+func parseObjectTypeFilter(filter string) (func(siteObject) bool, error) {
+ if filter == "" {
+ return func(siteObject) bool { return true }, nil
+ }
+
+ match := objectTypeFilterPattern.FindStringSubmatch(filter)
+ if match == nil {
+ return nil, fmt.Errorf("unsupported filter %q: the mock only accepts objectType==\"\"", filter)
+ }
+ value := cmp.Or(match[1], match[2])
+ index := slices.IndexFunc(siteObjectTypes, func(candidate string) bool { return strings.EqualFold(candidate, value) })
+ if index < 0 {
+ return nil, fmt.Errorf("invalid objectType %q", value)
+ }
+ objectType := siteObjectTypes[index]
+
+ return func(object siteObject) bool { return object.objectType == objectType }, nil
+}
+
+// requestSort returns the request's sort, or defaultSort when absent. A value
+// outside supported answers 400.
+func requestSort(w http.ResponseWriter, r *http.Request, defaultSort string, supported ...string) (string, bool) {
+ sortValue := cmp.Or(r.URL.Query().Get("sort"), defaultSort)
+ if !slices.Contains(supported, sortValue) {
+ writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("unsupported sort %q: the mock supports %s", sortValue, strings.Join(supported, ", ")))
+ return "", false
+ }
+ return sortValue, true
+}
+
+// nullableString maps an empty value to JSON null, as the nullable phone and position fields allow.
+func nullableString(value string) *string {
+ if value == "" {
+ return nil
+ }
+ return &value
+}
+
+func ceilDiv(total, pageSize int) int {
+ pages := total / pageSize
+ if total%pageSize != 0 {
+ pages++
+ }
+ return pages
+}