Files
Jordan Montgomery 356caea6fd 42508 Rename abm to ab in API (#46657)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42508 

Renames abm/apple_business_manager to ab/apple_business in API and
fleetctl. Uses existing renameto logic with a slight twist: added
"inline" option to handle cases particularly where a single object tree
has renames in multiple versions so that we don't break backwards
compatibiility since the default behavior when you have multi-level
renames is a new/old split at the top level

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Canonical Apple Business (AB) API endpoints and CLI:
/api/v1/fleet/ab_tokens, /api/v1/fleet/mdm/apple/ab_public_key, plus new
fleetctl get mdm-ab and fleetctl generate mdm-ab
  * New GitOps/config key: mdm.apple_business
* Admin UI updated to show Apple Business tokens with fleet-based
associations and updated labels

* **Deprecations**
* Legacy ABM endpoints, CLI aliases, and config keys remain supported
but emit deprecation warnings pointing to the new AB equivalents
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 14:58:17 -04:00

239 lines
7.9 KiB
Go

package endpointer
import (
"bytes"
"io"
"github.com/go-json-experiment/json/jsontext"
)
// DuplicateJSONKeysOpts controls optional behavior of DuplicateJSONKeys.
type DuplicateJSONKeysOpts struct {
// Compact disables pretty-printing. By default, output is indented with
// two spaces to match the standard API response format.
Compact bool
}
// DuplicateJSONKeys takes marshaled JSON and, for each AliasRule, duplicates
// keys so that both the old (native) and new names appear in the output.
// For example, if a rule maps OldKey:"team_id" → NewKey:"fleet_id", and the
// JSON contains "team_id": 42, the output will contain both "team_id": 42
// and "fleet_id": 42.
//
// By default a renamed key produces a clean split: the old-named key keeps an
// all-old subtree and the new-named key gets an all-new subtree (via
// RewriteOldToNewKeys), so each subtree is internally single-named. A renamed
// leaf at the top level (or under a non-renamed key) is instead duplicated in
// place, so both names appear as siblings with the same value.
//
// A rule with Inline set opts into "merged" duplication for that container: its
// old-named subtree additionally carries the new-named copies of any nested
// renamed containers, so both names appear together on the same object (e.g.
// "abm_tokens" holding both "macos_team" and "macos_fleet"). Leaf renames
// inside an inlined subtree are still kept single-named per container (so
// "macos_team" holds "team_id" while its sibling "macos_fleet" holds
// "fleet_id") rather than cross-contaminating both id names into one object.
//
// If the new key already exists in the same object scope, the duplication is
// skipped for that key (to avoid producing duplicate keys when the source
// struct already has both, or when the function is called more than once).
//
// The function uses jsontext.Decoder/Encoder for token-level processing,
// delegating all JSON lexing (string escaping, unicode, nesting) to the
// library. Duplicates are deferred until the closing '}' of each object so
// that naturally-occurring new keys can be detected and skipped.
func DuplicateJSONKeys(data []byte, rules []AliasRule, opts ...DuplicateJSONKeysOpts) []byte {
compact := len(opts) > 0 && opts[0].Compact
return duplicateJSONKeys(data, rules, compact)
}
// duplicateJSONKeys is the recursive core of DuplicateJSONKeys.
//
// An Inline container is the only recursive case: its old-named subtree is
// re-run through this function so nested renames surface there too — exactly as
// they did before the container itself was renamed. That recursion needs no
// special mode because the default rules already produce the right shape:
// nested renamed *containers* split cleanly into old/new siblings (their values
// are consumed whole by ReadValue, so their leaves are never duplicated in
// place), while nested renamed *leaves* are duplicated in place. The new-named
// subtree is always a clean RewriteOldToNewKeys copy.
func duplicateJSONKeys(data []byte, rules []AliasRule, compact bool) []byte {
if len(rules) == 0 || len(data) == 0 {
return data
}
oldToNew := make(map[string]string, len(rules))
newToOld := make(map[string]string, len(rules))
inlineOld := make(map[string]struct{}, len(rules))
for _, r := range rules {
oldToNew[r.OldKey] = r.NewKey
newToOld[r.NewKey] = r.OldKey
if r.Inline {
inlineOld[r.OldKey] = struct{}{}
}
}
var buf bytes.Buffer
dec := jsontext.NewDecoder(bytes.NewReader(data), jsontext.AllowDuplicateNames(true))
encOpts := []jsontext.Options{jsontext.AllowDuplicateNames(true)}
if !compact {
encOpts = append(encOpts, jsontext.WithIndent(" "))
}
enc := jsontext.NewEncoder(&buf, encOpts...)
// pendingDup holds a key-value pair that should be inserted as a
// duplicate at the end of the current object scope (before '}'),
// unless the new key was found naturally in the same scope.
type pendingDup struct {
newKey string
value jsontext.Value
}
// Per-object-scope state: pending duplicates and naturally-seen new keys.
type scopeState struct {
pending []pendingDup
naturalNew map[string]bool
}
var scopes []scopeState
for {
tok, err := dec.ReadToken()
if err != nil {
if err == io.EOF {
break
}
// On any error, return the original data unchanged.
return data
}
kind := tok.Kind()
switch kind {
case '{':
scopes = append(scopes, scopeState{naturalNew: make(map[string]bool)})
if err := enc.WriteToken(tok); err != nil {
return data
}
case '}':
// Before closing the object, emit any pending duplicates whose
// new key was not seen naturally in this scope.
if len(scopes) > 0 {
scope := scopes[len(scopes)-1]
for _, dup := range scope.pending {
if scope.naturalNew[dup.newKey] {
continue // new key exists naturally; skip duplicate
}
if err := enc.WriteToken(jsontext.String(dup.newKey)); err != nil {
return data
}
if err := enc.WriteValue(dup.value); err != nil {
return data
}
}
scopes = scopes[:len(scopes)-1]
}
if err := enc.WriteToken(tok); err != nil {
return data
}
case '"':
// Determine if this string is an object key.
isKey := false
depth := dec.StackDepth()
if depth > 0 {
parentKind, length := dec.StackIndex(depth)
if parentKind == '{' && length%2 == 1 {
isKey = true
}
}
if isKey {
keyName := tok.String()
// Track new keys that appear naturally.
if len(scopes) > 0 && newToOld[keyName] != "" {
scopes[len(scopes)-1].naturalNew[keyName] = true
}
// Check if this key is deprecated and should generate a duplicate.
newKey, shouldDuplicate := oldToNew[keyName]
if shouldDuplicate {
// Write the old key.
if err := enc.WriteToken(tok); err != nil {
return data
}
// Read the raw value.
val, err := dec.ReadValue()
if err != nil {
return data
}
// Old-named subtree. By default it is written as-is (the
// value already uses old names from json.Marshal). An Inline
// container instead re-runs the duplicator over its value so
// nested renames also surface under the old name, the way
// they did before this container was renamed.
if _, ok := inlineOld[keyName]; ok && startsWithContainer(val) {
// compact is irrelevant here: the result is re-encoded
// by the outer encoder, which applies its own indent.
oldVal := duplicateJSONKeys([]byte(val), rules, true)
if err := enc.WriteValue(jsontext.Value(oldVal)); err != nil {
return data
}
} else if err := enc.WriteValue(val); err != nil {
return data
}
// New-named sibling: a clean, fully new-named copy. For a
// scalar this is the same value, which yields an in-place
// duplicate (both old and new key on the same object).
newVal, renameErr := RewriteOldToNewKeys([]byte(val), rules)
if renameErr != nil {
newVal = []byte(val) // fall back to original value on error
}
if len(scopes) > 0 {
scopes[len(scopes)-1].pending = append(
scopes[len(scopes)-1].pending,
pendingDup{newKey: newKey, value: jsontext.Value(newVal)},
)
}
} else { // !shouldDuplicate (no old key match) — just write the key as-is
if err := enc.WriteToken(tok); err != nil {
return data
}
}
} else { // !isKey — string value, not a key — just write as-is
if err := enc.WriteToken(tok); err != nil {
return data
}
}
default:
// All other tokens: [, ], numbers, bools, null — pass through.
if err := enc.WriteToken(tok); err != nil {
return data
}
}
}
return buf.Bytes()
}
// startsWithContainer reports whether the JSON value v is an object or array
// (as opposed to a scalar: string, number, bool, or null).
func startsWithContainer(v []byte) bool {
for _, b := range v {
switch b {
case ' ', '\t', '\n', '\r':
continue
case '{', '[':
return true
default:
return false
}
}
return false
}