From 2e632aca3b49fc72ffb3a6b188ddbad33197aa8c Mon Sep 17 00:00:00 2001 From: Mateo Vespasiano Date: Fri, 30 Oct 2026 17:22:26 -0300 Subject: [PATCH 1/5] [CXP-1240] perf: cut sync request cost and fix sync paths - List users, admin accounts and site members through the Jamf Pro API, SDK-paged (1000 / 100 / 1000 per page) - Role grants type-scoped: privilege-set phase, then Custom accounts (10 per page, one detail read each) - Group grants from one group read: members, held roles and site; empty-member retry loop removed - Site grants in bag phases: users, user groups, admin accounts - StaticEntitlements + SkipEntitlements on group, userGroup, site and role; role descriptions on the resource - Grant pages capped at 1000 with a keyset cursor - Session store: privilege catalog read once; username/email -> user id written by user List, read by device Grants - Remove getUserIndex, the in-process caches and the per-item Classic detail reads - Device Grant resolves the replaced grant through a Pro users RSQL lookup - Pro API 404 -> FailedPrecondition (users, accounts, site objects, privileges) - userAccount display name uses FullName - CreateAccount/Delete input errors -> InvalidArgument; Delete refuses the connector's own account - Enable the session store; move resource types to resource_types.go - Mock: Pro users, accounts and site objects endpoints; README sync-cost section; openapi spec; capabilities regenerated - Tests for grant paging, role/site/group grant phases, device owner lookup, Pro client paging and 404 mapping --- README.md | 22 +- baton_capabilities.json | 25 +- cmd/baton-jamf/main.go | 1 + docs/connector.mdx | 6 +- docs/docs-info.md | 5 +- pkg/connector/admin_fixture_test.go | 202 ++++++++++++ pkg/connector/connector.go | 73 +---- pkg/connector/group.go | 122 ++++--- pkg/connector/group_test.go | 90 +++++- pkg/connector/helpers.go | 50 +-- pkg/connector/helpers_test.go | 99 ++++++ pkg/connector/managedDevice.go | 121 +++---- pkg/connector/managedDevice_test.go | 326 ++++++++++--------- pkg/connector/resource_types.go | 63 ++++ pkg/connector/role.go | 215 +++++++++---- pkg/connector/role_test.go | 309 +++++++----------- pkg/connector/session_cache.go | 77 +++++ pkg/connector/sessionstore_test.go | 105 ++++++ pkg/connector/site.go | 160 ++++----- pkg/connector/site_test.go | 88 ++--- pkg/connector/user.go | 43 ++- pkg/connector/userAccount.go | 43 ++- pkg/connector/userAccount_test.go | 33 ++ pkg/connector/userGroup.go | 55 ++-- pkg/connector/userGroup_test.go | 30 ++ pkg/jamf/client.go | 237 ++++++++------ pkg/jamf/errors.go | 12 + pkg/jamf/helpers.go | 26 ++ pkg/jamf/models.go | 74 ++++- pkg/jamf/pro_client_test.go | 128 ++++++++ specs/openapi.json | 428 +++++++++++++++++++++--- test-server/devices.go | 5 +- test-server/main.go | 44 ++- test-server/proapi.go | 483 ++++++++++++++++++++++++++++ 34 files changed, 2814 insertions(+), 986 deletions(-) create mode 100644 pkg/connector/admin_fixture_test.go create mode 100644 pkg/connector/helpers_test.go create mode 100644 pkg/connector/resource_types.go create mode 100644 pkg/connector/session_cache.go create mode 100644 pkg/connector/sessionstore_test.go create mode 100644 pkg/jamf/helpers.go create mode 100644 pkg/jamf/pro_client_test.go create mode 100644 test-server/proapi.go diff --git a/README.md b/README.md index 77201962..821631cc 100644 --- a/README.md +++ b/README.md @@ -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,7 +49,7 @@ 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. +Jamf reports a missing privilege differently depending on which API it uses: the Classic API (`/JSSResource` — User Groups, admin groups, Sites, and user and account details and writes) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the users and accounts lists, site objects, the roles catalog, Computers, Mobile Devices) with HTTP 403. The connector reports both as 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 @@ -100,6 +100,22 @@ 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 is reported as a permission error. +## Sync cost + +Requests per full sync: + +| Resource type | Requests | +|---|---| +| `user` | ⌈users / 1000⌉ | +| `userAccount` | ⌈accounts / 100⌉ | +| `group` | 1, plus per group one read per 1,000 grants (members, roles and site; at least one) | +| `userGroup` | 1, plus per user group one read per 1,000 members (at least one) | +| `site` | 1, plus per site ⌊users / 1000⌋ + 1, ⌊user groups / 1000⌋ + 1 and ⌈accounts / 100⌉ (at least one) | +| `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..3ede340d 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -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. +Jamf reports a missing privilege differently depending on which API it uses: the Classic API (`/JSSResource` — User Groups, admin groups, Sites, and user and account details and writes) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the users and accounts lists, site objects, the roles catalog, Computers, Mobile Devices) with HTTP 403. The connector reports both as a permission error. ## Configure the Jamf connector diff --git a/docs/docs-info.md b/docs/docs-info.md index 0e0fd54b..f7cf07a1 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -70,7 +70,7 @@ 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). ### Classic API content-type contract @@ -84,6 +84,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..07510bd8 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,12 @@ 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. + syncRoles bool + syncSites bool + syncUserAccounts bool + syncManagedDevices bool } // userProvisioningActive treats an empty accountProvisioningTarget as "user" @@ -121,7 +75,14 @@ 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, + 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) { @@ -148,11 +109,11 @@ func (j *Jamf) Validate(ctx context.Context) (annotations.Annotations, error) { 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), + roleBuilder(j.client, j.syncUserAccounts), managedDeviceBuilder(j.client), } } @@ -171,7 +132,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..36031f3c 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,102 @@ 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, 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 } -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, fmt.Errorf("jamf-connector: invalid group id %q: %w", resource.Id.Resource, err) + } + + grants, err := g.groupGrants(ctx, attrs.Session, resource, groupId) + if err != nil { + return nil, nil, fmt.Errorf("jamf-connector: list group %d grants: %w", groupId, err) + } + + page, nextCursor, err := pageGrants(grants, attrs.PageToken.Token) if err != nil { return nil, nil, err } + return page, &rs.SyncOpResults{NextPageToken: nextCursor}, nil +} - // 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) - if err != nil { - return nil, nil, err - } - if len(group.Members) > 0 { - break - } - count++ - time.Sleep(time.Second) +// groupGrants lists the grants of one group: its members, then the roles and the site it holds. +func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, groupId int) ([]*v2.Grant, error) { + group, err := g.client.GetGroupDetails(ctx, groupId) + if err != nil { + return nil, err + } + + 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)) } - for _, user := range group.Members { - userAccountDetails, err := g.client.GetUserAccountDetails(ctx, user.ID) + if g.syncRoles { + roles, err := g.heldRoles(ctx, store, group) if err != nil { - return nil, nil, err + return nil, err } - ur, err := userAccountResource(userAccountDetails, resource.Id) - if err != nil { - return nil, nil, err + for _, role := range roles { + roleID := &v2.ResourceId{ResourceType: resourceTypeRole.Id, Resource: role} + grants = append(grants, grant.NewGrant(&v2.Resource{Id: roleID}, memberEntitlement, resource.Id)) } + } - grant := grant.NewGrant(resource, memberEntitlement, ur.Id) - rv = append(rv, grant) + // 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)) } + return grants, nil +} - return rv, nil, nil +// 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, error) { + if slices.Contains(privilegeSets, group.PrivilegeSet) { + return []string{group.PrivilegeSet}, nil + } + if group.PrivilegeSet != privilegeSetCustom { + return nil, nil + } + + catalog, err := privilegeCatalog(ctx, store, g.client) + if err != nil { + return nil, err + } + return customPrivileges(group.PrivilegeSet, &group.Privileges, catalog), nil } // Grant adds principal (a Jamf admin account) to the static admin account @@ -247,9 +269,11 @@ func (g *groupResourceType) Revoke(ctx context.Context, gr *v2.Grant) (annotatio return nil, 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..7128bc28 100644 --- a/pkg/connector/group_test.go +++ b/pkg/connector/group_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "testing" @@ -144,7 +145,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 +170,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 +187,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 +203,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 +223,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 +245,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 +261,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 +275,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 +302,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 +319,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 +337,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 +354,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 +368,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 +391,66 @@ func groupRevokeGrantForPrincipal(t *testing.T, groupID int, principal *v2.Resou t.Helper() return grant.NewGrant(groupEntitlement(t, groupID).Resource, memberEntitlement, principal.Id) } + +func groupResourceForTest(t *testing.T, groupID int) *v2.Resource { + t.Helper() + return groupEntitlement(t, groupID).Resource +} + +// Member grants come from the group read alone: any per-member account read would 404 and fail the sync. +func TestGroupGrants_PagesPastOneThousandMembers(t *testing.T) { + const memberCount = 1500 + client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/JSSResource/accounts/userid/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + encodeJSON(t, w, groupDetailsResponse(5, "Test Group", idRange(1, memberCount))) + }) + builder := groupBuilder(client, false, false) + + grants, calls := collectGrants(t, builder.Grants, groupResourceForTest(t, 5), nil) + + 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) + } +} + +// 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, groupResourceForTest(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..e161d94b --- /dev/null +++ b/pkg/connector/helpers_test.go @@ -0,0 +1,99 @@ +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/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 +} + +// 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..27d129e2 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) @@ -205,18 +192,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 } @@ -460,20 +447,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. + 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 +508,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 +809,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..8cd18c65 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{ @@ -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) { @@ -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..82c76465 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, 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 @@ -84,87 +101,142 @@ func (o *roleResourceType) List(ctx context.Context, parentId *v2.ResourceId, at return rv, nil, 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 err error + switch phase.ResourceTypeID { + case rolePhasePrivilegeSets: + grants, nextPage, err = o.privilegeSetGrants(ctx, phase.Token) + case rolePhaseCustomAccounts: + grants, nextPage, 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}, 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, error) { + accounts, nextPage, err := o.client.GetProAccounts(ctx, "", page, jamf.AccountsPageSize) if err != nil { - return nil, nil, err + return 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, 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, error) { + catalog, err := privilegeCatalog(ctx, store, o.client) + if err != nil { + return nil, "", err + } + accounts, nextPage, err := o.client.GetProAccounts(ctx, "privilegeLevel==CUSTOM", page, customAccountsPageSize) + if err != nil { + return 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, 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, "", err } - - if isCustomPrivilege && matchesIndividualPrivilege(userAccount.PrivilegeSet, &userAccount.Privileges, resource.Id.Resource) { - privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id) - rv = append(rv, privilegeGrant) - continue + 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, nil +} + +// customPrivileges returns the catalog privileges held individually; only a Custom privilege set confers them. +func customPrivileges(privilegeSet string, privileges *jamf.Privileges, catalog []string) []string { + if privilegeSet != privilegeSetCustom { + return nil + } + var held []string + for _, privilege := range dedupeStrings(privileges.All()) { + if slices.Contains(catalog, privilege) { + 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 @@ -500,9 +572,10 @@ func (o *roleResourceType) revokeIndividualPrivilege(ctx context.Context, p *rol return nil, 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..a94d24d3 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 { @@ -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 { @@ -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) @@ -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) @@ -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) @@ -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..5bdff194 --- /dev/null +++ b/pkg/connector/session_cache.go @@ -0,0 +1,77 @@ +package connector + +import ( + "context" + "maps" + "slices" + "strconv" + "strings" + + "github.com/conductorone/baton-jamf/pkg/jamf" + "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:") + +// privilegeCatalog returns the individual privileges Jamf offers, read once per sync. +func privilegeCatalog(ctx context.Context, store sessions.SessionStore, client *jamf.Client) ([]string, error) { + catalog, found, err := session.GetJSON[[]string](ctx, store, privilegeCatalogKey) + if err != nil { + return nil, err + } + if found { + return catalog, nil + } + + response, err := client.GetPrivileges(ctx) + if err != nil { + return nil, err + } + if err := session.SetJSON(ctx, store, privilegeCatalogKey, response.Privileges); err != nil { + return nil, err + } + return response.Privileges, 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)) +} 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..29ae0562 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,6 +17,12 @@ import ( "google.golang.org/grpc/status" ) +const ( + sitePhaseUser = "user" + sitePhaseUserGroup = "userGroup" + sitePhaseAdmins = "admins" +) + type siteResourceType struct { resourceType *v2.ResourceType client *jamf.Client @@ -47,8 +54,7 @@ func (g *siteResourceType) List(ctx context.Context, parentId *v2.ResourceId, at 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 } @@ -58,108 +64,106 @@ func (g *siteResourceType) List(ctx context.Context, parentId *v2.ResourceId, at return rv, nil, 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 three phases: 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 - } - 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) - } - } + 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 { + bag.Push(pagination.PageState{ResourceTypeID: sitePhaseAdmins}) + bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUserGroup}) + bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUser}) } - userGroups, err := g.client.GetUserGroups(ctx) + phase := bag.Current() + var grants []*v2.Grant + var nextPage string + switch phase.ResourceTypeID { + case sitePhaseUser: + grants, nextPage, err = g.siteObjectGrants(ctx, resource, siteID, "User", resourceTypeUser, phase.Token) + case sitePhaseUserGroup: + grants, nextPage, err = g.siteObjectGrants(ctx, resource, siteID, "User Group", resourceTypeUserGroup, phase.Token) + case sitePhaseAdmins: + grants, nextPage, 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}, 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, error) { + objects, nextPage, err := g.client.GetSiteObjects(ctx, siteID, objectType, page) if err != nil { - return nil, nil, err + return 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, 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, error) { + accounts, nextPage, err := g.client.GetProAccounts(ctx, fmt.Sprintf("siteId==%d", siteID), page, jamf.AccountsPageSize) + if err != nil { + return 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, 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) diff --git a/pkg/connector/site_test.go b/pkg/connector/site_test.go index 31af512c..55e62277 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" ) @@ -294,57 +295,56 @@ 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) - if err != nil { - t.Fatalf("siteResource: %v", err) - } - site2, err := siteResource(&jamf.Site{BaseType: jamf.BaseType{ID: 2, Name: "Remote"}}, 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()) + 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, 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 len(grants1) != 1 { - t.Fatalf("want 1 grant for site 1, got %d", len(grants1)) + 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) } - grants2, _, err := s.Grants(context.Background(), site2, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("Grants(site 2): %v", err) + 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)) } - if len(grants2) != 1 { - t.Fatalf("want 1 grant for site 2, got %d", len(grants2)) + 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) } } diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 49ed12a0..fe14a8ec 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 } @@ -72,22 +74,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, 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}, nil } func (o *userResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { @@ -201,9 +209,9 @@ 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) @@ -216,9 +224,10 @@ func (o *userResourceType) Delete(ctx context.Context, resourceID *v2.ResourceId return nil, 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..15ac4f4e 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, ) @@ -131,6 +134,11 @@ func userAccountResource(account *jamf.UserAccount, parentResourceID *v2.Resourc "user_id": fmt.Sprintf("account:%d", account.ID), } + displayName := account.FullName + if displayName == "" { + displayName = account.Name + } + var resourceStatus v2.Status_ResourceStatus if account.Enabled == enabledValue { resourceStatus = v2.Status_RESOURCE_STATUS_ENABLED @@ -143,7 +151,7 @@ func userAccountResource(account *jamf.UserAccount, parentResourceID *v2.Resourc } ret, err := rs.NewUserResource( - account.Name, + displayName, resourceTypeUserAccount, account.ID, userTraitOptions, @@ -159,23 +167,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, 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}, nil } func (o *userAccountResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { @@ -289,7 +295,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 } @@ -349,9 +355,18 @@ 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, 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) 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..87db2425 100644 --- a/pkg/connector/userGroup.go +++ b/pkg/connector/userGroup.go @@ -48,64 +48,61 @@ 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, 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 } -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) } + // Classic returns every member at once, so each page re-reads the group. group, err := g.client.GetUserGroupDetails(ctx, userGroupId) 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(group.Users)) for _, user := range group.Users { - userCopy := user - ur, err := userResource(&userCopy, resource.Id) - if err != nil { - return nil, nil, err - } - - grant := grant.NewGrant(resource, memberEntitlement, ur.Id) - rv = append(rv, grant) + principal := &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: strconv.Itoa(user.ID)} + grants = append(grants, grant.NewGrant(resource, memberEntitlement, principal)) } - return rv, nil, nil + page, nextCursor, err := pageGrants(grants, attrs.PageToken.Token) + if err != nil { + return nil, nil, err + } + return page, &rs.SyncOpResults{NextPageToken: nextCursor}, nil } // isUserGroupMember reports whether userID is in users, the membership list diff --git a/pkg/connector/userGroup_test.go b/pkg/connector/userGroup_test.go index 2e9f8d8f..7333b6d0 100644 --- a/pkg/connector/userGroup_test.go +++ b/pkg/connector/userGroup_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "encoding/xml" + "fmt" "io" "net/http" + "strings" "testing" "github.com/conductorone/baton-jamf/pkg/jamf" @@ -435,3 +437,31 @@ 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)}) + } + client := newTestJamfClient(t, func(w http.ResponseWriter, _ *http.Request) { + 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) + } + + grants, calls := collectGrants(t, builder.Grants, resource, nil) + + 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) + } +} diff --git a/pkg/jamf/client.go b/pkg/jamf/client.go index af07d08d..6c488cbb 100644 --- a/pkg/jamf/client.go +++ b/pkg/jamf/client.go @@ -9,6 +9,7 @@ import ( "net" "net/http" liburl "net/url" + "strconv" "strings" "github.com/conductorone/baton-sdk/pkg/uhttp" @@ -20,20 +21,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/" @@ -126,20 +136,6 @@ func (c *Client) GetTokenDetails(ctx context.Context) (*TokenDetails, error) { return &target, nil } -func (c *Client) getBaseUsers(ctx context.Context) ([]BaseType, error) { - url, err := c.getUrl(usersUrlPath) - if err != nil { - return nil, err - } - - var target UsersResponse - if err := c.doRequest(ctx, url, &target); err != nil { - return nil, err - } - - return target.Users, nil -} - // GetUserDetails returns Jamf user details for a single directory user. func (c *Client) GetUserDetails(ctx context.Context, userId int) (*User, error) { url, err := c.getUrl(fmt.Sprintf(userUrlPath, userId)) @@ -155,7 +151,9 @@ func (c *Client) GetUserDetails(ctx context.Context, userId int) (*User, error) return &target.User, 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, error) { url, err := c.getUrl(accountsUrlPath) if err != nil { return nil, err @@ -184,7 +182,7 @@ func (c *Client) GetGroupDetails(ctx context.Context, groupId int) (*Group, erro return &target.Group, nil } -// GetUserAccountDetails returns Jamf user account details. +// 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, error) { url, err := c.getUrl(fmt.Sprintf(accountUrlPath, userId)) if err != nil { @@ -214,7 +212,9 @@ func (c *Client) GetSites(ctx context.Context) (*[]Site, error) { return &target.Sites, 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, error) { url, err := c.getUrl(userGroupsUrlPath) if err != nil { return nil, err @@ -244,23 +244,125 @@ func (c *Client) GetUserGroupDetails(ctx context.Context, userGroupId int) (*Use return &target.UserGroup, 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, error) { + page, err := parsePageToken(pageToken) + if err != nil { + return nil, "", err + } + + url, err := c.getUrl(proUsersUrlPath) + if err != nil { + return 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 + if err := c.doRequest(ctx, url, &response); err != nil { + return nil, "", proEndpointError(proUsersUrlPath, err) + } + + nextPage := nextPageToken(page, pageSize, len(response.Results), response.TotalCount) + return response.Results, nextPage, 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, error) { + url, err := c.getUrl(proUsersUrlPath) if err != nil { return nil, err } - for _, baseUser := range baseUsers { - user, err := c.GetUserDetails(ctx, baseUser.ID) - if err != nil { - return nil, err - } - users = append(users, user) + 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() - return users, nil + var response ProUsersResponse + if err := c.doRequest(ctx, url, &response); err != nil { + return nil, proEndpointError(proUsersUrlPath, err) + } + + return response.Results, 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, error) { + page, err := parsePageToken(pageToken) + if err != nil { + return nil, "", err + } + + url, err := c.getUrl(proAccountsUrlPath) + if err != nil { + return 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 + if err := c.doRequest(ctx, url, &response); err != nil { + return nil, "", proEndpointError(proAccountsUrlPath, err) + } + + nextPage := nextPageToken(page, pageSize, len(response.Results), response.TotalCount) + return response.Results, nextPage, 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, error) { + page, err := parsePageToken(pageToken) + if err != nil { + return nil, "", err + } + + url, err := c.getUrl(fmt.Sprintf(siteObjectsUrlPath, siteID)) + if err != nil { + return 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 + if err := c.doRequest(ctx, url, &objects); err != nil { + return 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), nil + } + return objects, "", nil } // AddUserGroupMembers adds the given user IDs to a static Jamf user group via @@ -302,55 +404,6 @@ 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) { url, err := c.getUrl(fmt.Sprintf(userNameUrlPath, liburl.PathEscape(name))) @@ -673,7 +726,7 @@ func (c *Client) GetPrivileges(ctx context.Context) (*PrivilegesResponse, error) var target PrivilegesResponse if err := c.doRequest(ctx, url, &target); err != nil { - return nil, err + return nil, proEndpointError(privilegesUrlPath, err) } return &target, nil 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..ed7f9895 100644 --- a/pkg/jamf/models.go +++ b/pkg/jamf/models.go @@ -151,6 +151,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 +250,6 @@ type TokenResponse struct { Expires string `json:"expires"` } -type UsersResponse struct { - Users []BaseType `json:"users"` -} - type UserResponse struct { User User `json:"user"` } @@ -378,3 +382,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..c8528119 --- /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/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/devices.go b/test-server/devices.go index f6c03292..36c58fe4 100644 --- a/test-server/devices.go +++ b/test-server/devices.go @@ -460,10 +460,11 @@ func parsePaging(w http.ResponseWriter, r *http.Request) (int, int, bool) { } func pageOf[T any](items []T, page, pageSize int) []T { - start := page * pageSize - if start >= len(items) { + // Compare page counts, not page*pageSize, so a huge page cannot overflow. + if pageSize <= 0 || page >= ceilDiv(len(items), pageSize) { return nil } + start := page * pageSize return items[start:min(start+pageSize, len(items))] } 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 +} From fe05279c7356e543863c2d38c1b89bc79334c1ad Mon Sep 17 00:00:00 2001 From: Mateo Vespasiano Date: Mon, 2 Nov 2026 14:02:39 -0300 Subject: [PATCH 2/5] - User group grants read the group once; later pages come from the session store (1000 member ids per key) - User display name falls back to the username, then the email (CXP-1286) - README, connector.mdx and docs-info state the Jamf Pro minimum versions - README sync cost: one read per user group - Tests: user group paging with one Jamf read and a store miss; group tests reuse groupPrincipal --- README.md | 4 +- docs/connector.mdx | 2 +- docs/docs-info.md | 2 + pkg/connector/group_test.go | 9 +---- pkg/connector/session_cache.go | 27 +++++++++++++ pkg/connector/user.go | 11 +++++- pkg/connector/userGroup.go | 68 +++++++++++++++++++++++++++++---- pkg/connector/userGroup_test.go | 29 +++++++++++++- 8 files changed, 132 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 821631cc..55b3ab38 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more the proje 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. Tested with Jamf Pro 11.32.1. ## Authentication @@ -109,7 +109,7 @@ Requests per full sync: | `user` | ⌈users / 1000⌉ | | `userAccount` | ⌈accounts / 100⌉ | | `group` | 1, plus per group one read per 1,000 grants (members, roles and site; at least one) | -| `userGroup` | 1, plus per user group one read per 1,000 members (at least one) | +| `userGroup` | 1, plus one read per user group | | `site` | 1, plus per site ⌊users / 1000⌋ + 1, ⌊user groups / 1000⌋ + 1 and ⌈accounts / 100⌉ (at least one) | | `role` | 1, plus ⌈accounts / 100⌉ + ⌈`Custom` accounts / 10⌉ and one read per `Custom` account, when `userAccount` is synced | | `managedDevice` | ⌈computers / 100⌉ + ⌈mobile devices / 100⌉ | diff --git a/docs/connector.mdx b/docs/connector.mdx index 3ede340d..c012662e 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -37,7 +37,7 @@ The Jamf connector syncs the following resources: **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 (`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. Tested with Jamf Pro 11.32.1. ## Gather Jamf credentials diff --git a/docs/docs-info.md b/docs/docs-info.md index f7cf07a1..d97939f3 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -72,6 +72,8 @@ None identified — every endpoint below is part of standard Jamf Pro, not gated - 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 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, `GET /api/v1/accounts` in 11.20.0, `GET /api/v1/sites/{id}/objects` in 11.2.0 and `GET /api/v1/api-role-privileges` in 10.49.0; an older instance fails the sync with `FailedPrecondition` naming the missing endpoint. Managed Devices (opt-in) need 11.30.0 for `/api/v4/computers-inventory`. + ### Classic API content-type contract The Jamf Pro Classic API only accepts **XML** for POST/PUT request bodies — JSON is supported for GET responses only. This is easy to get wrong (the SDK's default HTTP helper sends JSON); the client explicitly uses `uhttp.WithXMLBody` for `CreateUser`/`CreateUserAccount`. See https://developer.jamf.com/jamf-pro/docs/getting-started-2. diff --git a/pkg/connector/group_test.go b/pkg/connector/group_test.go index 7128bc28..ac60bfb6 100644 --- a/pkg/connector/group_test.go +++ b/pkg/connector/group_test.go @@ -392,11 +392,6 @@ func groupRevokeGrantForPrincipal(t *testing.T, groupID int, principal *v2.Resou return grant.NewGrant(groupEntitlement(t, groupID).Resource, memberEntitlement, principal.Id) } -func groupResourceForTest(t *testing.T, groupID int) *v2.Resource { - t.Helper() - return groupEntitlement(t, groupID).Resource -} - // Member grants come from the group read alone: any per-member account read would 404 and fail the sync. func TestGroupGrants_PagesPastOneThousandMembers(t *testing.T) { const memberCount = 1500 @@ -410,7 +405,7 @@ func TestGroupGrants_PagesPastOneThousandMembers(t *testing.T) { }) builder := groupBuilder(client, false, false) - grants, calls := collectGrants(t, builder.Grants, groupResourceForTest(t, 5), nil) + grants, calls := collectGrants(t, builder.Grants, groupPrincipal(t, 5), nil) if calls != 2 { t.Errorf("Grants calls = %d, want 2", calls) @@ -436,7 +431,7 @@ func TestGroupGrants_EmitsRoleAndSiteGrantsFromTheSameRead(t *testing.T) { fixture.catalog = []string{"Delete Users", "Read Computers", "Read Users"} builder := groupBuilder(fixture.client(), true, true) - grants, _ := collectGrants(t, builder.Grants, groupResourceForTest(t, customGroup.ID), newMemorySessionStore()) + grants, _ := collectGrants(t, builder.Grants, groupPrincipal(t, customGroup.ID), newMemorySessionStore()) // Each grant as ":|:". got := make([]string, 0, len(grants)) diff --git a/pkg/connector/session_cache.go b/pkg/connector/session_cache.go index 5bdff194..128fb8d4 100644 --- a/pkg/connector/session_cache.go +++ b/pkg/connector/session_cache.go @@ -2,6 +2,7 @@ package connector import ( "context" + "fmt" "maps" "slices" "strconv" @@ -16,6 +17,14 @@ const privilegeCatalogKey = "privilege_catalog" var userIDsNamespace = sessions.WithPrefix("user_ids:") +var userGroupMembersNamespace = sessions.WithPrefix("user_group_members:") + +// 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, error) { catalog, found, err := session.GetJSON[[]string](ctx, store, privilegeCatalogKey) @@ -75,3 +84,21 @@ func userIDsByLookupKey(users []jamf.ProUser) map[string]string { func userLookupKey(value string) string { return strings.ToLower(strings.TrimSpace(value)) } + +// cacheUserGroupMemberPages stores a user group's member pages from page first on, one key per page. +func cacheUserGroupMemberPages(ctx context.Context, store sessions.SessionStore, userGroupID int, pages []userGroupMemberPage, first int) error { + values := make(map[string]userGroupMemberPage, len(pages)-first) + for page := first; page < len(pages); page++ { + values[userGroupMemberPageKey(userGroupID, page)] = pages[page] + } + return session.SetManyJSON(ctx, store, values, userGroupMembersNamespace) +} + +// cachedUserGroupMemberPage returns one stored member page of a user group. +func cachedUserGroupMemberPage(ctx context.Context, store sessions.SessionStore, userGroupID, page int) (userGroupMemberPage, bool, error) { + return session.GetJSON[userGroupMemberPage](ctx, store, userGroupMemberPageKey(userGroupID, page), userGroupMembersNamespace) +} + +func userGroupMemberPageKey(userGroupID, page int) string { + return fmt.Sprintf("%d:%d", userGroupID, page) +} diff --git a/pkg/connector/user.go b/pkg/connector/user.go index fe14a8ec..eb11b555 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -57,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, diff --git a/pkg/connector/userGroup.go b/pkg/connector/userGroup.go index 87db2425..d946a116 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" ) @@ -85,24 +86,75 @@ func (g *userGroupResourceType) Grants(ctx context.Context, resource *v2.Resourc if err != nil { return nil, nil, fmt.Errorf("jamf-connector: invalid user group id %q: %w", resource.Id.Resource, err) } + page := 0 + if attrs.PageToken.Token != "" { + page, 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) + } + } - // Classic returns every member at once, so each page re-reads the group. - group, err := g.client.GetUserGroupDetails(ctx, userGroupId) + members, err := g.memberPage(ctx, attrs.Session, userGroupId, page) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list user group %d grants: %w", userGroupId, err) } - grants := make([]*v2.Grant, 0, len(group.Users)) - for _, user := range group.Users { - principal := &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: strconv.Itoa(user.ID)} + 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)) } - page, nextCursor, err := pageGrants(grants, attrs.PageToken.Token) + var nextPage string + if !members.Last { + nextPage = strconv.Itoa(page + 1) + } + return grants, &rs.SyncOpResults{NextPageToken: nextPage}, nil +} + +// memberPage returns one grants page of the user group's members. Classic returns every member at once, +// so a read from Jamf stores the pages after this one; a page missing from the store is read again. +func (g *userGroupResourceType) memberPage(ctx context.Context, store sessions.SessionStore, userGroupId, page int) (userGroupMemberPage, error) { + if page > 0 { + stored, found, err := cachedUserGroupMemberPage(ctx, store, userGroupId, page) + if err != nil { + return userGroupMemberPage{}, err + } + if found { + return stored, nil + } + } + + group, err := g.client.GetUserGroupDetails(ctx, userGroupId) if err != nil { - return nil, nil, err + return userGroupMemberPage{}, err + } + pages := memberPages(group.Users) + if page >= len(pages) { + return userGroupMemberPage{Last: true}, nil + } + if err := cacheUserGroupMemberPages(ctx, store, userGroupId, pages, page+1); err != nil { + return userGroupMemberPage{}, err + } + return pages[page], nil +} + +// memberPages splits the members into grants pages of sorted user ids; the last one is marked. +func memberPages(users []jamf.User) []userGroupMemberPage { + userIDs := make([]int, 0, len(users)) + for _, user := range users { + 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 page, &rs.SyncOpResults{NextPageToken: nextCursor}, nil + return pages } // isUserGroupMember reports whether userID is in users, the membership list diff --git a/pkg/connector/userGroup_test.go b/pkg/connector/userGroup_test.go index 7333b6d0..5fb3b9b1 100644 --- a/pkg/connector/userGroup_test.go +++ b/pkg/connector/userGroup_test.go @@ -8,10 +8,12 @@ import ( "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" @@ -445,7 +447,9 @@ func TestUserGroupGrants_PagesPastOneThousandMembers(t *testing.T) { 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}}) }) @@ -455,7 +459,12 @@ func TestUserGroupGrants_PagesPastOneThousandMembers(t *testing.T) { t.Fatalf("userGroupResource: %v", err) } - grants, calls := collectGrants(t, builder.Grants, resource, nil) + // 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) @@ -464,4 +473,22 @@ func TestUserGroupGrants_PagesPastOneThousandMembers(t *testing.T) { 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. + secondPage, results, err := freshGrants(context.Background(), resource, rs.SyncOpAttrs{ + Session: newMemorySessionStore(), + PageToken: pagination.Token{Token: "1"}, + }) + 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 := jamfReads.Load(); reads != 2 { + t.Errorf("Jamf reads after the store miss = %d, want 2", reads) + } } From 5164e55709a9202b1a2b2f2dac3a3e63292a85c3 Mon Sep 17 00:00:00 2001 From: Mateo Vespasiano Date: Tue, 3 Nov 2026 12:54:02 -0300 Subject: [PATCH 3/5] - User group member pages are keyed by the last emitted user id; a re-read after a store miss keeps only the ids above it - Site grants only read the users, user groups and admin accounts the sync includes - Every client method returns Jamf's rate-limit annotations; List and Grants pass them to the SDK, and Grant/Revoke/CreateAccount/Delete return the write's - Docs: empty-member guard reworded; Delete refuses the connector's own account; site sync cost per synced type - Tests: user group store miss after a member leaves; site sync without userAccount, or without any of its principal types, reads nothing it doesn't need --- README.md | 12 +- docs/connector.mdx | 4 +- docs/docs-info.md | 2 +- pkg/connector/connector.go | 10 +- pkg/connector/group.go | 44 ++--- pkg/connector/helpers_test.go | 6 + pkg/connector/managedDevice.go | 61 ++++--- pkg/connector/managedDevice_test.go | 32 ++-- pkg/connector/role.go | 106 +++++++----- pkg/connector/role_test.go | 20 +-- pkg/connector/session_cache.go | 36 ++-- pkg/connector/site.go | 83 +++++---- pkg/connector/site_test.go | 77 +++++++-- pkg/connector/user.go | 16 +- pkg/connector/userAccount.go | 20 +-- pkg/connector/userGroup.go | 75 +++++---- pkg/connector/userGroup_test.go | 19 ++- pkg/jamf/auth_test.go | 34 ++-- pkg/jamf/client.go | 250 +++++++++++++++------------- pkg/jamf/client_test.go | 30 ++-- pkg/jamf/device_client.go | 54 +++--- pkg/jamf/device_client_test.go | 16 +- pkg/jamf/pro_client_test.go | 14 +- pkg/jamf/provisioning_test.go | 6 +- test-server/devices.go | 5 +- 25 files changed, 587 insertions(+), 445 deletions(-) diff --git a/README.md b/README.md index 55b3ab38..7f13733c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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. @@ -54,10 +54,10 @@ Jamf reports a missing privilege differently depending on which API it uses: the 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. The write replaces the whole member list, so the +connector never writes over an empty read or takes it to mean the member was +removed: if that read were incomplete, the write would drop every real member. +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 @@ -110,7 +110,7 @@ Requests per full sync: | `userAccount` | ⌈accounts / 100⌉ | | `group` | 1, plus per group one read per 1,000 grants (members, roles and site; at least one) | | `userGroup` | 1, plus one read per user group | -| `site` | 1, plus per site ⌊users / 1000⌋ + 1, ⌊user groups / 1000⌋ + 1 and ⌈accounts / 100⌉ (at least one) | +| `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⌉ | diff --git a/docs/connector.mdx b/docs/connector.mdx index c012662e..065015e7 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -26,7 +26,7 @@ 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). - **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. @@ -37,7 +37,7 @@ The Jamf connector syncs the following resources: **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. -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. Tested with Jamf Pro 11.32.1. +Requires Jamf Pro 11.26.0 or later (Managed Devices: 11.30.0). Tested with Jamf Pro 11.32.1. ## Gather Jamf credentials diff --git a/docs/docs-info.md b/docs/docs-info.md index d97939f3..dd84333b 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` | ✅ | - | - | diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 07510bd8..9332138f 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -26,6 +26,8 @@ type Jamf struct { 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 @@ -78,6 +80,8 @@ func New(ctx context.Context, cc *cfg.Jamf, opts *cli.ConnectorOpts) (connectorb 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), @@ -95,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) } @@ -103,7 +107,7 @@ 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 { @@ -112,7 +116,7 @@ func (j *Jamf) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceS groupBuilder(j.client, j.syncRoles, j.syncSites), j.userAccountSyncer(), userGroupBuilder(j.client), - siteBuilder(j.client), + siteBuilder(j.client, j.syncUsers, j.syncUserGroups, j.syncUserAccounts), roleBuilder(j.client, j.syncUserAccounts), managedDeviceBuilder(j.client), } diff --git a/pkg/connector/group.go b/pkg/connector/group.go index 36031f3c..3ff5deb2 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -55,7 +55,7 @@ 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) { - accounts, err := g.client.GetBaseAccounts(ctx) + accounts, annos, err := g.client.GetBaseAccounts(ctx) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: failed to list accounts: %w", err) } @@ -68,7 +68,7 @@ func (g *groupResourceType) List(ctx context.Context, parentId *v2.ResourceId, a } rv = append(rv, resource) } - return rv, nil, nil + return rv, &rs.SyncOpResults{Annotations: annos}, nil } // Entitlements is unused: StaticEntitlements declares the member entitlement. @@ -93,7 +93,7 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, a return nil, nil, fmt.Errorf("jamf-connector: invalid group id %q: %w", resource.Id.Resource, err) } - grants, err := g.groupGrants(ctx, attrs.Session, resource, groupId) + grants, annos, err := g.groupGrants(ctx, attrs.Session, resource, groupId) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list group %d grants: %w", groupId, err) } @@ -102,14 +102,14 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, a if err != nil { return nil, nil, err } - return page, &rs.SyncOpResults{NextPageToken: nextCursor}, nil + return page, &rs.SyncOpResults{NextPageToken: nextCursor, Annotations: annos}, nil } // groupGrants lists the grants of one group: its members, then the roles and the site it holds. -func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, groupId int) ([]*v2.Grant, error) { - group, err := g.client.GetGroupDetails(ctx, groupId) +func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, groupId int) ([]*v2.Grant, annotations.Annotations, error) { + group, annos, err := g.client.GetGroupDetails(ctx, groupId) if err != nil { - return nil, err + return nil, nil, err } grants := make([]*v2.Grant, 0, len(group.Members)) @@ -121,7 +121,7 @@ func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.Sess if g.syncRoles { roles, err := g.heldRoles(ctx, store, group) if err != nil { - return nil, err + return nil, nil, err } for _, role := range roles { roleID := &v2.ResourceId{ResourceType: resourceTypeRole.Id, Resource: role} @@ -134,7 +134,7 @@ func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.Sess siteID := &v2.ResourceId{ResourceType: resourceTypeSite.Id, Resource: strconv.Itoa(group.Site.ID)} grants = append(grants, grant.NewGrant(&v2.Resource{Id: siteID}, memberEntitlement, resource.Id)) } - return grants, nil + return grants, annos, nil } // heldRoles returns the roles a group holds: its privilege set, or the individual privileges of a Custom group. @@ -146,7 +146,8 @@ func (g *groupResourceType) heldRoles(ctx context.Context, store sessions.Sessio return nil, nil } - catalog, err := privilegeCatalog(ctx, store, g.client) + // The group read supplies the annotations. + catalog, _, err := privilegeCatalog(ctx, store, g.client) if err != nil { return nil, err } @@ -169,7 +170,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) @@ -186,14 +187,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) @@ -206,11 +209,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 @@ -227,7 +231,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 @@ -259,14 +263,16 @@ 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, syncRoles, syncSites bool) *groupResourceType { diff --git a/pkg/connector/helpers_test.go b/pkg/connector/helpers_test.go index e161d94b..29d771eb 100644 --- a/pkg/connector/helpers_test.go +++ b/pkg/connector/helpers_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -78,6 +79,11 @@ func idRange(first, count int) []int { 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) diff --git a/pkg/connector/managedDevice.go b/pkg/connector/managedDevice.go index 27d129e2..cf23d8c0 100644 --- a/pkg/connector/managedDevice.go +++ b/pkg/connector/managedDevice.go @@ -109,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) @@ -138,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) @@ -169,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 @@ -235,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) @@ -248,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 { @@ -269,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}) } } @@ -300,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. @@ -314,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 @@ -325,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 @@ -336,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 { @@ -360,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 @@ -407,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 } @@ -454,8 +461,8 @@ func (d *managedDeviceResourceType) replacedGrantID(ctx context.Context, owner a return "", false, nil } - // Grant has no session store, so ask Jamf. - users, err := d.client.GetProUsersByUsernameOrEmail(ctx, keys) + // 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 } diff --git a/pkg/connector/managedDevice_test.go b/pkg/connector/managedDevice_test.go index 8cd18c65..9c6c0a0e 100644 --- a/pkg/connector/managedDevice_test.go +++ b/pkg/connector/managedDevice_test.go @@ -529,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") @@ -568,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") @@ -595,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) @@ -663,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) @@ -983,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) @@ -1081,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) @@ -1109,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") @@ -1148,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") diff --git a/pkg/connector/role.go b/pkg/connector/role.go index 82c76465..db770fa3 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -85,7 +85,7 @@ func (o *roleResourceType) List(ctx context.Context, parentId *v2.ResourceId, at } // Also stores the catalog the grants read, so Jamf is asked once per sync. - privileges, err := privilegeCatalog(ctx, attrs.Session, o.client) + privileges, annos, err := privilegeCatalog(ctx, attrs.Session, o.client) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list privileges: %w", err) } @@ -98,7 +98,7 @@ 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 is unused: StaticEntitlements declares the member entitlement. @@ -151,12 +151,13 @@ func (o *roleResourceType) GrantsForResourceType(ctx context.Context, _ string, phase := bag.Current() var grants []*v2.Grant var nextPage string + var annos annotations.Annotations var err error switch phase.ResourceTypeID { case rolePhasePrivilegeSets: - grants, nextPage, err = o.privilegeSetGrants(ctx, phase.Token) + grants, nextPage, annos, err = o.privilegeSetGrants(ctx, phase.Token) case rolePhaseCustomAccounts: - grants, nextPage, err = o.customPrivilegeGrants(ctx, attrs.Session, phase.Token) + grants, nextPage, annos, err = o.customPrivilegeGrants(ctx, attrs.Session, phase.Token) default: err = fmt.Errorf("unknown phase %q", phase.ResourceTypeID) } @@ -168,14 +169,14 @@ func (o *roleResourceType) GrantsForResourceType(ctx context.Context, _ string, if err != nil { return nil, nil, fmt.Errorf("jamf-connector: build role grants page token: %w", err) } - return grants, &resource.SyncOpResults{NextPageToken: nextToken}, nil + return grants, &resource.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil } // 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, error) { - accounts, nextPage, err := o.client.GetProAccounts(ctx, "", page, jamf.AccountsPageSize) +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, "", err + return nil, "", nil, err } var grants []*v2.Grant @@ -185,37 +186,39 @@ func (o *roleResourceType) privilegeSetGrants(ctx context.Context, page string) grants = append(grants, roleGrant(privilegeSet, account.ID)) } } - return grants, nextPage, nil + return grants, nextPage, annos, nil } // 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, error) { - catalog, err := privilegeCatalog(ctx, store, o.client) +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 := privilegeCatalog(ctx, store, o.client) if err != nil { - return nil, "", err + return nil, "", nil, err } - accounts, nextPage, err := o.client.GetProAccounts(ctx, "privilegeLevel==CUSTOM", page, customAccountsPageSize) + accounts, nextPage, annos, err := o.client.GetProAccounts(ctx, "privilegeLevel==CUSTOM", page, customAccountsPageSize) if err != nil { - return nil, "", err + return nil, "", nil, err } var grants []*v2.Grant for _, account := range accounts { // The accounts list has no privileges; only the account detail does. - detail, err := o.client.GetUserAccountDetails(ctx, account.ID) + 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, "", err + return nil, "", nil, err } + annos = detailAnnos for _, privilege := range customPrivileges(detail.PrivilegeSet, &detail.Privileges, catalog) { grants = append(grants, roleGrant(privilege, account.ID)) } } - return grants, nextPage, nil + return grants, nextPage, annos, nil } // customPrivileges returns the catalog privileges held individually; only a Custom privilege set confers them. @@ -278,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, @@ -296,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, @@ -309,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) } @@ -329,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 @@ -347,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 @@ -359,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, @@ -367,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 { @@ -375,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 } @@ -396,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, @@ -407,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 @@ -507,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 @@ -524,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 @@ -538,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 { @@ -565,11 +578,12 @@ 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, syncUserAccounts bool) *roleResourceType { diff --git a/pkg/connector/role_test.go b/pkg/connector/role_test.go index a94d24d3..6803ba31 100644 --- a/pkg/connector/role_test.go +++ b/pkg/connector/role_test.go @@ -229,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) } }) } @@ -342,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) } } @@ -452,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) } } @@ -478,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) } } @@ -605,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) } } diff --git a/pkg/connector/session_cache.go b/pkg/connector/session_cache.go index 128fb8d4..74e9beaa 100644 --- a/pkg/connector/session_cache.go +++ b/pkg/connector/session_cache.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -26,23 +27,23 @@ type userGroupMemberPage struct { } // privilegeCatalog returns the individual privileges Jamf offers, read once per sync. -func privilegeCatalog(ctx context.Context, store sessions.SessionStore, client *jamf.Client) ([]string, error) { +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, err + return nil, nil, err } if found { - return catalog, nil + return catalog, nil, nil } - response, err := client.GetPrivileges(ctx) + response, annos, err := client.GetPrivileges(ctx) if err != nil { - return nil, err + return nil, nil, err } if err := session.SetJSON(ctx, store, privilegeCatalogKey, response.Privileges); err != nil { - return nil, err + return nil, nil, err } - return response.Privileges, nil + return response.Privileges, annos, nil } // cacheUserIDs stores each user's username and email -> user id. @@ -85,20 +86,21 @@ func userLookupKey(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -// cacheUserGroupMemberPages stores a user group's member pages from page first on, one key per page. -func cacheUserGroupMemberPages(ctx context.Context, store sessions.SessionStore, userGroupID int, pages []userGroupMemberPage, first int) error { - values := make(map[string]userGroupMemberPage, len(pages)-first) - for page := first; page < len(pages); page++ { - values[userGroupMemberPageKey(userGroupID, page)] = pages[page] +// 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 one stored member page of a user group. -func cachedUserGroupMemberPage(ctx context.Context, store sessions.SessionStore, userGroupID, page int) (userGroupMemberPage, bool, error) { - return session.GetJSON[userGroupMemberPage](ctx, store, userGroupMemberPageKey(userGroupID, page), 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, page int) string { - return fmt.Sprintf("%d:%d", userGroupID, page) +func userGroupMemberPageKey(userGroupID, cursor int) string { + return fmt.Sprintf("%d:%d", userGroupID, cursor) } diff --git a/pkg/connector/site.go b/pkg/connector/site.go index 29ae0562..7f6e96cc 100644 --- a/pkg/connector/site.go +++ b/pkg/connector/site.go @@ -24,8 +24,11 @@ const ( ) 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 { @@ -47,7 +50,7 @@ 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) } @@ -61,7 +64,7 @@ func (g *siteResourceType) List(ctx context.Context, parentId *v2.ResourceId, at rv = append(rv, ur) } - return rv, nil, nil + return rv, &rs.SyncOpResults{Annotations: annos}, nil } // Entitlements is unused: StaticEntitlements declares the member entitlement. @@ -81,7 +84,7 @@ func (g *siteResourceType) StaticEntitlements(_ context.Context, _ rs.SyncOpAttr }, nil, nil } -// Grants emits the site's members in three phases: users, user groups and admin accounts. +// 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) { siteID, err := strconv.Atoi(resource.Id.Resource) if err != nil { @@ -93,21 +96,32 @@ func (g *siteResourceType) Grants(ctx context.Context, resource *v2.Resource, at return nil, nil, fmt.Errorf("jamf-connector: parse site %s grants page token: %w", resource.Id.Resource, err) } if bag.Current() == nil { - bag.Push(pagination.PageState{ResourceTypeID: sitePhaseAdmins}) - bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUserGroup}) - bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUser}) + // 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}) + } + if g.syncUsers { + bag.Push(pagination.PageState{ResourceTypeID: sitePhaseUser}) + } } 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, err = g.siteObjectGrants(ctx, resource, siteID, "User", resourceTypeUser, phase.Token) + grants, nextPage, annos, err = g.siteObjectGrants(ctx, resource, siteID, "User", resourceTypeUser, phase.Token) case sitePhaseUserGroup: - grants, nextPage, err = g.siteObjectGrants(ctx, resource, siteID, "User Group", resourceTypeUserGroup, phase.Token) + grants, nextPage, annos, err = g.siteObjectGrants(ctx, resource, siteID, "User Group", resourceTypeUserGroup, phase.Token) case sitePhaseAdmins: - grants, nextPage, err = g.adminGrants(ctx, resource, siteID, phase.Token) + grants, nextPage, annos, err = g.adminGrants(ctx, resource, siteID, phase.Token) default: err = fmt.Errorf("unknown phase %q", phase.ResourceTypeID) } @@ -119,7 +133,7 @@ func (g *siteResourceType) Grants(ctx context.Context, resource *v2.Resource, at 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}, nil + return grants, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil } // siteObjectGrants grants the site's objects of objectType on one page. @@ -130,10 +144,10 @@ func (g *siteResourceType) siteObjectGrants( objectType string, principalType *v2.ResourceType, page string, -) ([]*v2.Grant, string, error) { - objects, nextPage, err := g.client.GetSiteObjects(ctx, siteID, objectType, page) +) ([]*v2.Grant, string, annotations.Annotations, error) { + objects, nextPage, annos, err := g.client.GetSiteObjects(ctx, siteID, objectType, page) if err != nil { - return nil, "", err + return nil, "", nil, err } grants := make([]*v2.Grant, 0, len(objects)) @@ -141,14 +155,14 @@ func (g *siteResourceType) siteObjectGrants( principal := &v2.ResourceId{ResourceType: principalType.Id, Resource: strconv.Itoa(object.ObjectID)} grants = append(grants, grant.NewGrant(site, memberEntitlement, principal)) } - return grants, nextPage, nil + return grants, nextPage, annos, nil } // 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, error) { - accounts, nextPage, err := g.client.GetProAccounts(ctx, fmt.Sprintf("siteId==%d", siteID), page, jamf.AccountsPageSize) +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, "", err + return nil, "", nil, err } grants := make([]*v2.Grant, 0, len(accounts)) @@ -156,7 +170,7 @@ func (g *siteResourceType) adminGrants(ctx context.Context, site *v2.Resource, s principal := &v2.ResourceId{ResourceType: resourceTypeUserAccount.Id, Resource: strconv.Itoa(account.ID)} grants = append(grants, grant.NewGrant(site, memberEntitlement, principal)) } - return grants, nextPage, nil + return grants, nextPage, annos, nil } // Grant adds principal (a Jamf user) to the multi-valued list of the @@ -172,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) @@ -180,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 @@ -201,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 @@ -221,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 55e62277..9d22c277 100644 --- a/pkg/connector/site_test.go +++ b/pkg/connector/site_test.go @@ -84,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 { @@ -93,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") @@ -111,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 { @@ -131,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") @@ -157,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) @@ -181,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) @@ -194,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") @@ -202,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 { @@ -220,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 { @@ -238,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 { @@ -259,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) @@ -280,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) @@ -313,7 +313,7 @@ func TestSiteGrants_WalksUsersUserGroupsThenAdmins(t *testing.T) { "1/User Group": userGroupIDs, "2/User": {999}, } - builder := siteBuilder(fixture.client()) + 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) @@ -348,3 +348,44 @@ func TestSiteGrants_WalksUsersUserGroupsThenAdmins(t *testing.T) { 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) + } + + grants, _ := collectGrants(t, builder.Grants, site, newMemorySessionStore()) + + if got := fixture.accountsRequestsSeen(); len(got) != 0 { + t.Errorf("accounts requests = %v, want none", got) + } + if got := grantPrincipals(t, grants); !slices.Equal(got, []string{"user:1", "userGroup:500"}) { + t.Errorf("principals = %v, want [user:1 userGroup:500]", got) + } +} + +// 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("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 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 eb11b555..92e77195 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -83,7 +83,7 @@ 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, nextToken, err := o.client.GetProUsers(ctx, attrs.PageToken.Token, jamf.UsersPageSize) + users, nextToken, annos, err := o.client.GetProUsers(ctx, attrs.PageToken.Token, jamf.UsersPageSize) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list users: %w", err) } @@ -104,7 +104,7 @@ func (o *userResourceType) List(ctx context.Context, parentId *v2.ResourceId, at } } - return rv, &rs.SyncOpResults{NextPageToken: nextToken}, nil + return rv, &rs.SyncOpResults{NextPageToken: nextToken, Annotations: annos}, nil } func (o *userResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { @@ -189,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) } @@ -207,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 — @@ -223,14 +223,14 @@ func (o *userResourceType) Delete(ctx context.Context, resourceID *v2.ResourceId 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, syncManagedDevices bool) *userResourceType { diff --git a/pkg/connector/userAccount.go b/pkg/connector/userAccount.go index 15ac4f4e..5c3d8c37 100644 --- a/pkg/connector/userAccount.go +++ b/pkg/connector/userAccount.go @@ -167,7 +167,7 @@ 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) { - accounts, nextToken, err := o.client.GetProAccounts(ctx, "", attrs.PageToken.Token, jamf.AccountsPageSize) + accounts, nextToken, annos, err := o.client.GetProAccounts(ctx, "", attrs.PageToken.Token, jamf.AccountsPageSize) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list accounts: %w", err) } @@ -181,7 +181,7 @@ func (o *userAccountResourceType) List(ctx context.Context, parentId *v2.Resourc rv = append(rv, resource) } - return rv, &rs.SyncOpResults{NextPageToken: nextToken}, 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) { @@ -310,7 +310,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, @@ -326,7 +326,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) } @@ -337,7 +337,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{ @@ -346,7 +346,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 @@ -360,7 +360,7 @@ func (o *userAccountResourceType) Delete(ctx context.Context, resourceID *v2.Res return nil, err } - tokenDetails, err := o.client.GetTokenDetails(ctx) + tokenDetails, annos, err := o.client.GetTokenDetails(ctx) if err != nil { return nil, fmt.Errorf("jamf-connector: delete userAccount %d: read token details: %w", id, err) } @@ -369,14 +369,14 @@ func (o *userAccountResourceType) Delete(ctx context.Context, resourceID *v2.Res "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/userGroup.go b/pkg/connector/userGroup.go index d946a116..ac70d5ee 100644 --- a/pkg/connector/userGroup.go +++ b/pkg/connector/userGroup.go @@ -49,7 +49,7 @@ 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.GetBaseUserGroups(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) } @@ -63,7 +63,7 @@ func (g *userGroupResourceType) List(ctx context.Context, parentId *v2.ResourceI rv = append(rv, resource) } - return rv, nil, nil + return rv, &rs.SyncOpResults{Annotations: annos}, nil } // Entitlements is unused: StaticEntitlements declares the member entitlement. @@ -86,15 +86,15 @@ func (g *userGroupResourceType) Grants(ctx context.Context, resource *v2.Resourc if err != nil { return nil, nil, fmt.Errorf("jamf-connector: invalid user group id %q: %w", resource.Id.Resource, err) } - page := 0 + cursor := 0 if attrs.PageToken.Token != "" { - page, err = strconv.Atoi(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) } } - members, err := g.memberPage(ctx, attrs.Session, userGroupId, page) + members, annos, err := g.memberPage(ctx, attrs.Session, userGroupId, cursor) if err != nil { return nil, nil, fmt.Errorf("jamf-connector: list user group %d grants: %w", userGroupId, err) } @@ -107,43 +107,45 @@ func (g *userGroupResourceType) Grants(ctx context.Context, resource *v2.Resourc var nextPage string if !members.Last { - nextPage = strconv.Itoa(page + 1) + nextPage = strconv.Itoa(members.UserIDs[len(members.UserIDs)-1]) } - return grants, &rs.SyncOpResults{NextPageToken: nextPage}, nil + return grants, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil } -// memberPage returns one grants page of the user group's members. Classic returns every member at once, -// so a read from Jamf stores the pages after this one; a page missing from the store is read again. -func (g *userGroupResourceType) memberPage(ctx context.Context, store sessions.SessionStore, userGroupId, page int) (userGroupMemberPage, error) { - if page > 0 { - stored, found, err := cachedUserGroupMemberPage(ctx, store, userGroupId, page) +// 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 userGroupMemberPage{}, err + return userGroupMemberPage{}, nil, err } if found { - return stored, nil + return stored, nil, nil } } - group, err := g.client.GetUserGroupDetails(ctx, userGroupId) + group, annos, err := g.client.GetUserGroupDetails(ctx, userGroupId) if err != nil { - return userGroupMemberPage{}, err + return userGroupMemberPage{}, nil, err } - pages := memberPages(group.Users) - if page >= len(pages) { - return userGroupMemberPage{Last: true}, nil + pages := memberPages(group.Users, cursor) + if len(pages) == 0 { + return userGroupMemberPage{Last: true}, annos, nil } - if err := cacheUserGroupMemberPages(ctx, store, userGroupId, pages, page+1); err != nil { - return userGroupMemberPage{}, err + if err := cacheUserGroupMemberPages(ctx, store, userGroupId, pages); err != nil { + return userGroupMemberPage{}, nil, err } - return pages[page], nil + return pages[0], annos, nil } -// memberPages splits the members into grants pages of sorted user ids; the last one is marked. -func memberPages(users []jamf.User) []userGroupMemberPage { +// 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 { - userIDs = append(userIDs, user.ID) + if user.ID > cursor { + userIDs = append(userIDs, user.ID) + } } slices.Sort(userIDs) @@ -174,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) @@ -185,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 @@ -209,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. @@ -222,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 5fb3b9b1..8d707341 100644 --- a/pkg/connector/userGroup_test.go +++ b/pkg/connector/userGroup_test.go @@ -110,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) } } @@ -163,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) } } @@ -477,16 +477,19 @@ func TestUserGroupGrants_PagesPastOneThousandMembers(t *testing.T) { 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. + // 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: "1"}, + 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 != "" { - t.Errorf("page 1 = %d grants, next token %q; want %d grants and no token", len(secondPage), results.NextPageToken, memberCount-grantsPageSize) + 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 page

Unauthorized

", - 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 page

Unauthorized

", 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 6c488cbb..297e549f 100644 --- a/pkg/jamf/client.go +++ b/pkg/jamf/client.go @@ -12,6 +12,8 @@ import ( "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" @@ -122,139 +124,147 @@ 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 + annos, err := c.doRequest(ctx, url, &target) + if err != nil { + return nil, nil, err } - return &target, 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 } // 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, error) { +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; a 404 (IsNotFoundError) means the account does not exist. -func (c *Client) GetUserAccountDetails(ctx context.Context, userId int) (*UserAccount, error) { +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 } // 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, error) { +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 } // 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, error) { +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 } url, err := c.getUrl(proUsersUrlPath) if err != nil { - return nil, "", err + return nil, "", nil, err } query := liburl.Values{} query.Set("page", strconv.Itoa(page)) @@ -263,12 +273,13 @@ func (c *Client) GetProUsers(ctx context.Context, pageToken string, pageSize int url.RawQuery = query.Encode() var response ProUsersResponse - if err := c.doRequest(ctx, url, &response); err != nil { - return nil, "", proEndpointError(proUsersUrlPath, err) + annos, err := c.doRequest(ctx, url, &response) + if err != nil { + return nil, "", nil, proEndpointError(proUsersUrlPath, err) } nextPage := nextPageToken(page, pageSize, len(response.Results), response.TotalCount) - return response.Results, nextPage, nil + return response.Results, nextPage, annos, nil } // rsqlValueEscaper escapes a value placed inside a double-quoted RSQL string. @@ -277,10 +288,10 @@ 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, error) { +func (c *Client) GetProUsersByUsernameOrEmail(ctx context.Context, values []string) ([]ProUser, annotations.Annotations, error) { url, err := c.getUrl(proUsersUrlPath) if err != nil { - return nil, err + return nil, nil, err } terms := make([]string, 0, len(values)*2) @@ -296,24 +307,25 @@ func (c *Client) GetProUsersByUsernameOrEmail(ctx context.Context, values []stri url.RawQuery = query.Encode() var response ProUsersResponse - if err := c.doRequest(ctx, url, &response); err != nil { - return nil, proEndpointError(proUsersUrlPath, err) + annos, err := c.doRequest(ctx, url, &response) + if err != nil { + return nil, nil, proEndpointError(proUsersUrlPath, err) } - return response.Results, nil + 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, error) { +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, "", err + return nil, "", nil, err } url, err := c.getUrl(proAccountsUrlPath) if err != nil { - return nil, "", err + return nil, "", nil, err } query := liburl.Values{} if filter != "" { @@ -325,26 +337,27 @@ func (c *Client) GetProAccounts(ctx context.Context, filter, pageToken string, p url.RawQuery = query.Encode() var response ProAccountsResponse - if err := c.doRequest(ctx, url, &response); err != nil { - return nil, "", proEndpointError(proAccountsUrlPath, err) + 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, nil + 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, error) { +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, "", err + return nil, "", nil, err } url, err := c.getUrl(fmt.Sprintf(siteObjectsUrlPath, siteID)) if err != nil { - return nil, "", err + return nil, "", nil, err } query := liburl.Values{} query.Set("filter", `objectType=="`+rsqlValueEscaper.Replace(objectType)+`"`) @@ -354,25 +367,26 @@ func (c *Client) GetSiteObjects(ctx context.Context, siteID int, objectType, pag url.RawQuery = query.Encode() var objects []SiteObject - if err := c.doRequest(ctx, url, &objects); err != nil { - return nil, "", proEndpointError(url.Path, err) + 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), nil + return objects, strconv.Itoa(page + 1), annos, nil } - return objects, "", 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)}} @@ -383,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)}} @@ -405,26 +419,27 @@ func baseTypesFromIDs(ids []int) []BaseType { } // 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{ @@ -438,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) @@ -476,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) @@ -490,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{ @@ -508,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) } @@ -525,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{ @@ -545,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)) @@ -564,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) } @@ -579,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. @@ -596,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) } @@ -614,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{ @@ -640,12 +656,13 @@ func (c *Client) doRequestWithMethodAndBodyEncoding( requestOpts..., ) if err != nil { - return err + return nil, err } - response, err := c.wrapper.Do(request) + var rateLimit v2.RateLimitDescription + response, err := c.wrapper.Do(request, uhttp.WithRatelimitData(&rateLimit)) 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, uhttp.WithRatelimitData(&rateLimit)) } if err != nil { // uhttp already logs the underlying HTTP failure at the appropriate @@ -655,28 +672,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) { @@ -700,7 +719,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 { @@ -715,19 +734,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, proEndpointError(privilegesUrlPath, 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/pro_client_test.go b/pkg/jamf/pro_client_test.go index c8528119..83922693 100644 --- a/pkg/jamf/pro_client_test.go +++ b/pkg/jamf/pro_client_test.go @@ -20,23 +20,23 @@ func TestProListEndpoints_MissingEndpointIsFailedPrecondition(t *testing.T) { calls := map[string]func() error{ "GetProUsers": func() error { - _, _, err := client.GetProUsers(ctx, "", UsersPageSize) + _, _, _, err := client.GetProUsers(ctx, "", UsersPageSize) return err }, "GetProUsersByUsernameOrEmail": func() error { - _, err := client.GetProUsersByUsernameOrEmail(ctx, []string{"jdoe"}) + _, _, err := client.GetProUsersByUsernameOrEmail(ctx, []string{"jdoe"}) return err }, "GetProAccounts": func() error { - _, _, err := client.GetProAccounts(ctx, "", "", AccountsPageSize) + _, _, _, err := client.GetProAccounts(ctx, "", "", AccountsPageSize) return err }, "GetSiteObjects": func() error { - _, _, err := client.GetSiteObjects(ctx, 3, "User", "") + _, _, _, err := client.GetSiteObjects(ctx, 3, "User", "") return err }, "GetPrivileges": func() error { - _, err := client.GetPrivileges(ctx) + _, _, err := client.GetPrivileges(ctx) return err }, } @@ -72,7 +72,7 @@ func TestGetProUsers_SendsPagingAndSort_DecodesStringIDs(t *testing.T) { ] }`)) - users, _, err := client.GetProUsers(context.Background(), "2", 50) + users, _, _, err := client.GetProUsers(context.Background(), "2", 50) if err != nil { t.Fatalf("GetProUsers: %v", err) } @@ -102,7 +102,7 @@ func TestGetProUsersByUsernameOrEmail_SendsFilter(t *testing.T) { "results": [{"id": "5", "username": "c@d", "realname": "", "email": "c@d"}] }`)) - users, err := client.GetProUsersByUsernameOrEmail(context.Background(), []string{`a"b`, "c@d"}) + users, _, err := client.GetProUsersByUsernameOrEmail(context.Background(), []string{`a"b`, "c@d"}) if err != nil { t.Fatalf("GetProUsersByUsernameOrEmail: %v", err) } 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/test-server/devices.go b/test-server/devices.go index 36c58fe4..f6c03292 100644 --- a/test-server/devices.go +++ b/test-server/devices.go @@ -460,11 +460,10 @@ func parsePaging(w http.ResponseWriter, r *http.Request) (int, int, bool) { } func pageOf[T any](items []T, page, pageSize int) []T { - // Compare page counts, not page*pageSize, so a huge page cannot overflow. - if pageSize <= 0 || page >= ceilDiv(len(items), pageSize) { + start := page * pageSize + if start >= len(items) { return nil } - start := page * pageSize return items[start:min(start+pageSize, len(items))] } From d860368a1252be67b12f473c2b19aecadc42e900 Mon Sep 17 00:00:00 2001 From: Mateo Vespasiano Date: Wed, 4 Nov 2026 18:15:17 -0300 Subject: [PATCH 4/5] - User group member pages are keyed by the last emitted user id; a re-read after a store miss keeps only the ids above it - Group grants read the group once; a group with more than one page is stored for its later pages - Site grants only read the users, user groups and admin accounts the sync includes - Every client method returns Jamf's rate-limit annotations; List and Grants pass them to the SDK, and Grant/Revoke/CreateAccount/Delete return the write's - Custom privileges are checked against a set built from the catalog - Admin account profiles gain full_name; first and last name keep their shipped values - Docs: empty-member guard reworded; Delete refuses the connector's own account; group and site sync cost - Tests: user group and group store misses; site sync without userAccount, or without any of its principal types, reads nothing it doesn't need --- README.md | 2 +- pkg/connector/group.go | 44 ++++++++++++++++++++++-------- pkg/connector/group_test.go | 50 ++++++++++++++++++++++++++-------- pkg/connector/role.go | 6 ++-- pkg/connector/session_cache.go | 25 +++++++++++++++++ pkg/connector/userAccount.go | 10 ++++--- 6 files changed, 106 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7f13733c..04cd4c2a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Requests per full sync: |---|---| | `user` | ⌈users / 1000⌉ | | `userAccount` | ⌈accounts / 100⌉ | -| `group` | 1, plus per group one read per 1,000 grants (members, roles and site; at least one) | +| `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 | diff --git a/pkg/connector/group.go b/pkg/connector/group.go index 3ff5deb2..95789853 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -93,25 +93,45 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, a return nil, nil, fmt.Errorf("jamf-connector: invalid group id %q: %w", resource.Id.Resource, err) } - grants, annos, err := g.groupGrants(ctx, attrs.Session, resource, 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) } - - page, nextCursor, err := pageGrants(grants, attrs.PageToken.Token) - if err != nil { - return nil, nil, err - } return page, &rs.SyncOpResults{NextPageToken: nextCursor, Annotations: annos}, nil } -// groupGrants lists the grants of one group: its members, then the roles and the site it holds. -func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, groupId int) ([]*v2.Grant, annotations.Annotations, error) { +// 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 + } + if found { + page, nextCursor, err := g.groupGrants(ctx, store, resource, &stored, cursor) + return page, nextCursor, nil, err + } + } + group, annos, err := g.client.GetGroupDetails(ctx, groupId) if err != nil { - return nil, nil, err + return nil, "", nil, err + } + page, nextCursor, err := g.groupGrants(ctx, store, resource, group, cursor) + if err != nil { + return nil, "", nil, err } + if nextCursor != "" { + if err := cacheGroupDetails(ctx, store, groupId, group); err != nil { + return nil, "", nil, err + } + } + 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. +func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, group *jamf.Group, cursor string) ([]*v2.Grant, string, 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)} @@ -121,7 +141,7 @@ func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.Sess if g.syncRoles { roles, err := g.heldRoles(ctx, store, group) if err != nil { - return nil, nil, err + return nil, "", err } for _, role := range roles { roleID := &v2.ResourceId{ResourceType: resourceTypeRole.Id, Resource: role} @@ -134,7 +154,7 @@ func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.Sess siteID := &v2.ResourceId{ResourceType: resourceTypeSite.Id, Resource: strconv.Itoa(group.Site.ID)} grants = append(grants, grant.NewGrant(&v2.Resource{Id: siteID}, memberEntitlement, resource.Id)) } - return grants, annos, nil + return pageGrants(grants, cursor) } // heldRoles returns the roles a group holds: its privilege set, or the individual privileges of a Custom group. @@ -147,7 +167,7 @@ func (g *groupResourceType) heldRoles(ctx context.Context, store sessions.Sessio } // The group read supplies the annotations. - catalog, _, err := privilegeCatalog(ctx, store, g.client) + catalog, err := privilegeCatalogSet(ctx, store, g.client) if err != nil { return nil, err } diff --git a/pkg/connector/group_test.go b/pkg/connector/group_test.go index ac60bfb6..ec64d0e3 100644 --- a/pkg/connector/group_test.go +++ b/pkg/connector/group_test.go @@ -11,8 +11,10 @@ import ( "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" @@ -392,20 +394,24 @@ func groupRevokeGrantForPrincipal(t *testing.T, groupID int, principal *v2.Resou return grant.NewGrant(groupEntitlement(t, groupID).Resource, memberEntitlement, principal.Id) } -// Member grants come from the group read alone: any per-member account read would 404 and fail the sync. +// 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 - client := newTestJamfClient(t, func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/JSSResource/accounts/userid/") { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - encodeJSON(t, w, groupDetailsResponse(5, "Test Group", idRange(1, memberCount))) - }) - builder := groupBuilder(client, false, false) + 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, builder.Grants, groupPrincipal(t, 5), nil) + grants, calls := collectGrants(t, freshGrants, resource, newMemorySessionStore()) if calls != 2 { t.Errorf("Grants calls = %d, want 2", calls) @@ -413,6 +419,28 @@ func TestGroupGrants_PagesPastOneThousandMembers(t *testing.T) { 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. diff --git a/pkg/connector/role.go b/pkg/connector/role.go index db770fa3..af32c093 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -192,7 +192,7 @@ func (o *roleResourceType) privilegeSetGrants(ctx context.Context, page string) // 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 := privilegeCatalog(ctx, store, o.client) + catalog, err := privilegeCatalogSet(ctx, store, o.client) if err != nil { return nil, "", nil, err } @@ -222,13 +222,13 @@ func (o *roleResourceType) customPrivilegeGrants(ctx context.Context, store sess } // customPrivileges returns the catalog privileges held individually; only a Custom privilege set confers them. -func customPrivileges(privilegeSet string, privileges *jamf.Privileges, catalog []string) []string { +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 slices.Contains(catalog, privilege) { + if _, inCatalog := catalog[privilege]; inCatalog { held = append(held, privilege) } } diff --git a/pkg/connector/session_cache.go b/pkg/connector/session_cache.go index 74e9beaa..67fd9db4 100644 --- a/pkg/connector/session_cache.go +++ b/pkg/connector/session_cache.go @@ -20,6 +20,8 @@ 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"` @@ -46,6 +48,19 @@ func privilegeCatalog(ctx context.Context, store sessions.SessionStore, client * 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{}, error) { + privileges, _, err := privilegeCatalog(ctx, store, client) + if err != nil { + return nil, err + } + catalog := make(map[string]struct{}, len(privileges)) + for _, privilege := range privileges { + catalog[privilege] = struct{}{} + } + return catalog, 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) @@ -104,3 +119,13 @@ func cachedUserGroupMemberPage(ctx context.Context, store sessions.SessionStore, 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/userAccount.go b/pkg/connector/userAccount.go index 5c3d8c37..43a2b6fb 100644 --- a/pkg/connector/userAccount.go +++ b/pkg/connector/userAccount.go @@ -126,12 +126,14 @@ 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 From 89409f642732809e1b0e68e5873fca385d403a01 Mon Sep 17 00:00:00 2001 From: Mateo Vespasiano Date: Fri, 6 Nov 2026 15:06:35 -0300 Subject: [PATCH 5/5] - Jamf Pro API error messages reach the returned error (uhttp.WithErrorResponse) - Group grants return the privilege catalog read's annotations when they read it - Docs: drop internal mechanics, test evidence and the 401/403 detail; link endpoint versions to the Jamf changelog; C1 instead of ConductorOne --- README.md | 18 +++++--------- docs/connector.mdx | 18 +++++++------- docs/docs-info.md | 2 +- pkg/connector/group.go | 43 +++++++++++++++++++++++----------- pkg/connector/role.go | 2 +- pkg/connector/session_cache.go | 8 +++---- pkg/jamf/client.go | 6 +++-- pkg/jamf/models.go | 22 +++++++++++++++++ 8 files changed, 76 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 04cd4c2a..46f5a262 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more the proje Account creation/deletion and Grant/Revoke all require `-p`/`$BATON_PROVISIONING` to be set; without it the connector only syncs. -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. Tested with Jamf Pro 11.32.1. +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 @@ -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` — User Groups, admin groups, Sites, and user and account details and writes) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the users and accounts lists, site objects, 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. The write replaces the whole member list, so the -connector never writes over an empty read or takes it to mean the member was -removed: if that read were incomplete, the write would drop every real member. -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,18 +83,15 @@ 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 diff --git a/docs/connector.mdx b/docs/connector.mdx index 065015e7..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" --- @@ -28,16 +28,16 @@ The Jamf connector syncs the following resources: **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, 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. -Requires Jamf Pro 11.26.0 or later (Managed Devices: 11.30.0). Tested with Jamf Pro 11.32.1. +Requires Jamf Pro 11.26.0 or later (Managed Devices: 11.30.0). ## Gather Jamf credentials @@ -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` — User Groups, admin groups, Sites, and user and account details and writes) answers with HTTP 401, and the Jamf Pro API (`/api/v1`, `/api/v2`, `/api/v4` — the users and accounts lists, site objects, 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 dd84333b..0412434b 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -72,7 +72,7 @@ None identified — every endpoint below is part of standard Jamf Pro, not gated - 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 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, `GET /api/v1/accounts` in 11.20.0, `GET /api/v1/sites/{id}/objects` in 11.2.0 and `GET /api/v1/api-role-privileges` in 10.49.0; an older instance fails the sync with `FailedPrecondition` naming the missing endpoint. Managed Devices (opt-in) need 11.30.0 for `/api/v4/computers-inventory`. +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 diff --git a/pkg/connector/group.go b/pkg/connector/group.go index 95789853..10eff559 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -109,8 +109,7 @@ func (g *groupResourceType) grantsPage(ctx context.Context, store sessions.Sessi return nil, "", nil, err } if found { - page, nextCursor, err := g.groupGrants(ctx, store, resource, &stored, cursor) - return page, nextCursor, nil, err + return g.groupGrants(ctx, store, resource, &stored, cursor) } } @@ -118,10 +117,14 @@ func (g *groupResourceType) grantsPage(ctx context.Context, store sessions.Sessi if err != nil { return nil, "", nil, err } - page, nextCursor, err := g.groupGrants(ctx, store, resource, group, cursor) + 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 @@ -131,18 +134,27 @@ func (g *groupResourceType) grantsPage(ctx context.Context, store sessions.Sessi } // groupGrants returns the page after cursor of one group's grants: its members, then the roles and the site it holds. -func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.SessionStore, resource *v2.Resource, group *jamf.Group, cursor string) ([]*v2.Grant, string, error) { +// 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, err := g.heldRoles(ctx, store, group) + roles, catalogAnnos, err := g.heldRoles(ctx, store, group) if err != nil { - return 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)) @@ -154,24 +166,27 @@ func (g *groupResourceType) groupGrants(ctx context.Context, store sessions.Sess siteID := &v2.ResourceId{ResourceType: resourceTypeSite.Id, Resource: strconv.Itoa(group.Site.ID)} grants = append(grants, grant.NewGrant(&v2.Resource{Id: siteID}, memberEntitlement, resource.Id)) } - return pageGrants(grants, cursor) + page, nextCursor, err := pageGrants(grants, cursor) + if err != nil { + return nil, "", nil, err + } + return page, nextCursor, annos, nil } // 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, error) { +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 + return []string{group.PrivilegeSet}, nil, nil } if group.PrivilegeSet != privilegeSetCustom { - return nil, nil + return nil, nil, nil } - // The group read supplies the annotations. - catalog, err := privilegeCatalogSet(ctx, store, g.client) + catalog, annos, err := privilegeCatalogSet(ctx, store, g.client) if err != nil { - return nil, err + return nil, nil, err } - return customPrivileges(group.PrivilegeSet, &group.Privileges, catalog), nil + return customPrivileges(group.PrivilegeSet, &group.Privileges, catalog), annos, nil } // Grant adds principal (a Jamf admin account) to the static admin account diff --git a/pkg/connector/role.go b/pkg/connector/role.go index af32c093..e3b9e649 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -192,7 +192,7 @@ func (o *roleResourceType) privilegeSetGrants(ctx context.Context, page string) // 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) + catalog, _, err := privilegeCatalogSet(ctx, store, o.client) if err != nil { return nil, "", nil, err } diff --git a/pkg/connector/session_cache.go b/pkg/connector/session_cache.go index 67fd9db4..8e6bd187 100644 --- a/pkg/connector/session_cache.go +++ b/pkg/connector/session_cache.go @@ -49,16 +49,16 @@ func privilegeCatalog(ctx context.Context, store sessions.SessionStore, client * } // 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{}, error) { - privileges, _, err := privilegeCatalog(ctx, store, client) +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, err + return nil, nil, err } catalog := make(map[string]struct{}, len(privileges)) for _, privilege := range privileges { catalog[privilege] = struct{}{} } - return catalog, nil + return catalog, annos, nil } // cacheUserIDs stores each user's username and email -> user id. diff --git a/pkg/jamf/client.go b/pkg/jamf/client.go index 297e549f..56fc7ef0 100644 --- a/pkg/jamf/client.go +++ b/pkg/jamf/client.go @@ -659,10 +659,12 @@ func (c *Client) doRequestWithMethodAndBodyEncoding( return nil, err } var rateLimit v2.RateLimitDescription - response, err := c.wrapper.Do(request, uhttp.WithRatelimitData(&rateLimit)) + 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, uhttp.WithRatelimitData(&rateLimit)) + response, err = c.retryWithNewToken(request, token.AccessToken, doOptions...) } if err != nil { // uhttp already logs the underlying HTTP failure at the appropriate diff --git a/pkg/jamf/models.go b/pkg/jamf/models.go index ed7f9895..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"`