diff --git a/README.md b/README.md index 5e36abd0..5e2aa97c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/connector.mdx b/docs/connector.mdx index 50ddb08a..07021658 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -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. **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. diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 9a154a19..5dd7a916 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -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{}) diff --git a/pkg/connector/role.go b/pkg/connector/role.go index a5c3dfb6..4b8ccf6d 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -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) { @@ -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) @@ -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 @@ -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 diff --git a/pkg/connector/role_test.go b/pkg/connector/role_test.go new file mode 100644 index 00000000..64ba90b6 --- /dev/null +++ b/pkg/connector/role_test.go @@ -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) + } + }) + } +} diff --git a/pkg/connector/slugs_test.go b/pkg/connector/slugs_test.go new file mode 100644 index 00000000..f5e88d1e --- /dev/null +++ b/pkg/connector/slugs_test.go @@ -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) + } +} diff --git a/pkg/connector/userAccount.go b/pkg/connector/userAccount.go index 1e1d182e..58ef403a 100644 --- a/pkg/connector/userAccount.go +++ b/pkg/connector/userAccount.go @@ -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 ( + 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 { @@ -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 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"). @@ -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 @@ -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, @@ -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 { diff --git a/pkg/connector/userAccount_test.go b/pkg/connector/userAccount_test.go new file mode 100644 index 00000000..a904e32e --- /dev/null +++ b/pkg/connector/userAccount_test.go @@ -0,0 +1,65 @@ +package connector + +import ( + "testing" +) + +func TestResolvePrivileges_CustomWithPrivileges(t *testing.T) { + profileMap := map[string]interface{}{ + profileFieldPrivilegesJSSObjects: []interface{}{"Read User"}, + } + got, err := resolvePrivileges(profileMap, privilegeSetCustom) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.IsEmpty() { + t.Fatal("expected a populated Privileges value for a Custom account with privileges set") + } +} + +func TestResolvePrivileges_CustomWithNoPrivileges_Errors(t *testing.T) { + _, err := resolvePrivileges(map[string]interface{}{}, privilegeSetCustom) + if err == nil { + t.Fatal("expected an error when privilege_set is Custom but no privileges are set") + } +} + +func TestResolvePrivileges_NonCustomWithNoPrivileges_OK(t *testing.T) { + got, err := resolvePrivileges(map[string]interface{}{}, privilegeSetAuditor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Fatalf("expected nil Privileges for a non-Custom account with no privileges fields set, got %+v", got) + } +} + +// TestResolvePrivileges_NonCustomWithPrivileges_Errors guards against +// silently discarding an operator's privileges_* input when they leave +// privilege_set at its default — see PR #28 review feedback. +func TestResolvePrivileges_NonCustomWithPrivileges_Errors(t *testing.T) { + profileMap := map[string]interface{}{ + profileFieldPrivilegesRecon: []interface{}{"Read Advanced Computer Searches"}, + } + got, err := resolvePrivileges(profileMap, privilegeSetAuditor) + if err == nil { + t.Fatal("expected an error when privileges are set but privilege_set is not Custom") + } + if got != nil { + t.Fatalf("expected nil Privileges alongside the error, got %+v", got) + } +} + +func TestResolvePrivileges_DiscardsMalformedEntries(t *testing.T) { + profileMap := map[string]interface{}{ + profileFieldPrivilegesJSSObjects: []interface{}{"Read User", 123, ""}, + } + got, err := resolvePrivileges(profileMap, privilegeSetCustom) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"Read User"} + if len(got.JSSObjects) != len(want) || got.JSSObjects[0] != want[0] { + t.Fatalf("expected malformed/empty entries filtered out, got %v", got.JSSObjects) + } +} diff --git a/pkg/jamf/models.go b/pkg/jamf/models.go index 23f98836..12a1099b 100644 --- a/pkg/jamf/models.go +++ b/pkg/jamf/models.go @@ -1,6 +1,9 @@ package jamf -import "encoding/xml" +import ( + "encoding/xml" + "slices" +) type BaseType struct { ID int `json:"id"` @@ -37,9 +40,86 @@ type UserAccount struct { Site BaseType `json:"site"` } +// Privileges models the Classic API's block, which gives a +// Custom privilege_set its actual meaning. Each category is a list of +// privilege names. See +// https://developer.jamf.com/jamf-pro/reference/createaccountbyid and +// https://developer.jamf.com/jamf-pro/reference/findaccountsbyid. type Privileges struct { - // array of privileges the resource has access to - JSSObjects []string `json:"jss_objects"` + JSSObjects []string `json:"jss_objects" xml:"jss_objects>privilege,omitempty"` + JSSSettings []string `json:"jss_settings" xml:"jss_settings>privilege,omitempty"` + JSSActions []string `json:"jss_actions" xml:"jss_actions>privilege,omitempty"` + Recon []string `json:"recon" xml:"recon>privilege,omitempty"` + CasperAdmin []string `json:"casper_admin" xml:"casper_admin>privilege,omitempty"` + CasperRemote []string `json:"casper_remote" xml:"casper_remote>privilege,omitempty"` + CasperImaging []string `json:"casper_imaging" xml:"casper_imaging>privilege,omitempty"` +} + +// IsEmpty reports whether every privilege category is empty — i.e. this +// Privileges value grants nothing. +func (p *Privileges) IsEmpty() bool { + if p == nil { + return true + } + return len(p.JSSObjects) == 0 && + len(p.JSSSettings) == 0 && + len(p.JSSActions) == 0 && + len(p.Recon) == 0 && + len(p.CasperAdmin) == 0 && + len(p.CasperRemote) == 0 && + len(p.CasperImaging) == 0 +} + +// Contains reports whether privilege appears in any of p's 7 categories. +func (p *Privileges) Contains(privilege string) bool { + if p == nil { + return false + } + return slices.Contains(p.JSSObjects, privilege) || + slices.Contains(p.JSSSettings, privilege) || + slices.Contains(p.JSSActions, privilege) || + slices.Contains(p.Recon, privilege) || + slices.Contains(p.CasperAdmin, privilege) || + slices.Contains(p.CasperRemote, privilege) || + slices.Contains(p.CasperImaging, privilege) +} + +// 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 +// always emits the empty wrapper element regardless. This method replaces +// that reflection-based encoding for the write path so an unset category is +// actually omitted from the XML sent to Jamf, rather than sent as +// "". The struct field xml tags remain in place +// for decoding (test-server's XML unmarshal still uses them). +func (p Privileges) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if err := e.EncodeToken(start); err != nil { + return err + } + categories := []struct { + name string + items []string + }{ + {"jss_objects", p.JSSObjects}, + {"jss_settings", p.JSSSettings}, + {"jss_actions", p.JSSActions}, + {"recon", p.Recon}, + {"casper_admin", p.CasperAdmin}, + {"casper_remote", p.CasperRemote}, + {"casper_imaging", p.CasperImaging}, + } + for _, c := range categories { + if len(c.items) == 0 { + continue + } + element := struct { + Items []string `xml:"privilege"` + }{Items: c.items} + if err := e.EncodeElement(element, xml.StartElement{Name: xml.Name{Local: c.name}}); err != nil { + return err + } + } + return e.EncodeToken(start.End()) } type Group struct { @@ -130,6 +210,10 @@ type UserAccountCreateBody struct { Enabled string `xml:"enabled,omitempty"` AccessLevel string `xml:"access_level,omitempty"` PrivilegeSet string `xml:"privilege_set,omitempty"` + // Privileges is only meaningful (and should only be set) when + // PrivilegeSet is "Custom" — a pointer so the whole element + // is omitted otherwise. + Privileges *Privileges `xml:"privileges,omitempty"` } type UserGroupsResponse struct { diff --git a/pkg/jamf/models_test.go b/pkg/jamf/models_test.go new file mode 100644 index 00000000..d4e53aaa --- /dev/null +++ b/pkg/jamf/models_test.go @@ -0,0 +1,84 @@ +package jamf + +import ( + "encoding/xml" + "strings" + "testing" +) + +func TestUserAccountCreateBody_Privileges_OmitsEmptyCategories(t *testing.T) { + body := UserAccountCreateBody{ + Name: "customadmin", + Password: "pw", + PrivilegeSet: "Custom", + Privileges: &Privileges{ + JSSObjects: []string{"Read User", "Update User"}, + Recon: []string{"Read Advanced Computer Searches"}, + }, + } + + out, err := xml.Marshal(body) //nolint:gosec // test-only literal password, not a real secret + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := string(out) + + for _, want := range []string{ + "Read UserUpdate User", + "Read Advanced Computer Searches", + } { + if !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, got: %s", want, got) + } + } + + for _, category := range []string{"jss_settings", "jss_actions", "casper_admin", "casper_remote", "casper_imaging"} { + if strings.Contains(got, "<"+category+">") { + t.Errorf("expected unset category %q to be omitted entirely, got: %s", category, got) + } + } +} + +func TestUserAccountCreateBody_Privileges_NilOmitsWholeElement(t *testing.T) { + body := UserAccountCreateBody{Name: "auditor1", Password: "pw", PrivilegeSet: "Auditor"} + + out, err := xml.Marshal(body) //nolint:gosec // test-only literal password, not a real secret + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(string(out), " element for a non-Custom account, got: %s", string(out)) + } +} + +func TestPrivileges_Contains(t *testing.T) { + p := &Privileges{ + JSSObjects: []string{"Read User"}, + Recon: []string{"Read Advanced Computer Searches"}, + } + + if !p.Contains("Read User") { + t.Error("expected Contains to find a JSSObjects privilege") + } + if !p.Contains("Read Advanced Computer Searches") { + t.Error("expected Contains to find a Recon privilege") + } + if p.Contains("Update User") { + t.Error("expected Contains to return false for an unlisted privilege") + } + if (*Privileges)(nil).Contains("anything") { + t.Error("expected Contains to return false on a nil receiver") + } +} + +func TestPrivileges_IsEmpty(t *testing.T) { + if !(&Privileges{}).IsEmpty() { + t.Error("expected an all-nil Privileges to be empty") + } + if (&Privileges{Recon: []string{"x"}}).IsEmpty() { + t.Error("expected a Privileges with one populated category to not be empty") + } + if !(*Privileges)(nil).IsEmpty() { + t.Error("expected a nil Privileges to be empty") + } +} diff --git a/test-server/main.go b/test-server/main.go index 22f63701..f5fef9a5 100644 --- a/test-server/main.go +++ b/test-server/main.go @@ -59,6 +59,7 @@ import ( "net" "net/http" "os" + "slices" "strconv" "strings" "sync" @@ -78,10 +79,22 @@ const ( accessLevelFullAccess = "Full Access" privilegeSetAdministrator = "Administrator" + privilegeSetAuditor = "Auditor" + privilegeSetCustom = "Custom" + enabledValue = "Enabled" privilegeReadAdvancedComputerSearches = "Read Advanced Computer Searches" ) +// Enums declared on the Classic API "account" schema — see +// https://developer.jamf.com/jamf-pro/reference/createaccountbyid and +// https://developer.jamf.com/jamf-pro/reference/findaccountsbyid. +var ( + validAccessLevels = []string{"Full Access", "Site Access", "Group Access"} + validPrivilegeSets = []string{privilegeSetAdministrator, privilegeSetAuditor, "Enrollment Only", privilegeSetCustom} + validEnabledValues = []string{enabledValue, "Disabled"} +) + type server struct { mu sync.Mutex @@ -159,15 +172,15 @@ func (s *server) seedData() { admin1 := &jamf.UserAccount{ BaseType: jamf.BaseType{ID: 101, Name: "admin1"}, FullName: "Admin One", Email: "admin1@example.com", - Enabled: "Enabled", AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetAdministrator, Site: headquarters, + Enabled: enabledValue, AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetAdministrator, Site: headquarters, } admin2 := &jamf.UserAccount{ BaseType: jamf.BaseType{ID: 102, Name: "admin2"}, FullName: "Admin Two", Email: "admin2@example.com", - Enabled: "Disabled", AccessLevel: accessLevelFullAccess, PrivilegeSet: "Auditor", Site: headquarters, + Enabled: "Disabled", AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetAuditor, Site: headquarters, } admin3 := &jamf.UserAccount{ BaseType: jamf.BaseType{ID: 103, Name: "admin3"}, FullName: "Admin Three", Email: "admin3@example.com", - Enabled: "Enabled", AccessLevel: accessLevelFullAccess, PrivilegeSet: "Custom", Site: remote, + Enabled: enabledValue, AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetCustom, Site: remote, Privileges: jamf.Privileges{JSSObjects: []string{privilegeReadAdvancedComputerSearches}}, } accounts := []*jamf.UserAccount{admin1, admin2, admin3} @@ -187,11 +200,11 @@ func (s *server) seedData() { Members: []jamf.BaseType{admin1Ref, admin2Ref}, }, { - BaseType: jamf.BaseType{ID: 202, Name: "group-auditors"}, AccessLevel: accessLevelFullAccess, PrivilegeSet: "Auditor", Site: headquarters, + BaseType: jamf.BaseType{ID: 202, Name: "group-auditors"}, AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetAuditor, Site: headquarters, Members: []jamf.BaseType{admin2Ref, admin3Ref}, }, { - BaseType: jamf.BaseType{ID: 203, Name: "group-custom"}, AccessLevel: accessLevelFullAccess, PrivilegeSet: "Custom", Site: remote, + BaseType: jamf.BaseType{ID: 203, Name: "group-custom"}, AccessLevel: accessLevelFullAccess, PrivilegeSet: privilegeSetCustom, Site: remote, Privileges: jamf.Privileges{JSSObjects: []string{privilegeReadAdvancedComputerSearches}}, Members: []jamf.BaseType{admin3Ref}, }, @@ -356,6 +369,8 @@ func (s *server) handleUserByID(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() + // NOTE: same caveat as the account create path below — 409 here is + // unverified against a live Jamf tenant. See CXH-2156. if existing, dup := s.findUserByNameLocked(body.Name); dup { s.mu.Unlock() _ = existing @@ -370,10 +385,13 @@ func (s *server) handleUserByID(w http.ResponseWriter, r *http.Request) { } s.users[u.ID] = u s.userList = append(s.userList, u) - cp := *u + id := u.ID s.mu.Unlock() - writeJSON(w, http.StatusCreated, jamf.UserResponse{User: cp}) + // createuserbyid declares 201 with no response body schema; the docs + // state only that the result includes the created resource's ID — + // see https://developer.jamf.com/jamf-pro/reference/createuserbyid. + writeJSON(w, http.StatusCreated, createResponse{ID: id}) case http.MethodDelete: s.mu.Lock() @@ -493,8 +511,27 @@ func (s *server) handleAccountByID(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "name and password are required") return } + enumChecks := []struct { + name string + value string + allowed []string + }{ + {"access_level", body.AccessLevel, validAccessLevels}, + {"privilege_set", body.PrivilegeSet, validPrivilegeSets}, + {"enabled", body.Enabled, validEnabledValues}, + } + for _, c := range enumChecks { + if c.value != "" && !slices.Contains(c.allowed, c.value) { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s %q", c.name, c.value)) + return + } + } s.mu.Lock() + // NOTE: Jamf does not document a per-endpoint error code for a name + // collision on createaccountbyid — 409 only appears in the Classic + // API Overview's generic response-code table, alongside 400 for a + // malformed XML body. Unverified against a live tenant; see CXH-2156. if existing, dup := s.findAccountByNameLocked(body.Name); dup { s.mu.Unlock() _ = existing @@ -510,12 +547,18 @@ func (s *server) handleAccountByID(w http.ResponseWriter, r *http.Request) { AccessLevel: body.AccessLevel, PrivilegeSet: body.PrivilegeSet, } + if body.Privileges != nil { + a.Privileges = *body.Privileges + } s.accounts[a.ID] = a s.accountList = append(s.accountList, a) - cp := *a + id := a.ID s.mu.Unlock() - writeJSON(w, http.StatusCreated, jamf.UserAccountResponse{UserAccount: cp}) + // createaccountbyid declares 201 with no response body schema; the + // docs state only that the result includes the created resource's + // ID — see https://developer.jamf.com/jamf-pro/reference/createaccountbyid. + writeJSON(w, http.StatusCreated, createResponse{ID: id}) case http.MethodDelete: s.mu.Lock() @@ -730,10 +773,23 @@ func writeJSON(w http.ResponseWriter, code int, v any) { _ = json.NewEncoder(w).Encode(v) } +// errorBody is JSON for every mocked error, including auth and validation +// failures. The Classic API Overview states error responses are HTML, not +// JSON — https://developer.jamf.com/jamf-pro/docs/classic-api-overview. +// Left as JSON: the connector maps errors purely off HTTP status code, via +// vendor/github.com/conductorone/baton-sdk/pkg/uhttp (GrpcCodeFromHTTPStatus) +// — it never parses this body — so this divergence has no functional effect +// on the connector today. Unverified against a live tenant; see CXH-2156. type errorBody struct { Message string `json:"message"` } +// createResponse mirrors the only field the Classic API's create endpoints +// actually document returning — the new resource's ID. +type createResponse struct { + ID int `json:"id"` +} + func writeJSONError(w http.ResponseWriter, code int, message string) { writeJSON(w, code, errorBody{Message: message}) }