<!-- 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 -->
347 lines
11 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|