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

347 lines
11 KiB
Go

package endpointer
import (
"bytes"
"fmt"
"io"
"github.com/go-json-experiment/json/jsontext"
)
// AliasConflictError is returned when both the deprecated and new field names
// are specified in the same JSON object scope. For example, if "team_id" is
// renamed to "fleet_id", and a request contains both, this error is returned.
type AliasConflictError struct {
Old string
New string
}
func (e *AliasConflictError) Error() string {
return fmt.Sprintf("Conflicting field names: cannot specify both `%s` (deprecated) and `%s` in the same request", e.Old, e.New)
}
// AliasRule defines a key-rename rule: the deprecated (old) key name and its
// replacement (new) key name. The struct's json tag uses OldKey (the current
// name), and renameto specifies NewKey (the target name). The rewriter
// accepts both names in requests: OldKey passes through as-is (with
// deprecation tracking) and NewKey is rewritten to OldKey for deserialization.
type AliasRule struct {
OldKey string
NewKey string
// Inline opts a renamed container into "merged" response duplication:
// instead of the default clean split (the old key holds an all-old subtree
// and the new key an all-new one), the old key's subtree also carries the
// new-named copies of any nested renamed containers — so both names appear
// together on the same object. Set via the `,inline` option on the
// `renameto` struct tag (e.g. `renameto:"ab_tokens,inline"`). It only
// affects response encoding (DuplicateJSONKeys); request decoding ignores
// it.
Inline bool
}
// JSONKeyRewriteReader is a streaming io.Reader that handles
// JSON key aliasing while reading. It:
//
// - Passes through OldKey (deprecated) names as-is (the struct expects them)
// and tracks them in usedDeprecated for deprecation logging.
// - Rewrites NewKey names to OldKey so the struct can deserialize them.
// - Detects alias conflicts: if both OldKey and NewKey appear in the same
// JSON object scope, it returns an *AliasConflictError.
//
// It uses jsontext.Decoder/Encoder for token-level processing, delegating all
// JSON lexing (string escaping, unicode, whitespace) to the library.
type JSONKeyRewriteReader struct {
reader *bytes.Reader
initErr error
// Map from old (deprecated) key to its AliasRule for fast lookup.
oldKeyIndex map[string]AliasRule
// Map from new key to its AliasRule for fast lookup.
newKeyIndex map[string]AliasRule
// Tracks which deprecated keys have been used (old key -> true).
usedDeprecated map[string]bool
}
// NewJSONKeyRewriteReader creates a new JSONKeyRewriteReader that wraps the
// given reader and applies the provided alias rules. It reads JSON tokens
// from src, handles bidirectional key aliasing, detects conflicts, and
// writes the result to an internal buffer.
func NewJSONKeyRewriteReader(src io.Reader, rules []AliasRule) *JSONKeyRewriteReader {
oldIdx := make(map[string]AliasRule, len(rules))
newIdx := make(map[string]AliasRule, len(rules))
for _, r := range rules {
oldIdx[r.OldKey] = r
newIdx[r.NewKey] = r
}
rw := &JSONKeyRewriteReader{
oldKeyIndex: oldIdx,
newKeyIndex: newIdx,
usedDeprecated: make(map[string]bool),
}
var buf bytes.Buffer
if err := rw.rewrite(src, &buf); err != nil {
rw.initErr = err
return rw
}
rw.reader = bytes.NewReader(buf.Bytes())
return rw
}
// UsedDeprecatedKeys returns the list of deprecated key names that were
// encountered during reading. This should be called after the reader has been
// fully consumed (i.e., after json.Decoder.Decode or similar has returned),
// which guarantees the background goroutine has finished.
func (r *JSONKeyRewriteReader) UsedDeprecatedKeys() []string {
keys := make([]string, 0, len(r.usedDeprecated))
for k := range r.usedDeprecated {
keys = append(keys, k)
}
return keys
}
// Close closes the reader end of the pipe to unblock the transform goroutine
// if the consumer stops reading early.
func (r *JSONKeyRewriteReader) Close() error {
return nil
}
// Read implements io.Reader by reading from the pipe.
func (r *JSONKeyRewriteReader) Read(p []byte) (int, error) {
if r.initErr != nil {
return 0, r.initErr
}
if r.reader == nil {
return 0, io.EOF
}
return r.reader.Read(p)
}
// RewriteDeprecatedKeys handles JSON key aliasing in data using
// the provided alias rules. It rewrites NewKey→OldKey (so the struct can
// deserialize), passes through OldKey as-is, and returns an error if both
// appear in the same scope (alias conflict) or the JSON is malformed.
//
// This is useful when a request body is captured as json.RawMessage and later
// decoded into a struct with `renameto` tags — the rewriter in MakeDecoder
// won't have seen the inner fields, so this function can be called before the
// deferred unmarshal.
func RewriteDeprecatedKeys(data []byte, rules []AliasRule) ([]byte, map[string]string, error) {
if len(rules) == 0 || len(data) == 0 {
return data, nil, nil
}
oldIdx := make(map[string]AliasRule, len(rules))
newIdx := make(map[string]AliasRule, len(rules))
for _, r := range rules {
oldIdx[r.OldKey] = r
newIdx[r.NewKey] = r
}
rw := &JSONKeyRewriteReader{
oldKeyIndex: oldIdx,
newKeyIndex: newIdx,
usedDeprecated: make(map[string]bool),
}
var buf bytes.Buffer
if err := rw.rewrite(bytes.NewReader(data), &buf); err != nil {
return nil, nil, err
}
deprecatedKeysMap := make(map[string]string, len(rw.usedDeprecated))
for k := range rw.usedDeprecated {
deprecatedKeysMap[k] = rw.oldKeyIndex[k].NewKey
}
return buf.Bytes(), deprecatedKeysMap, nil
}
// RewriteOldToNewKeys is the reverse of RewriteDeprecatedKey; it takes
// the rules and reverses them before translating keys.
// Use this in situations where a payload was rewritten from new to old keys
// for deserialization, but you want to return a response with the new keys
// for forward compatibility.
func RewriteOldToNewKeys(data []byte, rules []AliasRule) ([]byte, error) {
reversed := make([]AliasRule, len(rules))
for i, r := range rules {
// Inline is intentionally not preserved: this only renames keys, it
// never duplicates them.
reversed[i] = AliasRule{OldKey: r.NewKey, NewKey: r.OldKey}
}
result, _, err := RewriteDeprecatedKeys(data, reversed)
return result, err
}
// softwareScopeKey marks the JSON object/array container whose contents are
// the values of TeamSpec.Software — i.e. SoftwarePackageSpec /
// TeamSpecAppStoreApp / MaintainedAppSpec items. The literal `setup_experience`
// install flag on those items collides with the `macos_setup`↔`setup_experience`
// rename on the MDM section, so renames are skipped under this subtree. See
// https://github.com/fleetdm/fleet/issues/44970.
const softwareScopeKey = "software"
// rewrite reads tokens from src, rewrites deprecated keys, checks for alias
// conflicts, and writes the transformed JSON to w.
func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error {
dec := jsontext.NewDecoder(src, jsontext.AllowDuplicateNames(true))
enc := jsontext.NewEncoder(w, jsontext.AllowDuplicateNames(true))
// Stack of per-object-scope key sets for conflict detection.
// Pushed on '{', popped on '}'.
var keyScopes []map[string]bool
// Track whether we are currently inside the `software` subtree.
// pendingKey is the most-recent object key whose value has not yet been
// read; openContainer/closeContainer maintain softwareDepth by checking
// whether the container being opened lives under `software`.
pendingKey := ""
softwareDepth := 0
openContainer := func() {
if softwareDepth > 0 || pendingKey == softwareScopeKey {
softwareDepth++
}
pendingKey = ""
}
closeContainer := func() {
if softwareDepth > 0 {
softwareDepth--
}
}
for {
tok, err := dec.ReadToken()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
kind := tok.Kind()
switch kind {
case '{':
keyScopes = append(keyScopes, make(map[string]bool))
openContainer()
if err := enc.WriteToken(tok); err != nil {
return err
}
case '}':
if len(keyScopes) > 0 {
keyScopes = keyScopes[:len(keyScopes)-1]
}
closeContainer()
if err := enc.WriteToken(tok); err != nil {
return err
}
case '[':
openContainer()
if err := enc.WriteToken(tok); err != nil {
return err
}
case ']':
closeContainer()
if err := enc.WriteToken(tok); err != nil {
return err
}
case '"':
// Determine if this string is an object key by checking the
// decoder's stack: inside an object ('{') at an odd length
// means we just read a key (name).
isKey := false
depth := dec.StackDepth()
if depth > 0 {
parentKind, length := dec.StackIndex(depth)
// length is odd after reading a name (names and values
// are counted separately).
if parentKind == '{' && length%2 == 1 {
isKey = true
}
}
if isKey {
keyName := tok.String()
// Inside the `software` subtree, all inner keys are literal —
// most notably each item's `setup_experience` is a bool
// install flag, not the renamed `macos_setup` container. Pass
// them through untouched.
if softwareDepth > 0 {
pendingKey = keyName
if err := enc.WriteToken(tok); err != nil {
return err
}
continue
}
// Use OldKey as the canonical key for scope tracking.
// Both OldKey (pass-through) and NewKey (rewrite) resolve
// to the same canonical key for conflict detection.
if rule, ok := r.oldKeyIndex[keyName]; ok {
// This is an OldKey (deprecated name). Pass through
// as-is — the struct expects this name. Track it for
// deprecation logging.
canonicalKey := rule.OldKey
r.usedDeprecated[keyName] = true
// Conflict detection.
if len(keyScopes) > 0 {
scope := keyScopes[len(keyScopes)-1]
if scope[canonicalKey] {
return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey}
}
scope[canonicalKey] = true
}
pendingKey = keyName
// Write the key as-is (old name, which the struct expects).
if err := enc.WriteToken(tok); err != nil {
return err
}
} else if rule, ok := r.newKeyIndex[keyName]; ok {
// This is a NewKey. Rewrite it to OldKey so the
// struct can deserialize it.
canonicalKey := rule.OldKey
// Conflict detection.
if len(keyScopes) > 0 {
scope := keyScopes[len(keyScopes)-1]
if scope[canonicalKey] {
return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey}
}
scope[canonicalKey] = true
}
pendingKey = canonicalKey
// Write the rewritten (old) key.
if err := enc.WriteToken(jsontext.String(canonicalKey)); err != nil {
return err
}
} else {
// Not an aliased key — pass through unchanged.
pendingKey = keyName
if err := enc.WriteToken(tok); err != nil {
return err
}
}
} else {
// String value — pass through unchanged.
pendingKey = ""
if err := enc.WriteToken(tok); err != nil {
return err
}
}
default:
// All other tokens: numbers, bools, null — scalar values.
pendingKey = ""
if err := enc.WriteToken(tok); err != nil {
return err
}
}
}
}