Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ cp settings.example.yaml settings.yaml

See [settings.example.yaml](settings.example.yaml) for all options and documentation.

`abm.client_id` now drives the Apple API defaults automatically:

- `BUSINESSAPI.*` uses `business.api` with `https://api-business.apple.com/`
- `SCHOOLAPI.*` uses `school.api` with `https://api-school.apple.com/`

If Apple changes those requirements, you can override them with `abm.oauth_scope` and `abm.api_base_url`.

## Usage

```bash
Expand Down Expand Up @@ -124,11 +131,11 @@ axm2snipe sync -v --log-file /var/log/axm2snipe.log
# JSON logs to file
axm2snipe sync -v --log-format json --log-file /var/log/axm2snipe.log

# Print an ABM access token (useful for manual API testing with curl)
# Print an ABM/ASM access token (useful for manual API testing with curl)
axm2snipe access-token

# Make an authenticated ABM API request
axm2snipe request https://mdmenrollment.apple.com/server/devices
# Make an authenticated ABM/ASM API request
axm2snipe request https://api-school.apple.com/v1/orgDevices
```

### Commands
Expand All @@ -139,8 +146,8 @@ axm2snipe request https://mdmenrollment.apple.com/server/devices
| `download` | Download ABM/ASM data to local cache |
| `setup` | Create AXM custom fields in Snipe-IT and save mappings to config |
| `test` | Test connections to ABM/ASM and Snipe-IT |
| `access-token` | Print an ABM API access token |
| `request <url>` | Make an authenticated ABM API GET request |
| `access-token` | Print an ABM/ASM API access token |
| `request <url>` | Make an authenticated ABM/ASM API GET request |

### Global Flags

Expand Down
8 changes: 4 additions & 4 deletions abmclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,20 @@ type Client struct {
abm *abm.Client
}

// NewClient creates a new ABM client using the abm library for auth.
// NewClient creates a new ABM/ASM client using the abm library for auth.
// Rate limiting and retry logic are handled by the upstream abm library.
func NewClient(ctx context.Context, clientID, keyID, privateKey string) (*Client, error) {
func NewClient(ctx context.Context, clientID, keyID, privateKey, scope, baseURL string) (*Client, error) {
assertion, err := abm.NewAssertion(ctx, clientID, keyID, privateKey)
if err != nil {
return nil, fmt.Errorf("creating ABM assertion: %w", err)
}

ts, err := abm.NewTokenSource(ctx, nil, clientID, assertion, "")
ts, err := abm.NewTokenSource(ctx, nil, clientID, assertion, scope)
if err != nil {
return nil, fmt.Errorf("creating ABM token source: %w", err)
}

client, err := abm.NewClient(nil, ts)
client, err := abm.NewClientWithBaseURL(nil, ts, baseURL)
if err != nil {
return nil, fmt.Errorf("creating ABM client: %w", err)
}
Expand Down
10 changes: 2 additions & 8 deletions cmd/access_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"

"github.com/spf13/cobra"
"github.com/CampusTech/abm"
)

// NewAccessTokenCmd creates the access-token command.
Expand All @@ -25,14 +24,9 @@ func runAccessToken(cmd *cobra.Command, args []string) error {

ctx := context.Background()

assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue())
ts, err := newABMTokenSource(ctx)
if err != nil {
return fmt.Errorf("creating ABM assertion: %w", err)
}

ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, "")
if err != nil {
return fmt.Errorf("creating ABM token source: %w", err)
return err
}

token, err := ts.Token()
Expand Down
10 changes: 2 additions & 8 deletions cmd/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/http"

"github.com/spf13/cobra"
"github.com/CampusTech/abm"
"golang.org/x/oauth2"
)

Expand All @@ -29,14 +28,9 @@ func runRequest(cmd *cobra.Command, args []string) error {

ctx := context.Background()

assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue())
ts, err := newABMTokenSource(ctx)
if err != nil {
return fmt.Errorf("creating ABM assertion: %w", err)
}

ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, "")
if err != nil {
return fmt.Errorf("creating ABM token source: %w", err)
return err
}

client := oauth2.NewClient(ctx, ts)
Expand Down
34 changes: 32 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"strings"
"syscall"

"github.com/CampusTech/abm"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/oauth2"

"github.com/CampusTech/axm2snipe/abmclient"
"github.com/CampusTech/axm2snipe/config"
Expand Down Expand Up @@ -184,14 +186,42 @@ func contextWithSignal() (context.Context, context.CancelFunc) {

// newABMClient creates and returns a new ABM client from global config.
func newABMClient(ctx context.Context) (*abmclient.Client, error) {
log.Info("Connecting to Apple Business Manager...")
client, err := abmclient.NewClient(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue())
log.Infof("Connecting to Apple %s Manager...", appleManagerName())
client, err := abmclient.NewClient(
ctx,
Cfg.ABM.ClientID,
Cfg.ABM.KeyID,
Cfg.ABM.PrivateKeyValue(),
Cfg.ABM.OAuthScopeValue(),
Cfg.ABM.APIBaseURLValue(),
)
if err != nil {
return nil, fmt.Errorf("creating ABM client: %w", err)
}
return client, nil
}

func newABMTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue())
if err != nil {
return nil, fmt.Errorf("creating ABM assertion: %w", err)
}

ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, Cfg.ABM.OAuthScopeValue())
if err != nil {
return nil, fmt.Errorf("creating ABM token source: %w", err)
}

return ts, nil
}

func appleManagerName() string {
if Cfg != nil && Cfg.ABM.IsSchoolManager() {
return "School"
}
return "Business"
}

// newSnipeClient creates and returns a new Snipe-IT client from global config.
func newSnipeClient() (*snipe.Client, error) {
log.Info("Connecting to Snipe-IT...")
Expand Down
62 changes: 57 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ type SlackConfig struct {
type ABMConfig struct {
ClientID string `yaml:"client_id"`
KeyID string `yaml:"key_id"`
PrivateKey string `yaml:"private_key"` // path to PEM file or raw PEM string
PrivateKey string `yaml:"private_key"` // path to PEM file or raw PEM string
OAuthScope string `yaml:"oauth_scope"` // optional explicit override; auto-detected from client_id when empty
APIBaseURL string `yaml:"api_base_url"` // optional explicit override; auto-detected from client_id when empty
}

// SnipeITConfig holds Snipe-IT API settings.
Expand Down Expand Up @@ -80,19 +82,25 @@ func Load(path string) (*Config, error) {
}

// Environment variable overrides
if v := os.Getenv("AXM_ABM_CLIENT_ID"); v != "" {
if v := strings.TrimSpace(os.Getenv("AXM_ABM_CLIENT_ID")); v != "" {
cfg.ABM.ClientID = v
}
if v := os.Getenv("AXM_ABM_KEY_ID"); v != "" {
if v := strings.TrimSpace(os.Getenv("AXM_ABM_KEY_ID")); v != "" {
cfg.ABM.KeyID = v
}
if v := os.Getenv("AXM_ABM_PRIVATE_KEY"); v != "" {
cfg.ABM.PrivateKey = v
}
if v := os.Getenv("AXM_SNIPE_URL"); v != "" {
if v := strings.TrimSpace(os.Getenv("AXM_ABM_OAUTH_SCOPE")); v != "" {
cfg.ABM.OAuthScope = v
}
if v := strings.TrimSpace(os.Getenv("AXM_ABM_API_BASE_URL")); v != "" {
cfg.ABM.APIBaseURL = v
}
if v := strings.TrimSpace(os.Getenv("AXM_SNIPE_URL")); v != "" {
cfg.SnipeIT.URL = v
}
if v := os.Getenv("AXM_SNIPE_API_KEY"); v != "" {
if v := strings.TrimSpace(os.Getenv("AXM_SNIPE_API_KEY")); v != "" {
cfg.SnipeIT.APIKey = v
}

Expand Down Expand Up @@ -129,6 +137,44 @@ func (c *ABMConfig) PrivateKeyValue() string {
return "-----BEGIN EC PRIVATE KEY-----\n" + key + "\n-----END EC PRIVATE KEY-----\n"
}

const (
BusinessAPIClientPrefix = "BUSINESSAPI."
SchoolAPIClientPrefix = "SCHOOLAPI."
BusinessAPIScope = "business.api"
SchoolAPIScope = "school.api"
BusinessAPIBaseURL = "https://api-business.apple.com/"
SchoolAPIBaseURL = "https://api-school.apple.com/"
)

// IsSchoolManager reports whether the configured client ID belongs to ASM.
func (c *ABMConfig) IsSchoolManager() bool {
return strings.HasPrefix(strings.TrimSpace(c.ClientID), SchoolAPIClientPrefix)
}

// OAuthScopeValue returns the configured OAuth scope, auto-detecting a default
// from the client ID prefix when not explicitly set.
func (c *ABMConfig) OAuthScopeValue() string {
if v := strings.TrimSpace(c.OAuthScope); v != "" {
return v
}
if c.IsSchoolManager() {
return SchoolAPIScope
}
return BusinessAPIScope
}

// APIBaseURLValue returns the configured API base URL, auto-detecting a
// default from the client ID prefix when not explicitly set.
func (c *ABMConfig) APIBaseURLValue() string {
if v := strings.TrimSpace(c.APIBaseURL); v != "" {
return v
}
if c.IsSchoolManager() {
return SchoolAPIBaseURL
}
return BusinessAPIBaseURL
}
Comment on lines +149 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The new ValidateABM checks are currently dead code.

OAuthScopeValue() and APIBaseURLValue() always return either an explicit override or a hard-coded default, so Lines 189-194 can never fire. That means an unrecognized client_id prefix is still silently treated as Business Manager. Either make the resolver return an unknown state for prefixes other than BUSINESSAPI. / SCHOOLAPI., or remove the unreachable validation if fallback-to-Business is intentional.

💡 One way to make the validation meaningful
+func (c *ABMConfig) authDefaults() (scope, baseURL string, ok bool) {
+	id := strings.TrimSpace(c.ClientID)
+	switch {
+	case strings.HasPrefix(id, SchoolAPIClientPrefix):
+		return SchoolAPIScope, SchoolAPIBaseURL, true
+	case strings.HasPrefix(id, BusinessAPIClientPrefix):
+		return BusinessAPIScope, BusinessAPIBaseURL, true
+	default:
+		return "", "", false
+	}
+}
+
 // OAuthScopeValue returns the configured OAuth scope, auto-detecting a default
 // from the client ID prefix when not explicitly set.
 func (c *ABMConfig) OAuthScopeValue() string {
 	if v := strings.TrimSpace(c.OAuthScope); v != "" {
 		return v
 	}
-	if c.IsSchoolManager() {
-		return SchoolAPIScope
-	}
-	return BusinessAPIScope
+	scope, _, _ := c.authDefaults()
+	return scope
 }
 
 // APIBaseURLValue returns the configured API base URL, auto-detecting a
 // default from the client ID prefix when not explicitly set.
 func (c *ABMConfig) APIBaseURLValue() string {
 	if v := strings.TrimSpace(c.APIBaseURL); v != "" {
 		return v
 	}
-	if c.IsSchoolManager() {
-		return SchoolAPIBaseURL
-	}
-	return BusinessAPIBaseURL
+	_, baseURL, _ := c.authDefaults()
+	return baseURL
 }

Also applies to: 189-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/config.go` around lines 149 - 176, The validation in ValidateABM is
dead because OAuthScopeValue() and APIBaseURLValue() always fall back to
Business defaults; change the prefix resolution to yield an explicit "unknown"
state and make the value resolvers honor that: add a helper like
resolveClientPrefix() (or adjust IsSchoolManager() into a tri-state resolver)
that returns School, Business, or Unknown; update OAuthScopeValue() and
APIBaseURLValue() to return an empty string (or a sentinel) when the resolver
returns Unknown instead of defaulting to Business; leave ValidateABM to treat
empty/sentinel as an error so unrecognized client_id prefixes are caught.


// ValidateABM checks that ABM credentials are set.
func (c *Config) ValidateABM() error {
if c.ABM.ClientID == "" {
Expand All @@ -140,6 +186,12 @@ func (c *Config) ValidateABM() error {
if c.ABM.PrivateKey == "" {
return fmt.Errorf("abm.private_key is required (file path, inline PEM, or bare base64)")
}
if strings.TrimSpace(c.ABM.OAuthScopeValue()) == "" {
return fmt.Errorf("abm.oauth_scope could not be determined")
}
if strings.TrimSpace(c.ABM.APIBaseURLValue()) == "" {
return fmt.Errorf("abm.api_base_url could not be determined")
}
return nil
}

Expand Down
63 changes: 63 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ snipe_it:
}

t.Setenv("AXM_ABM_CLIENT_ID", "env-client-id")
t.Setenv("AXM_ABM_OAUTH_SCOPE", "env.scope")
t.Setenv("AXM_ABM_API_BASE_URL", "https://env.example.invalid/")
t.Setenv("AXM_SNIPE_URL", "https://env.example.com")

cfg, err := Load(path)
Expand All @@ -149,6 +151,12 @@ snipe_it:
if cfg.ABM.ClientID != "env-client-id" {
t.Errorf("env override ABM.ClientID = %q, want env-client-id", cfg.ABM.ClientID)
}
if cfg.ABM.OAuthScope != "env.scope" {
t.Errorf("env override ABM.OAuthScope = %q, want env.scope", cfg.ABM.OAuthScope)
}
if cfg.ABM.APIBaseURL != "https://env.example.invalid/" {
t.Errorf("env override ABM.APIBaseURL = %q", cfg.ABM.APIBaseURL)
}
if cfg.SnipeIT.URL != "https://env.example.com" {
t.Errorf("env override SnipeIT.URL = %q", cfg.SnipeIT.URL)
}
Expand Down Expand Up @@ -213,6 +221,61 @@ func TestValidateABM_MissingPrivateKey(t *testing.T) {
}
}

func TestABMConfig_AutoDetectsScopeAndBaseURL(t *testing.T) {
tests := []struct {
name string
cfg ABMConfig
want string
wantBase string
}{
{
name: "school",
cfg: ABMConfig{
ClientID: "SCHOOLAPI.test-id",
},
want: SchoolAPIScope,
wantBase: SchoolAPIBaseURL,
},
{
name: "business",
cfg: ABMConfig{
ClientID: "BUSINESSAPI.test-id",
},
want: BusinessAPIScope,
wantBase: BusinessAPIBaseURL,
},
{
name: "unknown prefix defaults to business",
cfg: ABMConfig{
ClientID: "UNKNOWN.test-id",
},
want: BusinessAPIScope,
wantBase: BusinessAPIBaseURL,
},
{
name: "override",
cfg: ABMConfig{
ClientID: "SCHOOLAPI.test-id",
OAuthScope: "custom.scope",
APIBaseURL: "https://example.invalid/",
},
want: "custom.scope",
wantBase: "https://example.invalid/",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.OAuthScopeValue(); got != tt.want {
t.Fatalf("OAuthScopeValue() = %q, want %q", got, tt.want)
}
if got := tt.cfg.APIBaseURLValue(); got != tt.wantBase {
t.Fatalf("APIBaseURLValue() = %q, want %q", got, tt.wantBase)
}
})
}
}

func TestValidateSnipeIT_MissingURL(t *testing.T) {
cfg := &Config{SnipeIT: SnipeITConfig{APIKey: "test", ManufacturerID: 1, DefaultStatusID: 1, CategoryID: 1}}
if err := cfg.ValidateSnipeIT(); err == nil {
Expand Down
6 changes: 6 additions & 0 deletions settings.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ abm:
# 3. Bare base64 (the key body without PEM headers — useful for env vars):
# private_key: "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg..."
# AXM_ABM_PRIVATE_KEY accepts all three formats as well.
# OAuth scope and API base URL are auto-detected from client_id:
# BUSINESSAPI.* -> business.api + https://api-business.apple.com/
# SCHOOLAPI.* -> school.api + https://api-school.apple.com/
# Optional explicit overrides:
# oauth_scope: "school.api"
# api_base_url: "https://api-school.apple.com/"

snipe_it:
# Snipe-IT instance URL (no trailing slash)
Expand Down