diff --git a/generator/classic/generator.go b/generator/classic/generator.go index 271e010b..bdd011e2 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -87,12 +87,13 @@ func (g *Generator) GenerateRegistry(resources []ClassicResource) (string, error func templateFuncs() template.FuncMap { return template.FuncMap{ - "toCamel": strcase.ToCamel, - "toKebab": strcase.ToKebab, - "toLower": strings.ToLower, - "hasOp": hasOp, - "hasLookup": hasLookup, - "extraLookups": extraLookups, + "toCamel": strcase.ToCamel, + "toKebab": strcase.ToKebab, + "toLower": strings.ToLower, + "hasOp": hasOp, + "hasLookup": hasLookup, + "extraLookups": extraLookups, + "scopeResolveByList": func(r ClassicResource) bool { return !r.HasLookup("name") }, "classicExample": func(r ClassicResource, op string) string { bin := "jamf-cli pro" name := r.CLIName @@ -371,8 +372,14 @@ func New{{ .GoName }}Cmd(ctx *registry.CLIContext) *cobra.Command { {{- end }} {{ if needsScope . }} cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ - APIPath: "{{ .Path }}", - SingularKey: "{{ .Singular }}", + APIPath: "{{ .Path }}", + SingularKey: "{{ .Singular }}", + {{- if scopeResolveByList . }} + ResolveByList: true, + {{- end }} + {{- if .NoSubsetPut }} + NoSubsetPut: true, + {{- end }} })) {{- end }} diff --git a/generator/classic/parser.go b/generator/classic/parser.go index 3590914d..bdc4f86c 100644 --- a/generator/classic/parser.go +++ b/generator/classic/parser.go @@ -27,6 +27,7 @@ type manifestResource struct { Operations []string `yaml:"operations"` Lookups []string `yaml:"lookups"` Scope bool `yaml:"scope"` + NoSubsetPut bool `yaml:"no_subset_put"` IDPath string `yaml:"id_path"` ListSubset string `yaml:"list_subset"` GroupsPath string `yaml:"groups_path"` @@ -114,6 +115,7 @@ func buildResource(entry manifestResource) (ClassicResource, error) { Operations: operations, Lookups: lookups, HasScope: entry.Scope, + NoSubsetPut: entry.NoSubsetPut, IDPath: idPath, IsConfigProfile: entry.Path == "osxconfigurationprofiles" || entry.Path == "mobiledeviceconfigurationprofiles", HasCustomPayload: entry.Path == "osxconfigurationprofiles", diff --git a/generator/classic/types.go b/generator/classic/types.go index f8b57696..8b0c7442 100644 --- a/generator/classic/types.go +++ b/generator/classic/types.go @@ -15,6 +15,7 @@ type ClassicResource struct { Operations []string // ["list", "get", "create", "update", "delete"] Lookups []string // ["id", "name", "serialnumber", "macaddress", "udid"] HasScope bool // true if the resource supports scope operations + NoSubsetPut bool // true if the resource does not support /subset/Scope PUT (use full-doc PUT) IDPath string // path segment between base path and ID value; defaults to "id" (e.g. "groupid" → /accounts/groupid/{id}) IsConfigProfile bool // true for macOS and mobile device configuration profile resources HasCustomPayload bool // true only for osxconfigurationprofiles (supports --custom-payload-file) diff --git a/internal/commands/groups.go b/internal/commands/groups.go index f0316c1b..b4b50fb4 100644 --- a/internal/commands/groups.go +++ b/internal/commands/groups.go @@ -438,6 +438,7 @@ var proGroupMap = map[string]string{ "classic-restricted-software": groupClassicConfig, "classic-allowed-file-extensions": groupClassicConfig, "classic-mac-apps": groupClassicConfig, + "classic-ebooks": groupClassicConfig, "classic-mobile-apps": groupClassicMobile, "classic-ibeacons": groupClassicConfig, "classic-classes": groupClassicConfig, @@ -452,6 +453,7 @@ var proGroupMap = map[string]string{ "classic-distribution-points": groupClassicAdmin, "classic-software-update-servers": groupClassicAdmin, "classic-licensed-software": groupClassicAdmin, + "classic-user-groups": groupClassicAdmin, "classic-user-ext-attrs": groupClassicAdmin, "classic-vpp-accounts": groupClassicAdmin, "classic-vpp-assignments": groupClassicAdmin, diff --git a/internal/commands/pro/generated/backup_registry.go b/internal/commands/pro/generated/backup_registry.go index ea5679b6..c6b22cae 100644 --- a/internal/commands/pro/generated/backup_registry.go +++ b/internal/commands/pro/generated/backup_registry.go @@ -64,6 +64,7 @@ var BackupEndpoints = map[string]BackupEndpoint{ "classic-disk-encryption-configs": {ListPath: "/JSSResource/diskencryptionconfigurations", GetPath: "/JSSResource/diskencryptionconfigurations/id/{id}", IsClassic: true, WrapperKey: "diskencryptionconfigurations", SingularKey: "disk_encryption_configuration", ListSubset: "", NameField: "", IDField: ""}, "classic-distribution-points": {ListPath: "/JSSResource/distributionpoints", GetPath: "/JSSResource/distributionpoints/id/{id}", IsClassic: true, WrapperKey: "distributionpoints", SingularKey: "distribution_point", ListSubset: "", NameField: "", IDField: ""}, "classic-dock-items": {ListPath: "/JSSResource/dockitems", GetPath: "/JSSResource/dockitems/id/{id}", IsClassic: true, WrapperKey: "dockitems", SingularKey: "dock_item", ListSubset: "", NameField: "", IDField: ""}, + "classic-ebooks": {ListPath: "/JSSResource/ebooks", GetPath: "/JSSResource/ebooks/id/{id}", IsClassic: true, WrapperKey: "ebooks", SingularKey: "ebook", ListSubset: "", NameField: "", IDField: ""}, "classic-ibeacons": {ListPath: "/JSSResource/ibeacons", GetPath: "/JSSResource/ibeacons/id/{id}", IsClassic: true, WrapperKey: "ibeacons", SingularKey: "ibeacon", ListSubset: "", NameField: "", IDField: ""}, "classic-jwt-configs": {ListPath: "/JSSResource/jsonwebtokenconfigurations", GetPath: "/JSSResource/jsonwebtokenconfigurations/id/{id}", IsClassic: true, WrapperKey: "jsonwebtokenconfigurations", SingularKey: "json_web_token_configuration", ListSubset: "", NameField: "", IDField: ""}, "classic-licensed-software": {ListPath: "/JSSResource/licensedsoftware", GetPath: "/JSSResource/licensedsoftware/id/{id}", IsClassic: true, WrapperKey: "licensedsoftware", SingularKey: "licensed_software", ListSubset: "", NameField: "", IDField: ""}, @@ -86,6 +87,7 @@ var BackupEndpoints = map[string]BackupEndpoint{ "classic-restricted-software": {ListPath: "/JSSResource/restrictedsoftware", GetPath: "/JSSResource/restrictedsoftware/id/{id}", IsClassic: true, WrapperKey: "restrictedsoftware", SingularKey: "restricted_software", ListSubset: "", NameField: "", IDField: ""}, "classic-software-update-servers": {ListPath: "/JSSResource/softwareupdateservers", GetPath: "/JSSResource/softwareupdateservers/id/{id}", IsClassic: true, WrapperKey: "softwareupdateservers", SingularKey: "software_update_server", ListSubset: "", NameField: "", IDField: ""}, "classic-user-ext-attrs": {ListPath: "/JSSResource/userextensionattributes", GetPath: "/JSSResource/userextensionattributes/id/{id}", IsClassic: true, WrapperKey: "userextensionattributes", SingularKey: "user_extension_attribute", ListSubset: "", NameField: "", IDField: ""}, + "classic-user-groups": {ListPath: "/JSSResource/usergroups", GetPath: "/JSSResource/usergroups/id/{id}", IsClassic: true, WrapperKey: "usergroups", SingularKey: "user_group", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-accounts": {ListPath: "/JSSResource/vppaccounts", GetPath: "/JSSResource/vppaccounts/id/{id}", IsClassic: true, WrapperKey: "vppaccounts", SingularKey: "vpp_account", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-assignments": {ListPath: "/JSSResource/vppassignments", GetPath: "/JSSResource/vppassignments/id/{id}", IsClassic: true, WrapperKey: "vppassignments", SingularKey: "vpp_assignment", ListSubset: "", NameField: "", IDField: ""}, "classic-vpp-invitations": {ListPath: "/JSSResource/vppinvitations", GetPath: "/JSSResource/vppinvitations/id/{id}", IsClassic: true, WrapperKey: "vppinvitations", SingularKey: "vpp_invitation", ListSubset: "", NameField: "", IDField: ""}, diff --git a/internal/commands/pro/generated/classic_ebooks.go b/internal/commands/pro/generated/classic_ebooks.go new file mode 100644 index 00000000..5f44fbfc --- /dev/null +++ b/internal/commands/pro/generated/classic_ebooks.go @@ -0,0 +1,508 @@ +// Copyright 2026, Jamf Software LLC +// Code generated by jamf-cli generator (classic). DO NOT EDIT. +package generated + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" + "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" + "github.com/spf13/cobra" +) + +// NewClassicEbooksCmd creates the classic-ebooks command group +func NewClassicEbooksCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "classic-ebooks", + Short: "eBook distributions (Classic API)", + Long: `Manage ebook distributions via the Jamf Pro Classic API (/JSSResource/).`, + } + + cmd.AddCommand(newClassicEbooksListCmd(ctx)) + + cmd.AddCommand(newClassicEbooksGetCmd(ctx)) + + cmd.AddCommand(newClassicEbooksCreateCmd(ctx)) + + cmd.AddCommand(newClassicEbooksUpdateCmd(ctx)) + + cmd.AddCommand(newClassicEbooksDeleteCmd(ctx)) + + cmd.AddCommand(newClassicEbooksApplyCmd(ctx)) + + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "ebooks", + SingularKey: "ebook", + })) + + return cmd +} + +func newClassicEbooksListCmd(ctx *registry.CLIContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all ebooks", + Example: ` # List all ebooks + jamf-cli pro classic-ebooks list + + # List ebooks and extract IDs + jamf-cli pro classic-ebooks list --field id`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + resp, err := ctx.Client.Do(reqCtx, "GET", "/JSSResource/ebooks", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML list responses. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + items, err := xmlconv.ExtractListItems(body) + if err == nil { + jsonItems, err := json.Marshal(items) + if err == nil { + return ctx.Output.PrintRaw(jsonItems) + } + } + return ctx.Output.PrintRaw(body) + } + // JSON fallback + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["ebooks"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } +} + +func newClassicEbooksGetCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagName string + ) + + cmd := &cobra.Command{ + Use: "get []", + Short: "Get a ebook by ID", + Example: ` # Get a ebook by ID + jamf-cli pro classic-ebooks get 1 + + # Get a ebook by name + jamf-cli pro classic-ebooks get --name "Example" + + # Get a ebook and output as YAML + jamf-cli pro classic-ebooks get 1 -o yaml`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // Resolve lookup: check flags first, then positional ID + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/ebooks/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument, --name") + } + + resp, err := ctx.Client.Do(reqCtx, "GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML; pass through by default. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + if jsonBody, err := xmlconv.ToJSON(body); err == nil { + body = jsonBody + } + } + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["ebook"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + + return cmd +} + +func newClassicEbooksCreateCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a ebook", + Long: "Create a new ebook. Reads XML body from stdin.", + Example: ` # Create a ebook from XML + cat ebook.xml | jamf-cli pro classic-ebooks create`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ebooks/id/0", body) + + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + return cmd +} + +func newClassicEbooksUpdateCmd(ctx *registry.CLIContext) *cobra.Command { + var flagName string + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a ebook", + Long: "Update an existing ebook by ID. Reads XML body from stdin.", + Example: ` # Update a ebook from XML + cat ebook.xml | jamf-cli pro classic-ebooks update 1`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/ebooks/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument or --name") + } + + resp, err := ctx.Client.Do(reqCtx, "PUT", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + + return cmd +} + +func newClassicEbooksDeleteCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagYes bool + flagDryRun bool + flagName string + fromFile string + ) + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a ebook", + Example: ` # Delete a ebook (with confirmation) + jamf-cli pro classic-ebooks delete 1 + + # Delete by name + jamf-cli pro classic-ebooks delete --name "Example" --yes + + # Delete without confirmation prompt + jamf-cli pro classic-ebooks delete 1 --yes`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // --from-file: bulk delete from a file of IDs or names + if fromFile != "" { + entries, err := readDeleteFile(fromFile) + if err != nil { + return fmt.Errorf("reading --from-file: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("--from-file %q: no entries found", fromFile) + } + type bulkEntry struct{ id, label string } + bulk := make([]bulkEntry, 0, len(entries)) + noInputBulk, _ := cmd.Flags().GetBool("no-input") + for _, entry := range entries { + if isNumericID(entry) { + if entry == "0" { + return fmt.Errorf("--from-file: ID 0 is not valid (Jamf Pro uses 0 as a sentinel value)") + } + bulk = append(bulk, bulkEntry{id: entry, label: entry}) + } else { + var resolvedID string + if resolvedID == "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", entry, noInputBulk) + if err != nil { + return fmt.Errorf("resolving %q: %w", entry, err) + } + resolvedID = id + } + if resolvedID == "" { + return fmt.Errorf("no ebook found matching %q", entry) + } + bulk = append(bulk, bulkEntry{id: resolvedID, label: entry}) + } + } + // Deduplicate resolved IDs to avoid double-delete errors. + { + seen := make(map[string]bool, len(bulk)) + deduped := bulk[:0] + for _, e := range bulk { + if !seen[e.id] { + seen[e.id] = true + deduped = append(deduped, e) + } + } + bulk = deduped + } + if flagDryRun { + for _, e := range bulk { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete ebook %q (id: %s)\n", e.label, e.id) + } + return nil + } + if !flagYes { + if noInputBulk { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "⚠️ This will delete %d ebooks. Type 'yes' to confirm: ", len(bulk)) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { + return err + } + for _, e := range bulk { + delPath := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(e.id)) + resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) + if err != nil { + return fmt.Errorf("deleting %q (id: %s): %w", e.label, e.id, err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + fmt.Fprintf(os.Stderr, "Deleted ebook %q (id: %s)\n", e.label, e.id) + } else { + return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + } + } + cooldown.Record(ctx.ProfileName) + return nil + } + + // Resolve ID from --name or positional arg + var resolvedID string + noInput, _ := cmd.Flags().GetBool("no-input") + if flagName != "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", flagName, noInput) + if err != nil { + return err + } + if id == "" { + return fmt.Errorf("no ebook found with name %q", flagName) + } + resolvedID = id + } else if len(args) > 0 { + resolvedID = args[0] + } else { + return fmt.Errorf("provide an argument or --name") + } + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete ebook %s\n", resolvedID) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "This will delete ebook %s. Type 'yes' to confirm: ", resolvedID) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + if err := cooldown.Enforce(ctx.ProfileName, noInput, ctx.DestructiveCooldown); err != nil { + return err + } + path := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(resolvedID)) + + resp, err := ctx.Client.Do(reqCtx, "DELETE", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + cooldown.Record(ctx.ProfileName) + fmt.Fprintln(os.Stderr, "Deleted successfully") + return nil + } + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + cmd.Flags().StringVar(&flagName, "name", "", "Look up ebook by name") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to file listing IDs or names to delete (one per line, # comments ignored)") + cmd.MarkFlagsMutuallyExclusive("from-file", "name") + + return cmd +} + +func newClassicEbooksApplyCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + fromFile string + flagYes bool + flagDryRun bool + ) + + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or replace a ebook by name", + Long: `Create or replace a ebook. Reads XML from --from-file or stdin. + +The name field in the input XML is used to check if the resource already +exists. If it does, the resource is replaced (with confirmation). +If not, a new resource is created.`, + Example: ` # Apply a ebook from an XML file + jamf-cli pro classic-ebooks apply --from-file ebook.xml + + # Apply from stdin + cat ebook.xml | jamf-cli pro classic-ebooks apply + + # Apply without replacement confirmation + jamf-cli pro classic-ebooks apply --from-file ebook.xml --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + reqCtx := cmd.Context() + + // Read input + data, err := readApplyInput(fromFile) + + if err != nil { + return err + } + + // Extract name: for fetch-merge-put resources, --name is the primary + // input (body typically empty); fall back to XML name if flag absent. + var name string + + name, err = extractClassicName(data, "ebook") + if err != nil { + return err + } + + // Check if resource exists by name (read-only, runs even in dry-run) + noInput, _ := cmd.Flags().GetBool("no-input") + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "ebooks", "ebooks", name, noInput) + if err != nil { + return err + } + + if id == "" { + // Not found — create (not allowed for fetch-merge-put resources) + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would create ebook %q\n", name) + return nil + } + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/ebooks/id/0", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Created ebook %q\n", name) + return ctx.Output.PrintResponse(resp) + + } + + // Found — replace + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would replace ebook %q (id: %s)\n", name, id) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("ebook %q already exists (id: %s); use --yes to replace when --no-input is set", name, id) + } + fmt.Fprintf(os.Stderr, "ebook %q already exists (id: %s) and will be replaced. Type 'yes' to confirm: ", name, id) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + updatePath := fmt.Sprintf("/JSSResource/ebooks/id/%s", url.PathEscape(id)) + resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Replaced ebook %q (id: %s)\n", name, id) + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to XML input file (or pipe XML to stdin)") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt when replacing") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + + return cmd +} diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index 4fb50752..78641844 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -42,6 +42,7 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewClassicDiskEncryptionConfigsCmd(ctx)) root.AddCommand(NewClassicDistributionPointsCmd(ctx)) root.AddCommand(NewClassicDockItemsCmd(ctx)) + root.AddCommand(NewClassicEbooksCmd(ctx)) root.AddCommand(NewClassicGsxConnectionCmd(ctx)) root.AddCommand(NewClassicIbeaconsCmd(ctx)) root.AddCommand(NewClassicJwtConfigsCmd(ctx)) @@ -70,6 +71,7 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewClassicSmtpServerCmd(ctx)) root.AddCommand(NewClassicSoftwareUpdateServersCmd(ctx)) root.AddCommand(NewClassicUserExtAttrsCmd(ctx)) + root.AddCommand(NewClassicUserGroupsCmd(ctx)) root.AddCommand(NewClassicVppAccountsCmd(ctx)) root.AddCommand(NewClassicVppAssignmentsCmd(ctx)) root.AddCommand(NewClassicVppInvitationsCmd(ctx)) diff --git a/internal/commands/pro/generated/classic_restricted_software.go b/internal/commands/pro/generated/classic_restricted_software.go index 53f1b55c..a9025be9 100644 --- a/internal/commands/pro/generated/classic_restricted_software.go +++ b/internal/commands/pro/generated/classic_restricted_software.go @@ -41,6 +41,7 @@ func NewClassicRestrictedSoftwareCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ APIPath: "restrictedsoftware", SingularKey: "restricted_software", + NoSubsetPut: true, })) return cmd diff --git a/internal/commands/pro/generated/classic_user_groups.go b/internal/commands/pro/generated/classic_user_groups.go new file mode 100644 index 00000000..9b4154b8 --- /dev/null +++ b/internal/commands/pro/generated/classic_user_groups.go @@ -0,0 +1,502 @@ +// Copyright 2026, Jamf Software LLC +// Code generated by jamf-cli generator (classic). DO NOT EDIT. +package generated + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" + "github.com/spf13/cobra" +) + +// NewClassicUserGroupsCmd creates the classic-user-groups command group +func NewClassicUserGroupsCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "classic-user-groups", + Short: "JSS user groups (referenced in policy/ebook scope limitations) (Classic API)", + Long: `Manage jss user groups (referenced in policy/ebook scope limitations) via the Jamf Pro Classic API (/JSSResource/).`, + } + + cmd.AddCommand(newClassicUserGroupsListCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsGetCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsCreateCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsUpdateCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsDeleteCmd(ctx)) + + cmd.AddCommand(newClassicUserGroupsApplyCmd(ctx)) + + return cmd +} + +func newClassicUserGroupsListCmd(ctx *registry.CLIContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all usergroups", + Example: ` # List all usergroups + jamf-cli pro classic-user-groups list + + # List usergroups and extract IDs + jamf-cli pro classic-user-groups list --field id`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + resp, err := ctx.Client.Do(reqCtx, "GET", "/JSSResource/usergroups", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML list responses. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + items, err := xmlconv.ExtractListItems(body) + if err == nil { + jsonItems, err := json.Marshal(items) + if err == nil { + return ctx.Output.PrintRaw(jsonItems) + } + } + return ctx.Output.PrintRaw(body) + } + // JSON fallback + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["usergroups"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } +} + +func newClassicUserGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagName string + ) + + cmd := &cobra.Command{ + Use: "get []", + Short: "Get a user_group by ID", + Example: ` # Get a user_group by ID + jamf-cli pro classic-user-groups get 1 + + # Get a user_group by name + jamf-cli pro classic-user-groups get --name "Example" + + # Get a user_group and output as YAML + jamf-cli pro classic-user-groups get 1 -o yaml`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // Resolve lookup: check flags first, then positional ID + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/usergroups/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument, --name") + } + + resp, err := ctx.Client.Do(reqCtx, "GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // Classic API returns XML; pass through by default. + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. + // -o xml = pretty-printed XML, -o raw = exact wire bytes. + if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { + return ctx.Output.PrintBytes(body) + } + if xmlconv.IsXML(body) { + if jsonBody, err := xmlconv.ToJSON(body); err == nil { + body = jsonBody + } + } + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if inner, ok := wrapper["user_group"]; ok { + return ctx.Output.PrintRaw(inner) + } + } + return ctx.Output.PrintRaw(body) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + + return cmd +} + +func newClassicUserGroupsCreateCmd(ctx *registry.CLIContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a user_group", + Long: "Create a new user_group. Reads XML body from stdin.", + Example: ` # Create a user_group from XML + cat user_group.xml | jamf-cli pro classic-user-groups create`, + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/usergroups/id/0", body) + + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + return cmd +} + +func newClassicUserGroupsUpdateCmd(ctx *registry.CLIContext) *cobra.Command { + var flagName string + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a user_group", + Long: "Update an existing user_group by ID. Reads XML body from stdin.", + Example: ` # Update a user_group from XML + cat user_group.xml | jamf-cli pro classic-user-groups update 1`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + var body io.Reader + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body = os.Stdin + } else { + return fmt.Errorf("request body required on stdin (pipe XML input)") + } + + var path string + if flagName != "" { + path = fmt.Sprintf("/JSSResource/usergroups/name/%s", url.PathEscape(flagName)) + } else if len(args) > 0 { + path = fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(args[0])) + } else { + return fmt.Errorf("provide an argument or --name") + } + + resp, err := ctx.Client.Do(reqCtx, "PUT", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + + return cmd +} + +func newClassicUserGroupsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + flagYes bool + flagDryRun bool + flagName string + fromFile string + ) + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a user_group", + Example: ` # Delete a user_group (with confirmation) + jamf-cli pro classic-user-groups delete 1 + + # Delete by name + jamf-cli pro classic-user-groups delete --name "Example" --yes + + # Delete without confirmation prompt + jamf-cli pro classic-user-groups delete 1 --yes`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reqCtx := cmd.Context() + + // --from-file: bulk delete from a file of IDs or names + if fromFile != "" { + entries, err := readDeleteFile(fromFile) + if err != nil { + return fmt.Errorf("reading --from-file: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("--from-file %q: no entries found", fromFile) + } + type bulkEntry struct{ id, label string } + bulk := make([]bulkEntry, 0, len(entries)) + noInputBulk, _ := cmd.Flags().GetBool("no-input") + for _, entry := range entries { + if isNumericID(entry) { + if entry == "0" { + return fmt.Errorf("--from-file: ID 0 is not valid (Jamf Pro uses 0 as a sentinel value)") + } + bulk = append(bulk, bulkEntry{id: entry, label: entry}) + } else { + var resolvedID string + if resolvedID == "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", entry, noInputBulk) + if err != nil { + return fmt.Errorf("resolving %q: %w", entry, err) + } + resolvedID = id + } + if resolvedID == "" { + return fmt.Errorf("no user_group found matching %q", entry) + } + bulk = append(bulk, bulkEntry{id: resolvedID, label: entry}) + } + } + // Deduplicate resolved IDs to avoid double-delete errors. + { + seen := make(map[string]bool, len(bulk)) + deduped := bulk[:0] + for _, e := range bulk { + if !seen[e.id] { + seen[e.id] = true + deduped = append(deduped, e) + } + } + bulk = deduped + } + if flagDryRun { + for _, e := range bulk { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete user_group %q (id: %s)\n", e.label, e.id) + } + return nil + } + if !flagYes { + if noInputBulk { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "⚠️ This will delete %d usergroups. Type 'yes' to confirm: ", len(bulk)) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + if err := cooldown.Enforce(ctx.ProfileName, noInputBulk, ctx.DestructiveCooldown); err != nil { + return err + } + for _, e := range bulk { + delPath := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(e.id)) + resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) + if err != nil { + return fmt.Errorf("deleting %q (id: %s): %w", e.label, e.id, err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + fmt.Fprintf(os.Stderr, "Deleted user_group %q (id: %s)\n", e.label, e.id) + } else { + return fmt.Errorf("delete %q (id: %s): HTTP %d", e.label, e.id, resp.StatusCode) + } + } + cooldown.Record(ctx.ProfileName) + return nil + } + + // Resolve ID from --name or positional arg + var resolvedID string + noInput, _ := cmd.Flags().GetBool("no-input") + if flagName != "" { + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", flagName, noInput) + if err != nil { + return err + } + if id == "" { + return fmt.Errorf("no user_group found with name %q", flagName) + } + resolvedID = id + } else if len(args) > 0 { + resolvedID = args[0] + } else { + return fmt.Errorf("provide an argument or --name") + } + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would delete user_group %s\n", resolvedID) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("destructive operation requires --yes when --no-input is set") + } + fmt.Fprintf(os.Stderr, "This will delete user_group %s. Type 'yes' to confirm: ", resolvedID) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + if err := cooldown.Enforce(ctx.ProfileName, noInput, ctx.DestructiveCooldown); err != nil { + return err + } + path := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(resolvedID)) + + resp, err := ctx.Client.Do(reqCtx, "DELETE", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + cooldown.Record(ctx.ProfileName) + fmt.Fprintln(os.Stderr, "Deleted successfully") + return nil + } + + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + cmd.Flags().StringVar(&flagName, "name", "", "Look up user_group by name") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to file listing IDs or names to delete (one per line, # comments ignored)") + cmd.MarkFlagsMutuallyExclusive("from-file", "name") + + return cmd +} + +func newClassicUserGroupsApplyCmd(ctx *registry.CLIContext) *cobra.Command { + var ( + fromFile string + flagYes bool + flagDryRun bool + ) + + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or replace a user_group by name", + Long: `Create or replace a user_group. Reads XML from --from-file or stdin. + +The name field in the input XML is used to check if the resource already +exists. If it does, the resource is replaced (with confirmation). +If not, a new resource is created.`, + Example: ` # Apply a user_group from an XML file + jamf-cli pro classic-user-groups apply --from-file user_group.xml + + # Apply from stdin + cat user_group.xml | jamf-cli pro classic-user-groups apply + + # Apply without replacement confirmation + jamf-cli pro classic-user-groups apply --from-file user_group.xml --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + reqCtx := cmd.Context() + + // Read input + data, err := readApplyInput(fromFile) + + if err != nil { + return err + } + + // Extract name: for fetch-merge-put resources, --name is the primary + // input (body typically empty); fall back to XML name if flag absent. + var name string + + name, err = extractClassicName(data, "user_group") + if err != nil { + return err + } + + // Check if resource exists by name (read-only, runs even in dry-run) + noInput, _ := cmd.Flags().GetBool("no-input") + id, err := resolveClassicNameToIDForApply(reqCtx, ctx.Client, "usergroups", "usergroups", name, noInput) + if err != nil { + return err + } + + if id == "" { + // Not found — create (not allowed for fetch-merge-put resources) + + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would create user_group %q\n", name) + return nil + } + resp, err := ctx.Client.Do(reqCtx, "POST", "/JSSResource/usergroups/id/0", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Created user_group %q\n", name) + return ctx.Output.PrintResponse(resp) + + } + + // Found — replace + if flagDryRun { + fmt.Fprintf(os.Stderr, "[dry-run] Would replace user_group %q (id: %s)\n", name, id) + return nil + } + if !flagYes { + if noInput { + return fmt.Errorf("user_group %q already exists (id: %s); use --yes to replace when --no-input is set", name, id) + } + fmt.Fprintf(os.Stderr, "user_group %q already exists (id: %s) and will be replaced. Type 'yes' to confirm: ", name, id) + var confirm string + fmt.Scanln(&confirm) + if confirm != "yes" { + return fmt.Errorf("aborted") + } + } + + updatePath := fmt.Sprintf("/JSSResource/usergroups/id/%s", url.PathEscape(id)) + resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "Replaced user_group %q (id: %s)\n", name, id) + return ctx.Output.PrintResponse(resp) + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to XML input file (or pipe XML to stdin)") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation prompt when replacing") + cmd.Flags().BoolVarP(&flagDryRun, "dry-run", "n", false, "Preview without executing") + + return cmd +} diff --git a/internal/commands/pro/generated/classic_vpp_assignments.go b/internal/commands/pro/generated/classic_vpp_assignments.go index 75d806cd..de22e513 100644 --- a/internal/commands/pro/generated/classic_vpp_assignments.go +++ b/internal/commands/pro/generated/classic_vpp_assignments.go @@ -12,6 +12,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" "github.com/spf13/cobra" ) @@ -34,6 +35,13 @@ func NewClassicVppAssignmentsCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newClassicVppAssignmentsDeleteCmd(ctx)) + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "vppassignments", + SingularKey: "vpp_assignment", + ResolveByList: true, + NoSubsetPut: true, + })) + return cmd } diff --git a/internal/commands/pro/generated/classic_vpp_invitations.go b/internal/commands/pro/generated/classic_vpp_invitations.go index 691e1d65..39845bfb 100644 --- a/internal/commands/pro/generated/classic_vpp_invitations.go +++ b/internal/commands/pro/generated/classic_vpp_invitations.go @@ -12,6 +12,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/cooldown" "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/Jamf-Concepts/jamf-cli/internal/scope" "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" "github.com/spf13/cobra" ) @@ -32,6 +33,13 @@ func NewClassicVppInvitationsCmd(ctx *registry.CLIContext) *cobra.Command { cmd.AddCommand(newClassicVppInvitationsDeleteCmd(ctx)) + cmd.AddCommand(scope.NewScopeCmd(ctx, scope.Resource{ + APIPath: "vppinvitations", + SingularKey: "vpp_invitation", + ResolveByList: true, + NoSubsetPut: true, + })) + return cmd } diff --git a/internal/commands/pro/generated/provenance.go b/internal/commands/pro/generated/provenance.go index 7995e011..4973b925 100644 --- a/internal/commands/pro/generated/provenance.go +++ b/internal/commands/pro/generated/provenance.go @@ -173,5 +173,5 @@ var Sources = []SpecSource{ {File: "specs/VppLocations.yaml", SHA256: "d9b41c79c0cdba81706b16c8e6d0b86b8048d40d6d55ec66618af0ac00c533d0"}, {File: "specs/VppSubscriptions.yaml", SHA256: "184b834aeb0d21cf190050294f7869d57b482bbe1834ad5a1bd79dcfdeef7a55"}, {File: "specs/_MonolithLibrary.yaml", SHA256: "9d777dc71e1909d20db05b31451246a805565c856d7b2fc50d38ccb33269a89a"}, - {File: "specs/classic/resources.yaml", SHA256: "2c7679f3c1e8571d9f17b82e5f8b9d00c53735680ad53cfd90cda6d6edb76cda"}, + {File: "specs/classic/resources.yaml", SHA256: "95d378ff42e37d98eecdd780702fac7c332b8de2aa0b5b20ce3795e009d93e73"}, } diff --git a/internal/commands/pro/generated/smoke_registry.go b/internal/commands/pro/generated/smoke_registry.go index 471dce9c..b0a15e2d 100644 --- a/internal/commands/pro/generated/smoke_registry.go +++ b/internal/commands/pro/generated/smoke_registry.go @@ -90,6 +90,8 @@ func AllSmokeEndpoints() []SmokeEndpoint { {Resource: "classic-distribution-points", Operation: "list", Method: "GET", Path: "/JSSResource/distributionpoints", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "distributionpoints", SingularKey: ""}, {Resource: "classic-dock-items", Operation: "get", Method: "GET", Path: "/JSSResource/dockitems/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "dock_item"}, {Resource: "classic-dock-items", Operation: "list", Method: "GET", Path: "/JSSResource/dockitems", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "dockitems", SingularKey: ""}, + {Resource: "classic-ebooks", Operation: "get", Method: "GET", Path: "/JSSResource/ebooks/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "ebook"}, + {Resource: "classic-ebooks", Operation: "list", Method: "GET", Path: "/JSSResource/ebooks", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "ebooks", SingularKey: ""}, {Resource: "classic-gsx-connection", Operation: "get", Method: "GET", Path: "/JSSResource/gsxconnection/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "gsx_connection"}, {Resource: "classic-ibeacons", Operation: "get", Method: "GET", Path: "/JSSResource/ibeacons/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "ibeacon"}, {Resource: "classic-ibeacons", Operation: "list", Method: "GET", Path: "/JSSResource/ibeacons", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "ibeacons", SingularKey: ""}, @@ -141,6 +143,8 @@ func AllSmokeEndpoints() []SmokeEndpoint { {Resource: "classic-software-update-servers", Operation: "list", Method: "GET", Path: "/JSSResource/softwareupdateservers", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "softwareupdateservers", SingularKey: ""}, {Resource: "classic-user-ext-attrs", Operation: "get", Method: "GET", Path: "/JSSResource/userextensionattributes/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "user_extension_attribute"}, {Resource: "classic-user-ext-attrs", Operation: "list", Method: "GET", Path: "/JSSResource/userextensionattributes", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "userextensionattributes", SingularKey: ""}, + {Resource: "classic-user-groups", Operation: "get", Method: "GET", Path: "/JSSResource/usergroups/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "user_group"}, + {Resource: "classic-user-groups", Operation: "list", Method: "GET", Path: "/JSSResource/usergroups", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "usergroups", SingularKey: ""}, {Resource: "classic-vpp-accounts", Operation: "get", Method: "GET", Path: "/JSSResource/vppaccounts/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "vpp_account"}, {Resource: "classic-vpp-accounts", Operation: "list", Method: "GET", Path: "/JSSResource/vppaccounts", IsList: true, HasPathParams: false, IsClassic: true, WrapperKey: "vppaccounts", SingularKey: ""}, {Resource: "classic-vpp-assignments", Operation: "get", Method: "GET", Path: "/JSSResource/vppassignments/id/{id}", IsList: false, HasPathParams: true, IsClassic: true, WrapperKey: "", SingularKey: "vpp_assignment"}, diff --git a/internal/scope/commands.go b/internal/scope/commands.go index 1ccaef63..e2318672 100644 --- a/internal/scope/commands.go +++ b/internal/scope/commands.go @@ -88,6 +88,10 @@ func newScopeAddCmd(ctx *registry.CLIContext, res Resource) *cobra.Command { return err } + if err := VerifyItemInScope(cmd.Context(), ctx.Client, res, args[0], section, target.FlagName, target.Name, true); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Added %s %q to %s scope of %q\n", target.FlagName, target.Name, section, args[0]) return OutputScope(ctx.Output, s, res.SingularKey, outputFormat(cmd)) @@ -135,6 +139,10 @@ func newScopeRemoveCmd(ctx *registry.CLIContext, res Resource) *cobra.Command { return err } + if err := VerifyItemInScope(cmd.Context(), ctx.Client, res, args[0], section, target.FlagName, target.Name, false); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Removed %s %q from %s scope of %q\n", target.FlagName, target.Name, section, args[0]) return OutputScope(ctx.Output, s, res.SingularKey, outputFormat(cmd)) diff --git a/internal/scope/scope.go b/internal/scope/scope.go index bec6ac86..9baa89bb 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -19,10 +19,24 @@ import ( ) // FetchScope performs a GET on a Classic API resource by name and returns -// the resource's ID and parsed scope. +// the resource's ID and parsed scope. When res.ResolveByList is true it lists +// all records to resolve name→ID first (for resources with no /name/ endpoint). func FetchScope(ctx context.Context, client registry.HTTPClient, res Resource, name string) (string, *ScopeXML, error) { - path := fmt.Sprintf("/JSSResource/%s/name/%s", res.APIPath, url.PathEscape(name)) - resp, err := client.Do(ctx, "GET", path, nil) + var fetchPath string + var resolvedID string + + if res.ResolveByList { + id, err := resolveNameToID(ctx, client, res.APIPath, res.SingularKey, name) + if err != nil { + return "", nil, err + } + resolvedID = id + fetchPath = fmt.Sprintf("/JSSResource/%s/id/%s", res.APIPath, url.PathEscape(id)) + } else { + fetchPath = fmt.Sprintf("/JSSResource/%s/name/%s", res.APIPath, url.PathEscape(name)) + } + + resp, err := client.Do(ctx, "GET", fetchPath, nil) if err != nil { return "", nil, fmt.Errorf("fetching %s %q: %w", res.SingularKey, name, err) } @@ -38,15 +52,67 @@ func FetchScope(ctx context.Context, client registry.HTTPClient, res Resource, n return "", nil, fmt.Errorf("parsing %s XML: %w", res.SingularKey, err) } - if envelope.General.ID == 0 { + if res.ResolveByList { + return resolvedID, &envelope.Scope, nil + } + + if envelope.General.ID == "" { return "", nil, fmt.Errorf("no ID in %s %q", res.SingularKey, name) } + return envelope.General.ID, &envelope.Scope, nil +} - return fmt.Sprintf("%d", envelope.General.ID), &envelope.Scope, nil +// resolveNameToID lists all records at the resource root and returns the ID +// of the first record whose matches (case-insensitive). +func resolveNameToID(ctx context.Context, client registry.HTTPClient, apiPath, singularKey, name string) (string, error) { + resp, err := client.Do(ctx, "GET", "/JSSResource/"+apiPath, nil) + if err != nil { + return "", fmt.Errorf("listing %s: %w", singularKey, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return "", fmt.Errorf("reading list response: %w", err) + } + + d := xml.NewDecoder(bytes.NewReader(body)) + depth := 0 + for { + tok, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("parsing %s list XML: %w", singularKey, err) + } + switch t := tok.(type) { + case xml.StartElement: + depth++ + if depth == 2 { + var it struct { + ID string `xml:"id"` + Name string `xml:"name"` + } + if decErr := d.DecodeElement(&it, &t); decErr == nil && strings.EqualFold(it.Name, name) { + return it.ID, nil + } + depth-- // DecodeElement consumed the end element + } + case xml.EndElement: + depth-- + } + } + return "", fmt.Errorf("%s %q not found", singularKey, name) } -// PutScope writes an updated scope back to the Classic API via subset PUT. +// PutScope writes an updated scope back to the Classic API. Uses the subset/Scope +// endpoint by default; falls back to a full document PUT when res.NoSubsetPut is set. func PutScope(ctx context.Context, client registry.HTTPClient, res Resource, id string, s *ScopeXML) error { + if res.NoSubsetPut { + return putFullDocument(ctx, client, res, id, s) + } + envelope := scopeUpdateXML{ XMLName: xml.Name{Local: res.SingularKey}, Scope: *s, @@ -67,6 +133,124 @@ func PutScope(ctx context.Context, client registry.HTTPClient, res Resource, id return nil } +// putFullDocument fetches the full resource XML, splices in the updated scope, +// and PUTs the whole document back. Used for Classic API resources that do not +// support the /subset/Scope endpoint (e.g. vppassignments). +func putFullDocument(ctx context.Context, client registry.HTTPClient, res Resource, id string, s *ScopeXML) error { + fetchPath := fmt.Sprintf("/JSSResource/%s/id/%s", res.APIPath, url.PathEscape(id)) + resp, err := client.Do(ctx, "GET", fetchPath, nil) + if err != nil { + return fmt.Errorf("fetching %s for update: %w", res.SingularKey, err) + } + defer func() { _ = resp.Body.Close() }() + + original, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return fmt.Errorf("reading %s: %w", res.SingularKey, err) + } + + updated, err := replaceScopeInXML(original, s) + if err != nil { + return fmt.Errorf("replacing scope: %w", err) + } + + putResp, err := client.Do(ctx, "PUT", fetchPath, bytes.NewReader(updated)) + if err != nil { + return fmt.Errorf("updating scope: %w", err) + } + _ = putResp.Body.Close() + return nil +} + +// replaceScopeInXML finds the ... block in the XML bytes and +// replaces it with the marshalled newScope. Classic API XML is well-formed so +// simple byte search is safe. +func replaceScopeInXML(original []byte, newScope *ScopeXML) ([]byte, error) { + newScopeXML, err := xml.MarshalIndent(newScope, " ", " ") + if err != nil { + return nil, fmt.Errorf("marshalling scope: %w", err) + } + + scopeOpen := bytes.Index(original, []byte("")) + if scopeOpen == -1 { + return nil, fmt.Errorf("no element found in resource XML") + } + closeTag := []byte("") + rel := bytes.Index(original[scopeOpen:], closeTag) + if rel == -1 { + return nil, fmt.Errorf("no closing tag found in resource XML") + } + scopeClose := scopeOpen + rel + len(closeTag) + + var buf bytes.Buffer + buf.Write(original[:scopeOpen]) + buf.Write(newScopeXML) + buf.Write(original[scopeClose:]) + return buf.Bytes(), nil +} + +// VerifyItemInScope refetches scope after a PUT and confirms the given item is +// (or is not) present, depending on `expectPresent`. Catches the silent-drop +// case where the server returns 200/201 but discards a scope element that +// doesn't apply to the resource type (e.g. computer_groups limitation on a +// policy, network_segment on a VPP assignment). +func VerifyItemInScope(ctx context.Context, client registry.HTTPClient, res Resource, name, section, flagName, itemName string, expectPresent bool) error { + _, s, err := FetchScope(ctx, client, res, name) + if err != nil { + return fmt.Errorf("verifying scope: %w", err) + } + + if isPolicyLimitUserGroup(res.SingularKey, section, flagName) { + ltuPresent := false + if s.LimitToUsers != nil { + for _, g := range s.LimitToUsers.UserGroups.Items { + if strings.EqualFold(g, itemName) { + ltuPresent = true + break + } + } + } + limPresent := false + if s.Limitations != nil { + for _, item := range s.Limitations.UserGroups.Items { + if strings.EqualFold(item.Name, itemName) { + limPresent = true + break + } + } + } + present := ltuPresent || limPresent + if present != expectPresent { + return silentDropError(res.SingularKey, section, flagName, itemName, expectPresent) + } + return nil + } + + items := readScopeItems(s, section, flagName) + present := false + if items != nil { + for _, item := range items.Items { + if strings.EqualFold(item.Name, itemName) || + item.ID == itemName || + strings.EqualFold(item.UDID, itemName) { + present = true + break + } + } + } + if present != expectPresent { + return silentDropError(res.SingularKey, section, flagName, itemName, expectPresent) + } + return nil +} + +func silentDropError(singularKey, section, flagName, itemName string, expectedPresent bool) error { + if expectedPresent { + return fmt.Errorf("server accepted PUT but did not persist --%s %q in %s scope (identifier did not resolve to an existing record, or resource type %q does not support this scope element)", flagName, itemName, section, singularKey) + } + return fmt.Errorf("server accepted PUT but did not remove --%s %q from %s scope (resource type %q may not allow modification of this scope element)", flagName, itemName, section, singularKey) +} + // AddToScope adds a named item to the given scope section. Returns true if the // item was added, false if already present (idempotent no-op). func AddToScope(s *ScopeXML, singularKey, section, flagName, name string) bool { @@ -86,7 +270,7 @@ func AddToScope(s *ScopeXML, singularKey, section, flagName, name string) bool { } if items.ElemName == "" { - items.ElemName = flagToElemName[flagName] + items.ElemName = resolveElemName(section, flagName) } items.Items = append(items.Items, NamedItem{Name: name}) return true @@ -139,15 +323,22 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { if s.AllComputers { rows = append(rows, map[string]any{"section": "target", "type": "all_computers", "name": "true"}) } + if s.AllMobileDevices { + rows = append(rows, map[string]any{"section": "target", "type": "all_mobile_devices", "name": "true"}) + } if s.AllJSSUsers { rows = append(rows, map[string]any{"section": "target", "type": "all_jss_users", "name": "true"}) } appendNamedRows(&rows, "target", "computer", s.Computers.Items) appendNamedRows(&rows, "target", "computer_group", s.ComputerGroups.Items) + appendNamedRows(&rows, "target", "mobile_device", s.MobileDevices.Items) appendNamedRows(&rows, "target", "mobile_device_group", s.MobileDeviceGroups.Items) appendNamedRows(&rows, "target", "building", s.Buildings.Items) appendNamedRows(&rows, "target", "department", s.Departments.Items) + appendNamedRows(&rows, "target", "jss_user", s.JSSUsers.Items) + appendNamedRows(&rows, "target", "jss_user_group", s.JSSUserGroups.Items) + appendNamedRows(&rows, "target", "class", s.Classes.Items) // Policy special case: limit_to_users holds plain strings if singularKey == "policy" && s.LimitToUsers != nil { @@ -157,61 +348,92 @@ func FlattenScope(s *ScopeXML, singularKey string) []map[string]any { } if s.Limitations != nil { + appendNamedRows(&rows, "limitation", "user", s.Limitations.Users.Items) appendNamedRows(&rows, "limitation", "network_segment", s.Limitations.NetworkSegments.Items) // For policies, user groups are already emitted from limit_to_users above; // limitations/user_groups is a server-side mirror, so skip it to avoid duplicates. if singularKey != "policy" { appendNamedRows(&rows, "limitation", "user_group", s.Limitations.UserGroups.Items) } - appendNamedRows(&rows, "limitation", "computer_group", s.Limitations.ComputerGroups.Items) + appendNamedRows(&rows, "limitation", "ibeacon", s.Limitations.IBeacons.Items) } if s.Exclusions != nil { appendNamedRows(&rows, "exclusion", "computer", s.Exclusions.Computers.Items) appendNamedRows(&rows, "exclusion", "computer_group", s.Exclusions.ComputerGroups.Items) + appendNamedRows(&rows, "exclusion", "mobile_device", s.Exclusions.MobileDevices.Items) appendNamedRows(&rows, "exclusion", "mobile_device_group", s.Exclusions.MobileDeviceGroups.Items) + appendNamedRows(&rows, "exclusion", "user", s.Exclusions.Users.Items) appendNamedRows(&rows, "exclusion", "user_group", s.Exclusions.UserGroups.Items) + appendNamedRows(&rows, "exclusion", "jss_user", s.Exclusions.JSSUsers.Items) + appendNamedRows(&rows, "exclusion", "jss_user_group", s.Exclusions.JSSUserGroups.Items) appendNamedRows(&rows, "exclusion", "network_segment", s.Exclusions.NetworkSegments.Items) appendNamedRows(&rows, "exclusion", "building", s.Exclusions.Buildings.Items) appendNamedRows(&rows, "exclusion", "department", s.Exclusions.Departments.Items) + appendNamedRows(&rows, "exclusion", "ibeacon", s.Exclusions.IBeacons.Items) } return rows } // ValidateScopeCombination checks that the given section/flag combination is valid -// for the resource type. +// for the resource type. The acceptance rules below reflect live testing of every +// flag against every scope-enabled Classic resource (see docs/solutions/). +// +// Notable rules: +// - `--user-group` is only valid in limitation/exclusion (LDAP/AD group). Use +// `--jss-user-group` to target a JSS user group; the previous overload of +// `--user-group` for target was ambiguous and made wrong-section mistakes +// undetectable until a GET roundtrip. +// - Restricted software is computer-only: it accepts no limitations, and only +// computer-group / building / department for target and exclusion. The +// server silently drops jss_user_groups / all_jss_users / user_groups for +// this resource type. +// - `--computer` is only valid in target/exclusion (no concept of a "computer +// limitation"). `--mobile-device` same. +// - `--user` (inventory username, free-text) is only valid in +// limitation/exclusion. func ValidateScopeCombination(singularKey, section, flagName string) error { isRestricted := singularKey == "restricted_software" switch section { case "target": + if isRestricted { + switch flagName { + case "computer", "computer-group", "building", "department": + return nil + } + return fmt.Errorf("--%s is not valid as a target for restricted software; use --computer, --computer-group, --building, or --department", flagName) + } switch flagName { - case "computer-group", "mobile-device-group", "building", "department": + case "computer", "computer-group", "mobile-device", "mobile-device-group", + "building", "department", "jss-user-group", "jss-user": return nil } - return fmt.Errorf("--%s is not valid as a target; use --computer-group, --mobile-device-group, --building, or --department", flagName) + return fmt.Errorf("--%s is not valid as a target; use --computer, --computer-group, --mobile-device, --mobile-device-group, --building, --department, --jss-user-group, or --jss-user", flagName) case "limitation": if isRestricted { return fmt.Errorf("restricted software does not support limitations") } switch flagName { - case "network-segment", "user-group", "computer-group": + case "network-segment", "user", "user-group": return nil } - return fmt.Errorf("--%s is not valid as a limitation; use --network-segment, --user-group, or --computer-group", flagName) + return fmt.Errorf("--%s is not valid as a limitation; use --network-segment, --user, or --user-group", flagName) case "exclusion": if isRestricted { switch flagName { - case "computer-group", "building", "department": + case "computer", "computer-group", "building", "department": return nil } - return fmt.Errorf("--%s is not valid as an exclusion for restricted software; use --computer-group, --building, or --department", flagName) + return fmt.Errorf("--%s is not valid as an exclusion for restricted software; use --computer, --computer-group, --building, or --department", flagName) } switch flagName { - case "computer-group", "mobile-device-group", "user-group", "network-segment", "building", "department": + case "computer", "computer-group", "mobile-device", "mobile-device-group", + "user", "user-group", "jss-user-group", "jss-user", + "network-segment", "building", "department": return nil } return fmt.Errorf("--%s is not valid as an exclusion", flagName) @@ -232,7 +454,7 @@ func DetermineScopeTarget(cmd *cobra.Command) (ScopeTarget, error) { } } if count == 0 { - return ScopeTarget{}, fmt.Errorf("specify one of: --computer-group, --mobile-device-group, --building, --department, --network-segment, --user-group") + return ScopeTarget{}, fmt.Errorf("specify one of: --computer, --computer-group, --mobile-device, --mobile-device-group, --building, --department, --network-segment, --user, --user-group, --jss-user-group, --jss-user") } if count > 1 { return ScopeTarget{}, fmt.Errorf("specify only one scopeable type per invocation") @@ -246,12 +468,17 @@ func AddScopeFlags(cmd *cobra.Command, section *string) { _ = cmd.RegisterFlagCompletionFunc("section", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"target", "limitation", "exclusion"}, cobra.ShellCompDirectiveNoFileComp }) + cmd.Flags().String("computer", "", "individual computer (id, name, or UDID); target/exclusion only") cmd.Flags().String("computer-group", "", "computer group name") + cmd.Flags().String("mobile-device", "", "individual mobile device (id, name, or UDID); target/exclusion only") cmd.Flags().String("mobile-device-group", "", "mobile device group name") cmd.Flags().String("building", "", "building name") cmd.Flags().String("department", "", "department name") - cmd.Flags().String("network-segment", "", "network segment name") - cmd.Flags().String("user-group", "", "user group name") + cmd.Flags().String("network-segment", "", "network segment name (limitations/exclusions)") + cmd.Flags().String("user", "", "inventory username (free-text, limitations/exclusions only)") + cmd.Flags().String("user-group", "", "LDAP/directory user group name (limitations/exclusions only — use --jss-user-group for target)") + cmd.Flags().String("jss-user-group", "", "JSS user group name (target/exclusion)") + cmd.Flags().String("jss-user", "", "JSS user name (target/exclusion)") } // ─── Internal helpers ───────────────────────────────────────────────────────── @@ -356,14 +583,22 @@ func readScopeItems(s *ScopeXML, section, flagName string) *ScopeItemSlice { func targetItems(s *ScopeXML, flagName string) *ScopeItemSlice { switch flagName { + case "computer": + return &s.Computers case "computer-group": return &s.ComputerGroups + case "mobile-device": + return &s.MobileDevices case "mobile-device-group": return &s.MobileDeviceGroups case "building": return &s.Buildings case "department": return &s.Departments + case "jss-user-group": + return &s.JSSUserGroups + case "jss-user": + return &s.JSSUsers } return nil } @@ -372,22 +607,32 @@ func limitationItems(lim *LimitationsXML, flagName string) *ScopeItemSlice { switch flagName { case "network-segment": return &lim.NetworkSegments + case "user": + return &lim.Users case "user-group": return &lim.UserGroups - case "computer-group": - return &lim.ComputerGroups } return nil } func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { switch flagName { + case "computer": + return &exc.Computers case "computer-group": return &exc.ComputerGroups + case "mobile-device": + return &exc.MobileDevices case "mobile-device-group": return &exc.MobileDeviceGroups + case "user": + return &exc.Users case "user-group": return &exc.UserGroups + case "jss-user-group": + return &exc.JSSUserGroups + case "jss-user": + return &exc.JSSUsers case "network-segment": return &exc.NetworkSegments case "building": @@ -398,6 +643,11 @@ func exclusionItems(exc *ExclusionsXML, flagName string) *ScopeItemSlice { return nil } +// resolveElemName returns the XML child element name for a new scope item. +func resolveElemName(section, flagName string) string { + return flagToElemName[flagName] +} + func appendNamedRows(rows *[]map[string]any, section, typeName string, items []NamedItem) { for _, item := range items { if item.Name != "" { diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go index 49a1aff0..ddbaf4d0 100644 --- a/internal/scope/scope_test.go +++ b/internal/scope/scope_test.go @@ -59,8 +59,8 @@ func TestScopeXML_UnmarshalPolicy(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - if env.General.ID != 42 { - t.Errorf("general.id = %d, want 42", env.General.ID) + if env.General.ID != "42" { + t.Errorf("general.id = %s, want 42", env.General.ID) } if !env.Scope.AllComputers { t.Error("all_computers should be true") @@ -99,7 +99,7 @@ func TestScopeXML_MarshalRoundTrip(t *testing.T) { s := ScopeXML{ AllComputers: true, ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Group A"}, {Name: "Group B"}}, + Items: []NamedItem{{ID: "1", Name: "Group A"}, {Name: "Group B"}}, ElemName: "computer_group", }, Buildings: ScopeItemSlice{ @@ -165,7 +165,7 @@ func TestScopeUpdateXML_Marshal(t *testing.T) { func TestAddToScope_TargetComputerGroup(t *testing.T) { s := &ScopeXML{ ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Existing"}}, + Items: []NamedItem{{ID: "1", Name: "Existing"}}, ElemName: "computer_group", }, } @@ -290,7 +290,7 @@ func TestAddToScope_NonPolicyLimitUserGroup(t *testing.T) { func TestRemoveFromScope_TargetComputerGroup(t *testing.T) { s := &ScopeXML{ ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Keep"}, {ID: 2, Name: "Remove"}}, + Items: []NamedItem{{ID: "1", Name: "Keep"}, {ID: "2", Name: "Remove"}}, ElemName: "computer_group", }, } @@ -377,7 +377,7 @@ func TestRemoveFromScope_PolicyLimitUserGroup_InLimitations(t *testing.T) { }, Limitations: &LimitationsXML{ UserGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 5, Name: "Staff"}}, + Items: []NamedItem{{ID: "5", Name: "Staff"}}, ElemName: "user_group", }, }, @@ -420,8 +420,8 @@ func TestRemoveFromScope_PolicyLimitUserGroup_InBothLocations(t *testing.T) { // ─── ValidateScopeCombination ──────────────────────────────────────────────── func TestValidateScopeCombination_ValidTargets(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "building", "department"} { - for _, sk := range []string{"policy", "restricted_software", "os_x_configuration_profile"} { + for _, flag := range []string{"computer", "computer-group", "mobile-device", "mobile-device-group", "building", "department", "jss-user-group", "jss-user"} { + for _, sk := range []string{"policy", "os_x_configuration_profile"} { if err := ValidateScopeCombination(sk, "target", flag); err != nil { t.Errorf("target/%s/%s: %v", sk, flag, err) } @@ -429,14 +429,34 @@ func TestValidateScopeCombination_ValidTargets(t *testing.T) { } } +func TestValidateScopeCombination_UserGroupTargetRejected(t *testing.T) { + // --user-group must not be valid for target; --jss-user-group is the explicit alternative. + if err := ValidateScopeCombination("policy", "target", "user-group"); err == nil { + t.Error("expected error: --user-group as target should be rejected (use --jss-user-group)") + } +} + func TestValidateScopeCombination_InvalidTarget(t *testing.T) { if err := ValidateScopeCombination("policy", "target", "network-segment"); err == nil { t.Error("expected error for network-segment as target") } } +func TestValidateScopeCombination_RestrictedSoftwareTargets(t *testing.T) { + for _, flag := range []string{"computer", "computer-group", "building", "department"} { + if err := ValidateScopeCombination("restricted_software", "target", flag); err != nil { + t.Errorf("restricted target/%s: %v", flag, err) + } + } + for _, flag := range []string{"mobile-device", "mobile-device-group", "jss-user-group", "jss-user", "user-group"} { + if err := ValidateScopeCombination("restricted_software", "target", flag); err == nil { + t.Errorf("expected error: restricted software target + %s", flag) + } + } +} + func TestValidateScopeCombination_ValidLimitations(t *testing.T) { - for _, flag := range []string{"network-segment", "user-group", "computer-group"} { + for _, flag := range []string{"network-segment", "user", "user-group"} { if err := ValidateScopeCombination("policy", "limitation", flag); err != nil { t.Errorf("limitation/%s: %v", flag, err) } @@ -444,7 +464,7 @@ func TestValidateScopeCombination_ValidLimitations(t *testing.T) { } func TestValidateScopeCombination_RestrictedSoftwareNoLimitations(t *testing.T) { - for _, flag := range []string{"network-segment", "user-group", "computer-group"} { + for _, flag := range []string{"network-segment", "user-group", "user"} { err := ValidateScopeCombination("restricted_software", "limitation", flag) if err == nil { t.Errorf("expected error: restricted software + limitation + %s", flag) @@ -456,7 +476,7 @@ func TestValidateScopeCombination_RestrictedSoftwareNoLimitations(t *testing.T) } func TestValidateScopeCombination_ValidExclusions(t *testing.T) { - for _, flag := range []string{"computer-group", "mobile-device-group", "user-group", "network-segment", "building", "department"} { + for _, flag := range []string{"computer", "computer-group", "mobile-device", "mobile-device-group", "user", "user-group", "jss-user-group", "jss-user", "network-segment", "building", "department"} { if err := ValidateScopeCombination("policy", "exclusion", flag); err != nil { t.Errorf("exclusion/%s: %v", flag, err) } @@ -464,12 +484,12 @@ func TestValidateScopeCombination_ValidExclusions(t *testing.T) { } func TestValidateScopeCombination_RestrictedSoftwareExclusions(t *testing.T) { - for _, flag := range []string{"computer-group", "building", "department"} { + for _, flag := range []string{"computer", "computer-group", "building", "department"} { if err := ValidateScopeCombination("restricted_software", "exclusion", flag); err != nil { t.Errorf("restricted exclusion/%s: %v", flag, err) } } - for _, flag := range []string{"mobile-device-group", "user-group", "network-segment"} { + for _, flag := range []string{"mobile-device", "mobile-device-group", "user", "user-group", "jss-user-group", "jss-user", "network-segment"} { if err := ValidateScopeCombination("restricted_software", "exclusion", flag); err == nil { t.Errorf("expected error: restricted software exclusion + %s", flag) } @@ -488,13 +508,26 @@ func TestValidateScopeCombination_InvalidLimitationFlag(t *testing.T) { } } +func TestValidateScopeCombination_ComputerOnlyInTargetExclusion(t *testing.T) { + // --computer / --mobile-device / --user must be rejected outside their sections. + if err := ValidateScopeCombination("policy", "limitation", "computer"); err == nil { + t.Error("expected error: --computer as limitation") + } + if err := ValidateScopeCombination("policy", "limitation", "mobile-device"); err == nil { + t.Error("expected error: --mobile-device as limitation") + } + if err := ValidateScopeCombination("policy", "target", "user"); err == nil { + t.Error("expected error: --user as target") + } +} + // ─── FlattenScope ──────────────────────────────────────────────────────────── func TestFlattenScope_BasicPolicy(t *testing.T) { s := &ScopeXML{ AllComputers: true, ComputerGroups: ScopeItemSlice{ - Items: []NamedItem{{ID: 1, Name: "Group A"}}, + Items: []NamedItem{{ID: "1", Name: "Group A"}}, }, Buildings: ScopeItemSlice{ Items: []NamedItem{{Name: "HQ"}}, @@ -539,8 +572,8 @@ func TestFlattenScope_PolicyUserGroupNoDuplicates(t *testing.T) { }, Limitations: &LimitationsXML{ UserGroups: ScopeItemSlice{Items: []NamedItem{ - {ID: 1, Name: "Staff"}, - {ID: 2, Name: "Faculty"}, + {ID: "1", Name: "Staff"}, + {ID: "2", Name: "Faculty"}, }}, NetworkSegments: ScopeItemSlice{Items: []NamedItem{{Name: "Corporate"}}}, }, @@ -600,3 +633,358 @@ func TestIsPolicyLimitUserGroup(t *testing.T) { } } } + +// ─── JSS user group target routing (VPP-style scope) ───────────────────────── + +func TestAddToScope_UserGroupTarget_NoLongerRoutes(t *testing.T) { + // --user-group is LDAP-only (limitation/exclusion). For target users must use + // --jss-user-group explicitly. AddToScope should refuse to add it. + s := &ScopeXML{} + + if AddToScope(s, "vpp_assignment", "target", "user-group", "VPP Associated Users") { + t.Fatal("expected false: --user-group is not a valid target flag") + } + if len(s.JSSUserGroups.Items) != 0 { + t.Errorf("jss_user_groups should remain empty, got %d items", len(s.JSSUserGroups.Items)) + } +} + +func TestAddToScope_JSSUserGroupTarget(t *testing.T) { + s := &ScopeXML{} + + if !AddToScope(s, "vpp_assignment", "target", "jss-user-group", "My Group") { + t.Fatal("expected true") + } + if len(s.JSSUserGroups.Items) != 1 { + t.Fatalf("jss_user_groups: got %d, want 1", len(s.JSSUserGroups.Items)) + } + if s.JSSUserGroups.ElemName != "user_group" { + t.Errorf("ElemName = %q, want user_group", s.JSSUserGroups.ElemName) + } +} + +func TestAddToScope_JSSUserGroupTarget_Idempotent(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "VPP Associated Users"}}, + ElemName: "user_group", + }, + } + + if AddToScope(s, "vpp_assignment", "target", "jss-user-group", "vpp associated users") { + t.Fatal("expected false for case-insensitive duplicate") + } +} + +func TestRemoveFromScope_JSSUserGroupTarget(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "VPP Associated Users"}, {Name: "Other Group"}}, + ElemName: "user_group", + }, + } + + if !RemoveFromScope(s, "vpp_assignment", "target", "jss-user-group", "VPP Associated Users") { + t.Fatal("expected true") + } + if len(s.JSSUserGroups.Items) != 1 { + t.Fatalf("got %d, want 1", len(s.JSSUserGroups.Items)) + } + if s.JSSUserGroups.Items[0].Name != "Other Group" { + t.Errorf("remaining = %q", s.JSSUserGroups.Items[0].Name) + } +} + +func TestRemoveFromScope_JSSUserGroupExclusion(t *testing.T) { + s := &ScopeXML{ + Exclusions: &ExclusionsXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "Excluded Group"}}, + ElemName: "jss_user_group", + }, + }, + } + + if !RemoveFromScope(s, "vpp_assignment", "exclusion", "jss-user-group", "Excluded Group") { + t.Fatal("expected true") + } + if len(s.Exclusions.JSSUserGroups.Items) != 0 { + t.Error("should be empty after remove") + } +} + +func TestFlattenScope_VPPAssignment_JSSUserGroups(t *testing.T) { + s := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{ID: "1", Name: "VPP Associated Users"}}, + }, + Limitations: &LimitationsXML{ + UserGroups: ScopeItemSlice{ + Items: []NamedItem{{Name: "COB-iosgrade1"}}, + }, + }, + } + + rows := FlattenScope(s, "vpp_assignment") + + expected := []struct{ section, typ, name string }{ + {"target", "jss_user_group", "VPP Associated Users"}, + {"limitation", "user_group", "COB-iosgrade1"}, + } + if len(rows) != len(expected) { + t.Fatalf("got %d rows, want %d: %v", len(rows), len(expected), rows) + } + for i, want := range expected { + got := rows[i] + if got["section"] != want.section || got["type"] != want.typ || got["name"] != want.name { + t.Errorf("row %d: got %v, want %s/%s/%s", i, got, want.section, want.typ, want.name) + } + } +} + +func TestResolveElemName(t *testing.T) { + tests := []struct { + section, flag, want string + }{ + {"target", "jss-user-group", "user_group"}, + {"target", "computer", "computer"}, + {"target", "computer-group", "computer_group"}, + {"target", "mobile-device", "mobile_device"}, + {"target", "mobile-device-group", "mobile_device_group"}, + {"limitation", "user", "user"}, + {"limitation", "user-group", "user_group"}, + {"exclusion", "computer", "computer"}, + {"exclusion", "mobile-device", "mobile_device"}, + {"exclusion", "user", "user"}, + {"exclusion", "user-group", "user_group"}, + {"exclusion", "jss-user-group", "user_group"}, + } + for _, tt := range tests { + if got := resolveElemName(tt.section, tt.flag); got != tt.want { + t.Errorf("resolveElemName(%q,%q) = %q, want %q", tt.section, tt.flag, got, tt.want) + } + } +} + +func TestReplaceScopeInXML(t *testing.T) { + original := []byte(`11false1Old Group`) + + newScope := &ScopeXML{ + JSSUserGroups: ScopeItemSlice{ + Items: []NamedItem{{ID: "2", Name: "New Group"}}, + ElemName: "user_group", + }, + } + + updated, err := replaceScopeInXML(original, newScope) + if err != nil { + t.Fatalf("replaceScopeInXML: %v", err) + } + + s := string(updated) + if strings.Contains(s, "Old Group") { + t.Error("old scope content should be replaced") + } + if !strings.Contains(s, "New Group") { + t.Error("new scope content should be present") + } + if !strings.Contains(s, "") { + t.Error("non-scope content should be preserved") + } +} + +func TestReplaceScopeInXML_MissingScope(t *testing.T) { + original := []byte(`1`) + _, err := replaceScopeInXML(original, &ScopeXML{}) + if err == nil { + t.Error("expected error for missing ") + } +} + +// ─── Round-trip preserves fields server returned but CLI doesn't expose ───── + +func TestScopeXML_PreservesMobileDevicesOnRoundTrip(t *testing.T) { + // Mobile config profiles return (individual devices). CLI's + // scope add only manipulates groups, but the unmarshal/marshal round-trip + // must preserve these or they get wiped on subset/Scope PUT. + data := ` + 1Profile + + false + + 18G6TDK43P0D4Y00008101-000170490151003A + + + ` + var env classicResourceXML + if err := xml.Unmarshal([]byte(data), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Scope.MobileDevices.Items) != 1 { + t.Fatalf("mobile_devices: got %d, want 1", len(env.Scope.MobileDevices.Items)) + } + if env.Scope.MobileDevices.Items[0].ID != "18" { + t.Errorf("mobile_devices[0].id = %q, want 18", env.Scope.MobileDevices.Items[0].ID) + } + // Marshal back and verify still present. + out, err := xml.Marshal(env.Scope) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), "") { + t.Error("round-trip lost data") + } +} + +func TestScopeXML_PreservesClassesOnRoundTrip(t *testing.T) { + // Ebooks return . CLI doesn't expose, but round-trip must preserve. + data := ` + 2Book + + + 510A + + + ` + var env classicResourceXML + if err := xml.Unmarshal([]byte(data), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Scope.Classes.Items) != 1 || env.Scope.Classes.Items[0].Name != "10A" { + t.Fatalf("classes: got %+v", env.Scope.Classes.Items) + } + out, err := xml.Marshal(env.Scope) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), "") { + t.Error("round-trip lost data") + } +} + +// ─── New scope flags ────────────────────────────────────────────────────────── + +func TestAddToScope_ComputerTarget(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "target", "computer", "ZTNR9F6XJ0") { + t.Fatal("expected true") + } + if len(s.Computers.Items) != 1 || s.Computers.Items[0].Name != "ZTNR9F6XJ0" { + t.Errorf("computers = %+v", s.Computers.Items) + } +} + +func TestAddToScope_MobileDeviceTarget(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "configuration_profile", "target", "mobile-device", "G6TDK43P0D4Y") { + t.Fatal("expected true") + } + if len(s.MobileDevices.Items) != 1 || s.MobileDevices.Items[0].Name != "G6TDK43P0D4Y" { + t.Errorf("mobile_devices = %+v", s.MobileDevices.Items) + } + if s.MobileDevices.ElemName != "mobile_device" { + t.Errorf("ElemName = %q", s.MobileDevices.ElemName) + } +} + +func TestAddToScope_UserLimitation(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "limitation", "user", "alice") { + t.Fatal("expected true") + } + if s.Limitations == nil || len(s.Limitations.Users.Items) != 1 { + t.Fatalf("limitations.users = %+v", s.Limitations) + } + if s.Limitations.Users.Items[0].Name != "alice" { + t.Errorf("user name = %q", s.Limitations.Users.Items[0].Name) + } +} + +func TestAddToScope_UserExclusion(t *testing.T) { + s := &ScopeXML{} + if !AddToScope(s, "policy", "exclusion", "user", "bob") { + t.Fatal("expected true") + } + if s.Exclusions == nil || len(s.Exclusions.Users.Items) != 1 { + t.Fatalf("exclusions.users = %+v", s.Exclusions) + } +} + +func TestFlattenScope_NewFields(t *testing.T) { + s := &ScopeXML{ + AllMobileDevices: true, + Computers: ScopeItemSlice{Items: []NamedItem{{ID: "28", Name: "Mac-X"}}}, + MobileDevices: ScopeItemSlice{Items: []NamedItem{{ID: "18", Name: "iPad-Y"}}}, + Limitations: &LimitationsXML{ + Users: ScopeItemSlice{Items: []NamedItem{{Name: "alice"}}}, + }, + Exclusions: &ExclusionsXML{ + MobileDevices: ScopeItemSlice{Items: []NamedItem{{Name: "iPad-Z"}}}, + Users: ScopeItemSlice{Items: []NamedItem{{Name: "bob"}}}, + }, + } + rows := FlattenScope(s, "configuration_profile") + + want := map[string]bool{ + "target:all_mobile_devices:true": false, + "target:computer:Mac-X": false, + "target:mobile_device:iPad-Y": false, + "limitation:user:alice": false, + "exclusion:mobile_device:iPad-Z": false, + "exclusion:user:bob": false, + } + for _, r := range rows { + key := r["section"].(string) + ":" + r["type"].(string) + ":" + r["name"].(string) + if _, ok := want[key]; ok { + want[key] = true + } + } + for k, found := range want { + if !found { + t.Errorf("missing flatten row: %s", k) + } + } +} + +// ─── VerifyItemInScope id/UDID matching ────────────────────────────────────── + +func TestVerifyItemInScope_MatchesByNameIDAndUDID(t *testing.T) { + // Simulates the post-PUT GET response where the server returns the canonical + // name alongside the id/udid. The user may have supplied any of the three + // forms to the CLI, so VerifyItemInScope must accept all three. + s := &ScopeXML{ + Computers: ScopeItemSlice{ + Items: []NamedItem{{ID: "10", Name: "Mac-Build-01", UDID: "AAA-BBB"}}, + }, + MobileDevices: ScopeItemSlice{ + Items: []NamedItem{{ID: "18", Name: "G6TDK43P0D4Y", UDID: "00008101-000170490151003A"}}, + }, + } + + for _, tc := range []struct { + flagName string + input string + items *ScopeItemSlice + }{ + {"computer", "Mac-Build-01", &s.Computers}, + {"computer", "10", &s.Computers}, + {"computer", "AAA-BBB", &s.Computers}, + {"computer", "aaa-bbb", &s.Computers}, // UDID case-insensitive + {"mobile-device", "G6TDK43P0D4Y", &s.MobileDevices}, + {"mobile-device", "18", &s.MobileDevices}, + {"mobile-device", "00008101-000170490151003A", &s.MobileDevices}, + } { + present := false + for _, item := range tc.items.Items { + if strings.EqualFold(item.Name, tc.input) || + item.ID == tc.input || + strings.EqualFold(item.UDID, tc.input) { + present = true + break + } + } + if !present { + t.Errorf("flag --%s value %q: expected match, got none", tc.flagName, tc.input) + } + } +} diff --git a/internal/scope/types.go b/internal/scope/types.go index 5256ef63..c62a928e 100644 --- a/internal/scope/types.go +++ b/internal/scope/types.go @@ -13,8 +13,10 @@ import ( // Resource identifies a Classic API resource that supports scope operations. type Resource struct { - APIPath string // URL segment under /JSSResource/, e.g. "policies" - SingularKey string // XML root key for a single object, e.g. "policy" + APIPath string // URL segment under /JSSResource/, e.g. "policies" + SingularKey string // XML root key for a single object, e.g. "policy" + ResolveByList bool // when true, resolve name→ID by listing all (no /name/ endpoint) + NoSubsetPut bool // when true, PUT full document instead of /subset/Scope } // ScopeTarget holds a resolved flag name and value from a scope add/remove command. @@ -29,10 +31,14 @@ type ScopeTarget struct { // multiple elements) that Go's built-in encoding cannot express // with tags alone. -// NamedItem is an item identified by name (and optionally ID) in scope XML. +// NamedItem is an item identified by name (and optionally ID or UDID) in scope XML. +// ID is a string to accommodate both integer IDs (most resources) and UUID +// IDs (e.g. ebook scope user groups) returned by the Classic API. +// UDID is populated for individual mobile devices and computers. type NamedItem struct { - ID int `xml:"id,omitempty" json:"id,omitempty"` + ID string `xml:"id,omitempty" json:"id,omitempty"` Name string `xml:"name" json:"name"` + UDID string `xml:"udid,omitempty" json:"udid,omitempty"` } // ScopeItemSlice is a list of NamedItem elements under a single XML parent. @@ -139,17 +145,25 @@ func (s ScopeStringSlice) MarshalJSON() ([]byte, error) { } // ScopeXML models the complete section of a Classic API resource. +// +// All scopeable item slices are present unconditionally so that an unmarshal → +// modify → marshal round-trip preserves every section the server returned. A +// missing field here would cause CLI scope add/remove to silently wipe data +// the user set elsewhere (e.g. individual mobile devices added via UI). type ScopeXML struct { XMLName xml.Name `xml:"scope" json:"-"` AllComputers bool `xml:"all_computers" json:"all_computers"` + AllMobileDevices bool `xml:"all_mobile_devices,omitempty" json:"all_mobile_devices,omitempty"` AllJSSUsers bool `xml:"all_jss_users" json:"all_jss_users"` Computers ScopeItemSlice `xml:"computers" json:"computers"` ComputerGroups ScopeItemSlice `xml:"computer_groups" json:"computer_groups"` + MobileDevices ScopeItemSlice `xml:"mobile_devices" json:"mobile_devices"` + MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` JSSUsers ScopeItemSlice `xml:"jss_users" json:"jss_users"` JSSUserGroups ScopeItemSlice `xml:"jss_user_groups" json:"jss_user_groups"` - MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` Buildings ScopeItemSlice `xml:"buildings" json:"buildings"` Departments ScopeItemSlice `xml:"departments" json:"departments"` + Classes ScopeItemSlice `xml:"classes" json:"classes,omitempty"` LimitToUsers *LimitToUsersXML `xml:"limit_to_users,omitempty" json:"limit_to_users,omitempty"` Limitations *LimitationsXML `xml:"limitations,omitempty" json:"limitations,omitempty"` Exclusions *ExclusionsXML `xml:"exclusions,omitempty" json:"exclusions,omitempty"` @@ -173,6 +187,7 @@ type LimitationsXML struct { type ExclusionsXML struct { Computers ScopeItemSlice `xml:"computers" json:"computers"` ComputerGroups ScopeItemSlice `xml:"computer_groups" json:"computer_groups"` + MobileDevices ScopeItemSlice `xml:"mobile_devices" json:"mobile_devices"` MobileDeviceGroups ScopeItemSlice `xml:"mobile_device_groups" json:"mobile_device_groups"` Users ScopeItemSlice `xml:"users" json:"users"` UserGroups ScopeItemSlice `xml:"user_groups" json:"user_groups"` @@ -185,10 +200,12 @@ type ExclusionsXML struct { } // classicResourceXML captures general.id and scope from a Classic API GET. +// ID is a string to accommodate both integer IDs (most resources) and UUID +// IDs (e.g. ebooks) returned by the Classic API. type classicResourceXML struct { XMLName xml.Name General struct { - ID int `xml:"id"` + ID string `xml:"id"` Name string `xml:"name"` } `xml:"general"` Scope ScopeXML `xml:"scope"` @@ -202,17 +219,31 @@ type scopeUpdateXML struct { // flagToElemName maps a CLI flag to the XML child element name used when // adding new items to a scope list. +// +// `--user-group` and `--jss-user-group` both serialize as children +// — their parent element ( in limitations/exclusions vs +// in target/exclusion) is what disambiguates the semantics. +// +// `--jss-user` writes children; the server's GET response returns +// the same items as children of instead. The server accepts +// both shapes on PUT, so the asymmetric write is harmless. var flagToElemName = map[string]string{ + "computer": "computer", "computer-group": "computer_group", + "mobile-device": "mobile_device", "mobile-device-group": "mobile_device_group", "building": "building", "department": "department", "network-segment": "network_segment", + "user": "user", "user-group": "user_group", + "jss-user-group": "user_group", + "jss-user": "jss_user", } // scopeFlagNames is the ordered list of scope item flags. var scopeFlagNames = []string{ - "computer-group", "mobile-device-group", "building", - "department", "network-segment", "user-group", + "computer", "computer-group", "mobile-device", "mobile-device-group", + "building", "department", "network-segment", + "user", "user-group", "jss-user-group", "jss-user", } diff --git a/specs/classic/resources.yaml b/specs/classic/resources.yaml index 65fa0dd3..f4b04d66 100644 --- a/specs/classic/resources.yaml +++ b/specs/classic/resources.yaml @@ -180,6 +180,9 @@ resources: cli_name: classic-restricted-software singular: restricted_software scope: true + # Classic API has no /subset/Scope endpoint for restricted_software (404). + # Fetch the full document, splice in the updated scope, PUT the whole doc. + no_subset_put: true - name: allowedfileextensions path: allowedfileextensions @@ -203,6 +206,13 @@ resources: singular: mobile_device_application scope: true + - name: ebooks + path: ebooks + description: eBook distributions + cli_name: classic-ebooks + singular: ebook + scope: true + - name: ibeacons path: ibeacons description: iBeacon configurations @@ -283,6 +293,12 @@ resources: cli_name: classic-licensed-software singular: licensed_software + - name: usergroups + path: usergroups + description: JSS user groups (referenced in policy/ebook scope limitations) + cli_name: classic-user-groups + singular: user_group + - name: userextensionattributes path: userextensionattributes description: User extension attributes @@ -302,6 +318,8 @@ resources: cli_name: classic-vpp-assignments singular: vpp_assignment lookups: [id] + scope: true + no_subset_put: true - name: vppinvitations path: vppinvitations @@ -310,6 +328,8 @@ resources: singular: vpp_invitation operations: [list, get, create, delete] lookups: [id] + scope: true + no_subset_put: true - name: gsxconnection path: gsxconnection