Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more the proje
| Account Deletion (Users, User Accounts) | Yes |
| Provisioning (Grant/Revoke) | No — Groups, User Groups, Roles, and Sites are synced for visibility only |

## Jamf Pro console admin account privileges (`userAccount`)

When `--create-account-resource-type=userAccount`, the account's `privilege_set`
profile field controls its access level: `Administrator`, `Auditor`,
`Enrollment Only`, or `Custom`.

For `Custom`, set at least one of the following profile fields to a list of
Jamf privilege names for that category — Jamf creates the account with no
privileges at all otherwise:

- `privileges_jss_objects`
- `privileges_jss_settings`
- `privileges_jss_actions`
- `privileges_recon`
- `privileges_casper_admin`
- `privileges_casper_remote`
- `privileges_casper_imaging`

Privilege names are validated server-side by Jamf, not by this connector — an
invalid name returns a Jamf API error rather than a local validation error.

# Getting Started

## Prerequisites
Expand Down
1 change: 1 addition & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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.
- Groups, User Groups, Roles, and Sites are synced for visibility (including membership) but are not provisionable — access changes to these resources must be made directly in Jamf Pro.
- When creating a **User Account**, the `privilege_set` profile field sets its access level (`Administrator`, `Auditor`, `Enrollment Only`, or `Custom`). For `Custom`, at least one of `privileges_jss_objects`, `privileges_jss_settings`, `privileges_jss_actions`, `privileges_recon`, `privileges_casper_admin`, `privileges_casper_remote`, or `privileges_casper_imaging` must be set to a list of Jamf privilege names — Jamf validates these names server-side, not the connector.

<Note>
**Managed Devices is opt-in.** This resource type is off by default so existing connectors keep working after upgrading. Enable it by selecting the **Managed Device** resource type in the connector's sync configuration. When enabled, the Jamf API role used by the connector must additionally have the **Read Computers** and **Read Mobile Devices** privileges, or the sync will fail.
Expand Down
17 changes: 17 additions & 0 deletions pkg/connector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ import (
"github.com/conductorone/baton-sdk/pkg/annotations"
)

// 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.
func stringSliceFromProfile(profileMap map[string]interface{}, key string) []string {
raw, ok := profileMap[key].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}

func annotationsForUserResourceType() annotations.Annotations {
annos := annotations.Annotations{}
annos.Update(&v2.SkipEntitlementsAndGrants{})
Expand Down
21 changes: 14 additions & 7 deletions pkg/connector/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ func (o *roleResourceType) ResourceType(_ context.Context) *v2.ResourceType {
return o.resourceType
}

var privilegeSets = []string{
"Administrator",
"Auditor",
"Enrollment Only",
}
// privilegeSets are the built-in sets; privilegeSetCustom is deliberately
// excluded — a Custom account's access is described by its individual privileges.
var privilegeSets = []string{privilegeSetAdministrator, privilegeSetAuditor, privilegeSetEnrollmentOnly}

// Create a new connector resource for a Jamf role.
func roleResource(ctx context.Context, role string, parentResourceID *v2.ResourceId) (*v2.Resource, error) {
Expand Down Expand Up @@ -90,6 +88,15 @@ func (o *roleResourceType) Entitlements(_ context.Context, resource *v2.Resource
return rv, 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.UserAccountCreateBody.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)
}

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)
Expand All @@ -105,7 +112,7 @@ func (o *roleResourceType) Grants(ctx context.Context, resource *v2.Resource, at
return nil, nil, err
}

if isCustomPrivilege && slices.Contains(group.Privileges.JSSObjects, resource.Id.Resource) {
if isCustomPrivilege && matchesIndividualPrivilege(group.PrivilegeSet, &group.Privileges, resource.Id.Resource) {
privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
rv = append(rv, privilegeGrant)
continue
Expand All @@ -123,7 +130,7 @@ func (o *roleResourceType) Grants(ctx context.Context, resource *v2.Resource, at
return nil, nil, err
}

if isCustomPrivilege && slices.Contains(userAccount.Privileges.JSSObjects, resource.Id.Resource) {
if isCustomPrivilege && matchesIndividualPrivilege(userAccount.PrivilegeSet, &userAccount.Privileges, resource.Id.Resource) {
privilegeGrant := grant.NewGrant(resource, memberEntitlement, gr.Id)
rv = append(rv, privilegeGrant)
continue
Expand Down
40 changes: 40 additions & 0 deletions pkg/connector/role_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package connector

import (
"testing"

"github.com/conductorone/baton-jamf/pkg/jamf"
)

// TestMatchesIndividualPrivilege_CustomOnly guards against PR #28 review
// feedback: widening Privileges.Contains to all 7 categories must not grant
// 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)
}
})
}
}
16 changes: 16 additions & 0 deletions pkg/connector/slugs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package connector

import "testing"

// TestEntitlementSlugRegressionPin guards against an accidental rename of the
// shared entitlement slug: it's built into every group/role/userGroup/site
// entitlement and grant ID already synced for existing customers, so a rename
// here would silently orphan every existing grant of these types. Asserts
// against the literal string, not memberEntitlement itself, so renaming the
// constant's value actually fails this test instead of trivially passing.
func TestEntitlementSlugRegressionPin(t *testing.T) {
const wantMemberSlug = "member"
if memberEntitlement != wantMemberSlug {
t.Fatalf("memberEntitlement slug changed: got %q, want %q — this orphans every existing grant of this type", memberEntitlement, wantMemberSlug)
}
}
160 changes: 127 additions & 33 deletions pkg/connector/userAccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ type userAccountResourceType struct {
client *jamf.Client
}

// knownPrivilegeSets are the valid values Jamf accepts for an admin account's
// privilege_set. See https://developer.jamf.com/jamf-pro/reference/createaccountbyid.
var knownPrivilegeSets = []string{"Administrator", "Auditor", "Enrollment Only", "Custom"}
// Valid values Jamf accepts for an admin account's privilege_set. See
// https://developer.jamf.com/jamf-pro/reference/createaccountbyid.
const (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Connector] role.go still uses privilege-set string literals that now have named constants in this package

Good change to name these. role.go:24 declares a second, overlapping list of the same values as literals:

var privilegeSets = []string{
	"Administrator",
	"Auditor",
	"Enrollment Only",
}

Two near-identical lists of the same vendor enum in one package will drift — and privilegeSets gates isCustomPrivilege in Grants, so a drift there silently changes which grants are emitted. Reuse the constants:

// privilegeSets are the built-in sets; privilegeSetCustom is deliberately
// excluded — a Custom account's access is described by its individual privileges.
var privilegeSets = []string{privilegeSetAdministrator, privilegeSetAuditor, privilegeSetEnrollmentOnly}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 92831da: role.go now reuses privilegeSetAdministrator/privilegeSetAuditor/privilegeSetEnrollmentOnly from userAccount.go instead of a second literal list, with the same comment you suggested explaining why Custom is excluded.

privilegeSetAdministrator = "Administrator"
privilegeSetAuditor = "Auditor"
privilegeSetEnrollmentOnly = "Enrollment Only"
privilegeSetCustom = "Custom"
)

var knownPrivilegeSets = []string{privilegeSetAdministrator, privilegeSetAuditor, privilegeSetEnrollmentOnly, privilegeSetCustom}

func isKnownPrivilegeSet(privilegeSet string) bool {
for _, p := range knownPrivilegeSets {
Expand All @@ -32,9 +39,73 @@ func isKnownPrivilegeSet(privilegeSet string) bool {
return false
}

// The following profile fields only apply when privilege_set is "Custom" —
// they populate the Classic API's <privileges> block, which is what gives a
// Custom privilege_set its meaning (Jamf otherwise creates the account with
// no privileges at all). See
// https://developer.jamf.com/jamf-pro/reference/createaccountbyid.
const (
profileFieldPrivilegesJSSObjects = "privileges_jss_objects"
profileFieldPrivilegesJSSSettings = "privileges_jss_settings"
profileFieldPrivilegesJSSActions = "privileges_jss_actions"
profileFieldPrivilegesRecon = "privileges_recon"
profileFieldPrivilegesCasperAdmin = "privileges_casper_admin"
profileFieldPrivilegesCasperRemote = "privileges_casper_remote"
profileFieldPrivilegesCasperImaging = "privileges_casper_imaging"
)

// customPrivilegeFields maps each profile field to the jamf.Privileges
// category it populates, in schema display order.
var customPrivilegeFields = []struct {
field string
displayName string
}{
{profileFieldPrivilegesJSSObjects, "Privileges: JSS Objects"},
{profileFieldPrivilegesJSSSettings, "Privileges: JSS Settings"},
{profileFieldPrivilegesJSSActions, "Privileges: JSS Actions"},
{profileFieldPrivilegesRecon, "Privileges: Recon"},
{profileFieldPrivilegesCasperAdmin, "Privileges: Casper Admin"},
{profileFieldPrivilegesCasperRemote, "Privileges: Casper Remote"},
{profileFieldPrivilegesCasperImaging, "Privileges: Casper Imaging"},
}

// resolvePrivileges reads the 7 privileges_* profile fields and validates
// them against privilegeSet: a Custom account must specify at least one
// privilege, and a non-Custom account must specify none (Privileges only has
// meaning for Custom — see jamf.UserAccountCreateBody.Privileges). Returns
// nil, nil for a non-Custom account with no privileges fields set.
func resolvePrivileges(profileMap map[string]interface{}, privilegeSet string) (*jamf.Privileges, error) {
provided := &jamf.Privileges{
JSSObjects: stringSliceFromProfile(profileMap, profileFieldPrivilegesJSSObjects),
JSSSettings: stringSliceFromProfile(profileMap, profileFieldPrivilegesJSSSettings),
JSSActions: stringSliceFromProfile(profileMap, profileFieldPrivilegesJSSActions),
Recon: stringSliceFromProfile(profileMap, profileFieldPrivilegesRecon),
CasperAdmin: stringSliceFromProfile(profileMap, profileFieldPrivilegesCasperAdmin),
CasperRemote: stringSliceFromProfile(profileMap, profileFieldPrivilegesCasperRemote),
CasperImaging: stringSliceFromProfile(profileMap, profileFieldPrivilegesCasperImaging),
}

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)
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 operator with an account
// that has none of the access they asked for and no indication why.
return nil, fmt.Errorf(
"jamf-connector: privileges were set but privilege_set is %q, not %q — Privileges fields only apply to %q accounts",
privilegeSet, privilegeSetCustom, privilegeSetCustom,
)
default:
return nil, nil
}
}

const (
defaultAccessLevel = "Full Access"
defaultPrivilegeSet = "Auditor"
defaultPrivilegeSet = privilegeSetAuditor

// enabledValue is the Jamf Classic API's string representation of an
// enabled account (as opposed to "Disabled").
Expand Down Expand Up @@ -118,40 +189,57 @@ func (o *userAccountResourceType) Grants(_ context.Context, _ *v2.Resource, _ rs
// this profile map. Password is generated by C1 (see CreateAccountCapabilityDetails),
// not collected here.
func userAccountCreationSchema() *v2.ConnectorAccountCreationSchema {
return &v2.ConnectorAccountCreationSchema{
FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{
profileFieldFullName: {
DisplayName: "Full Name",
Required: false,
Description: "The admin's full name.",
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: "Jane Doe",
Order: 1,
privilegeSetDescription := fmt.Sprintf(
"The admin's privilege set. One of: %s. Defaults to %q. When %q, set at least one of the Privileges fields below.",
strings.Join(knownPrivilegeSets, ", "), defaultPrivilegeSet, privilegeSetCustom,
)

fieldMap := map[string]*v2.ConnectorAccountCreationSchema_Field{
profileFieldFullName: {
DisplayName: "Full Name",
Required: false,
Description: "The admin's full name.",
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
profileFieldEmail: {
DisplayName: "Email",
Required: false,
Description: "The admin's email address.",
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: "jane.doe@example.com",
Order: 2,
Placeholder: "Jane Doe",
Order: 1,
},
profileFieldEmail: {
DisplayName: "Email",
Required: false,
Description: "The admin's email address.",
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
profileFieldPrivilegeSet: {
DisplayName: "Privilege Set",
Required: false,
Description: fmt.Sprintf("The admin's privilege set. One of: %s. Defaults to %q.", strings.Join(knownPrivilegeSets, ", "), defaultPrivilegeSet),
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: defaultPrivilegeSet,
Order: 3,
Placeholder: "jane.doe@example.com",
Order: 2,
},
profileFieldPrivilegeSet: {
DisplayName: "Privilege Set",
Required: false,
Description: privilegeSetDescription,
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: defaultPrivilegeSet,
Order: 3,
},
}

for i, cp := range customPrivilegeFields {
fieldMap[cp.field] = &v2.ConnectorAccountCreationSchema_Field{
DisplayName: cp.displayName,
Required: false,
Description: fmt.Sprintf("Only used when Privilege Set is %q. List of privilege names to grant in this category.", privilegeSetCustom),
Field: &v2.ConnectorAccountCreationSchema_Field_StringListField{
StringListField: &v2.ConnectorAccountCreationSchema_StringListField{},
},
Order: int32(4 + i),
}
}

return &v2.ConnectorAccountCreationSchema{FieldMap: fieldMap}
}

// provisionableUserAccountType adds account-creation capability on top of
Expand Down Expand Up @@ -206,6 +294,11 @@ func (o *provisionableUserAccountType) CreateAccount(
return nil, nil, nil, fmt.Errorf("jamf-connector: failed to generate random password: %w", err)
}

privileges, err := resolvePrivileges(profileMap, privilegeSet)
if err != nil {
return nil, nil, nil, err
}

// Step 1: attempt creation.
err = o.client.CreateUserAccount(ctx, jamf.UserAccountCreateBody{
Name: name,
Expand All @@ -215,6 +308,7 @@ func (o *provisionableUserAccountType) CreateAccount(
Enabled: enabledValue,
AccessLevel: defaultAccessLevel,
PrivilegeSet: privilegeSet,
Privileges: privileges,
})
alreadyExists := err != nil && jamf.IsAlreadyExistsError(err)
if err != nil && !alreadyExists {
Expand Down
Loading
Loading