From 9e60f55d0d6b6ce9383092dec686bbd43385fa68 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Mon, 22 May 2023 11:56:43 -0300 Subject: [PATCH] Define the "jamf_service" configuration (#26478) * Add Jamf configuration to `servicecfg` * Add Jamf to enterpriseServicesEnabled() * Add JamfSpecV1 validation * Add jamf_service to fileconf * Add godoc to constants * Use non-pointer durations --- api/types/jamf.go | 93 ++++++++++++++ api/types/jamf_test.go | 177 ++++++++++++++++++++++++++ lib/config/configuration.go | 25 ++++ lib/config/configuration_test.go | 139 ++++++++++++++++++++ lib/config/fileconf.go | 95 ++++++++++++++ lib/service/service.go | 3 +- lib/service/service_test.go | 16 ++- lib/service/servicecfg/config.go | 6 +- lib/service/servicecfg/config_test.go | 43 +++++-- lib/service/servicecfg/jamf.go | 30 +++++ 10 files changed, 610 insertions(+), 17 deletions(-) create mode 100644 api/types/jamf.go create mode 100644 api/types/jamf_test.go create mode 100644 lib/service/servicecfg/jamf.go diff --git a/api/types/jamf.go b/api/types/jamf.go new file mode 100644 index 0000000000000..e731d29ea7991 --- /dev/null +++ b/api/types/jamf.go @@ -0,0 +1,93 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "net/url" + "strings" + "time" + + "github.com/gravitational/trace" + "golang.org/x/exp/slices" +) + +const ( + // JamfOnMissingNOOP is the textual representation for the NOOP on_missing + // action. + JamfOnMissingNoop = "NOOP" + // JamfOnMissingDelete is the textual representation for the DELETE on_missing + // action. + JamfOnMissingDelete = "DELETE" +) + +// JamfOnMissingActions is a slice of all textual on_missing representations, +// excluding the empty string. +var JamfOnMissingActions = []string{ + JamfOnMissingNoop, + JamfOnMissingDelete, +} + +const ( + // JamfSyncPeriodPartialDefault is the default value for sync_period_partial + // in inventory entries. + JamfSyncPeriodPartialDefault = 6 * time.Hour + // JamfSyncPeriodFullDefault is the default value for sync_period_full in + // inventory entries. + JamfSyncPeriodFullDefault = 24 * time.Hour +) + +// ValidateJamfSpecV1 validates a [JamfSpecV1] instance. +func ValidateJamfSpecV1(s *JamfSpecV1) error { + switch { + case s == nil: + return trace.BadParameter("spec required") + case s.Username == "": + return trace.BadParameter("username required") + case s.Password == "": + return trace.BadParameter("password required") + } + + switch u, err := url.Parse(s.ApiEndpoint); { + case err != nil: + return trace.BadParameter("invalid API endpoint: %v", err) + case u.Host == "": + return trace.BadParameter("invalid API endpoint: missing hostname") + } + + for i, e := range s.Inventory { + switch { + case e == nil: + return trace.BadParameter("inventory entry #%v is nil", i) + case e.OnMissing != "" && !slices.Contains(JamfOnMissingActions, e.OnMissing): + return trace.BadParameter( + "inventory[%v]: invalid on_missing action %q (expect empty or one of [%v])", + i, e.OnMissing, strings.Join(JamfOnMissingActions, ",")) + } + + syncPartial := e.SyncPeriodPartial + if syncPartial == 0 { + syncPartial = Duration(JamfSyncPeriodPartialDefault) + } + syncFull := e.SyncPeriodFull + if syncFull == 0 { + syncFull = Duration(JamfSyncPeriodFullDefault) + } + if syncPartial > syncFull { + return trace.BadParameter("inventory[%v]: sync_period_partial is greater than sync_period_full, partial syncs will never happen", i) + } + } + + return nil +} diff --git a/api/types/jamf_test.go b/api/types/jamf_test.go new file mode 100644 index 0000000000000..017a9899a14ff --- /dev/null +++ b/api/types/jamf_test.go @@ -0,0 +1,177 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types_test + +import ( + "testing" + "time" + + "github.com/gogo/protobuf/proto" + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + + "github.com/gravitational/teleport/api/types" +) + +func TestValidateJamfSpecV1(t *testing.T) { + validSpec := &types.JamfSpecV1{ + Enabled: true, + ApiEndpoint: "https://yourtenant.jamfcloud.com", + Username: "llama", + Password: "supersecret!!1!", + } + validEntry := &types.JamfInventoryEntry{ + FilterRsql: "", // no filters + SyncPeriodPartial: 0, // default period + SyncPeriodFull: 0, // default period + OnMissing: "", // same as NOOP + } + + modify := func(f func(spec *types.JamfSpecV1)) *types.JamfSpecV1 { + spec := proto.Clone(validSpec).(*types.JamfSpecV1) + f(spec) + return spec + } + + tests := []struct { + name string + spec *types.JamfSpecV1 + wantErr string + }{ + { + name: "minimal spec", + spec: validSpec, + }, + { + name: "spec with inventory", + spec: &types.JamfSpecV1{ + Enabled: true, + ApiEndpoint: "https://yourtenant.jamfcloud.com", + Username: "llama", + Password: "supersecret!!1!", + Inventory: []*types.JamfInventoryEntry{ + { + FilterRsql: `general.remoteManagement.managed==true and general.platform=="Mac"`, + SyncPeriodPartial: types.Duration(4 * time.Hour), + SyncPeriodFull: types.Duration(48 * time.Hour), + OnMissing: "DELETE", + }, + { + FilterRsql: `general.remoteManagement.managed==false`, + OnMissing: "NOOP", + }, + validEntry, + }, + }, + }, + { + name: "all fields", + spec: &types.JamfSpecV1{ + Enabled: true, + Name: "jamf2", + SyncDelay: types.Duration(2 * time.Minute), + ApiEndpoint: "https://yourtenant.jamfcloud.com", + Username: "llama", + Password: "supersecret!!1!", + Inventory: []*types.JamfInventoryEntry{ + { + FilterRsql: `general.remoteManagement.managed==true and general.platform=="Mac"`, + SyncPeriodPartial: types.Duration(4 * time.Hour), + SyncPeriodFull: types.Duration(48 * time.Hour), + OnMissing: "DELETE", + }, + }, + }, + }, + { + name: "nil spec", + spec: nil, + wantErr: "spec required", + }, + { + name: "api_endpoint invalid", + spec: modify(func(spec *types.JamfSpecV1) { + spec.ApiEndpoint = "https://%%" + }), + wantErr: "API endpoint", + }, + { + name: "api_endpoint empty hostname", + spec: modify(func(spec *types.JamfSpecV1) { + spec.ApiEndpoint = "not a valid URL" + }), + wantErr: "missing hostname", + }, + { + name: "username empty", + spec: modify(func(spec *types.JamfSpecV1) { + spec.Username = "" + }), + wantErr: "username", + }, + { + name: "password empty", + spec: modify(func(spec *types.JamfSpecV1) { + spec.Password = "" + }), + wantErr: "password", + }, + { + name: "inventory nil entry", + spec: modify(func(spec *types.JamfSpecV1) { + spec.Inventory = []*types.JamfInventoryEntry{ + nil, + } + }), + wantErr: "is nil", + }, + { + name: "inventory sync_partial > sync_full", + spec: modify(func(spec *types.JamfSpecV1) { + spec.Inventory = []*types.JamfInventoryEntry{ + validEntry, + { + SyncPeriodPartial: types.Duration(12 * time.Hour), + SyncPeriodFull: types.Duration(8 * time.Hour), + }, + } + }), + wantErr: "greater than sync_period_full", + }, + { + name: "inventory on_missing invalid", + spec: modify(func(spec *types.JamfSpecV1) { + spec.Inventory = []*types.JamfInventoryEntry{ + validEntry, + { + OnMissing: "BANANA", + }, + } + }), + wantErr: "on_missing", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := types.ValidateJamfSpecV1(test.spec) + if test.wantErr == "" { + assert.NoError(t, err, "ValidateJamfSpecV1 failed") + } else { + assert.ErrorContains(t, err, test.wantErr, "ValidateJamfSpecV1 error mismatch") + assert.True(t, trace.IsBadParameter(err), "ValidateJamfSpecV1 returned non-BadParameter error: %T", err) + } + }) + } +} diff --git a/lib/config/configuration.go b/lib/config/configuration.go index 489e3520e65d0..557ef21b8c67e 100644 --- a/lib/config/configuration.go +++ b/lib/config/configuration.go @@ -29,6 +29,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "regexp" "runtime" "strings" @@ -472,6 +473,12 @@ func ApplyFileConfig(fc *FileConfig, cfg *servicecfg.Config) error { } } + // Apply regardless of Jamf being enabled. + // If a config is present, we want it to be valid. + if err := applyJamfConfig(fc, cfg); err != nil { + return trace.Wrap(err) + } + return nil } @@ -2393,3 +2400,21 @@ func applyOktaConfig(fc *FileConfig, cfg *servicecfg.Config) error { cfg.Okta.APITokenPath = fc.Okta.APITokenPath return nil } + +func applyJamfConfig(fc *FileConfig, cfg *servicecfg.Config) error { + // Ignore empty configs, validate and transform anything else. + if reflect.DeepEqual(fc.Jamf, JamfService{}) { + return nil + } + + jamfSpec, err := fc.Jamf.toJamfSpecV1() + if err != nil { + return trace.Wrap(err) + } + + cfg.Jamf = servicecfg.JamfConfig{ + Spec: jamfSpec, + ExitOnSync: fc.Jamf.ExitOnSync, + } + return nil +} diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go index fa42a02fc911c..7902106caa5c8 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -3384,6 +3384,145 @@ func TestApplyFileConfig_deviceTrustMode_errors(t *testing.T) { } } +func TestApplyConfig_JamfService(t *testing.T) { + tempDir := t.TempDir() + + // Write a password file, valid configs require one. + const password = "supersecret!!1!" + passwordFile := filepath.Join(tempDir, "test_jamf_password.txt") + require.NoError(t, + os.WriteFile(passwordFile, []byte(password+"\n"), 0400), + "WriteFile(%q) failed", passwordFile) + + minimalYAML := fmt.Sprintf(` +jamf_service: + enabled: true + api_endpoint: https://yourtenant.jamfcloud.com + username: llama + password_file: %v +`, passwordFile) + + tests := []struct { + name string + yaml string + wantErr string + want servicecfg.JamfConfig + }{ + { + name: "minimal config", + yaml: minimalYAML, + want: servicecfg.JamfConfig{ + Spec: &types.JamfSpecV1{ + Enabled: true, + ApiEndpoint: "https://yourtenant.jamfcloud.com", + Username: "llama", + Password: password, + }, + }, + }, + { + name: "all fields", + yaml: minimalYAML + ` name: jamf2 + sync_delay: 1m + exit_on_sync: true + inventory: + - filter_rsql: 1==1 + sync_period_partial: 4h + sync_period_full: 48h + on_missing: NOOP + - {}`, + want: servicecfg.JamfConfig{ + Spec: &types.JamfSpecV1{ + Enabled: true, + Name: "jamf2", + SyncDelay: types.Duration(1 * time.Minute), + ApiEndpoint: "https://yourtenant.jamfcloud.com", + Username: "llama", + Password: password, + Inventory: []*types.JamfInventoryEntry{ + { + FilterRsql: "1==1", + SyncPeriodPartial: types.Duration(4 * time.Hour), + SyncPeriodFull: types.Duration(48 * time.Hour), + OnMissing: "NOOP", + }, + {}, + }, + }, + ExitOnSync: true, + }, + }, + + { + name: "listen_addr not supported", + yaml: minimalYAML + ` listen_addr: localhost:55555`, + wantErr: "listen_addr", + }, + { + name: "password_file empty", + yaml: ` +jamf_service: + enabled: true + api_endpoint: https://yourtenant.jamfcloud.com + username: llama`, + wantErr: "password_file required", + }, + { + name: "password_file invalid", + yaml: ` +jamf_service: + enabled: true + api_endpoint: https://yourtenant.jamfcloud.com + username: llama + password_file: /path/to/file/that/doesnt/exist.txt`, + wantErr: "password_file", + }, + { + name: "spec is validated", + yaml: minimalYAML + ` inventory: + - on_missing: BANANA`, + wantErr: "on_missing", + }, + + { + name: "absent config ignored", + yaml: ``, + }, + { + name: "empty config ignored", + yaml: `jamf_service: {}`, + }, + { + name: "disabled config is validated", + yaml: ` +jamf_service: + enabled: false + api_endpoint: https://yourtenant.jamfcloud.com + username: llama`, + wantErr: "password_file", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fc, err := ReadConfig(strings.NewReader(test.yaml)) + require.NoError(t, err, "ReadConfig failed") + + cfg := servicecfg.MakeDefaultConfig() + err = ApplyFileConfig(fc, cfg) + if test.wantErr == "" { + require.NoError(t, err, "ApplyFileConfig failed") + } else { + assert.ErrorContains(t, err, test.wantErr, "ApplyFileConfig error mismatch") + return + } + + if diff := cmp.Diff(test.want, cfg.Jamf, protocmp.Transform()); diff != "" { + t.Errorf("ApplyFileConfig: JamfConfig mismatch (-want +got)\n%s", diff) + } + }) + } +} + func TestAuthHostedPlugins(t *testing.T) { t.Parallel() diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go index 29c41596da4fa..ffcaa6031023d 100644 --- a/lib/config/fileconf.go +++ b/lib/config/fileconf.go @@ -94,6 +94,9 @@ type FileConfig struct { // Okta is the "okta_service" section in the Teleport configuration file Okta Okta `yaml:"okta_service,omitempty"` + // Jamf is the "jamf_service" section in the config file. + Jamf JamfService `yaml:"jamf_service,omitempty"` + // Plugins is the section of the config for configuring the plugin service. Plugins PluginService `yaml:"plugin_service,omitempty"` } @@ -2370,3 +2373,95 @@ type Okta struct { // APITokenPath is the path to the Okta API token. APITokenPath string `yaml:"api_token_path,omitempty"` } + +// JamfService is the yaml representation of jamf_service. +// Corresponds to [types.JamfSpecV1]. +type JamfService struct { + Service `yaml:",inline"` + // Name is the name of the sync device source. + Name string `yaml:"name,omitempty"` + // SyncDelay is the initial sync delay. + // Zero means "server default", negative means "immediate". + SyncDelay time.Duration `yaml:"sync_delay,omitempty"` + // ExitOnSync tells the service to exit immediately after the first sync. + ExitOnSync bool `yaml:"exit_on_sync,omitempty"` + // APIEndpoint is the Jamf Pro API endpoint. + // Example: "https://yourtenant.jamfcloud.com". + APIEndpoint string `yaml:"api_endpoint,omitempty"` + // Username is the Jamf Pro API username. + Username string `yaml:"username,omitempty"` + // PasswordFile is a file containing the Jamf Pro API password. + // A single trailing newline is trimmed, anything else is taken literally. + PasswordFile string `yaml:"password_file,omitempty"` + // Inventory are the entries for inventory sync. + Inventory []*JamfInventoryEntry `yaml:"inventory,omitempty"` +} + +// JamfInventoryEntry is the yaml representation of a jamf_service.inventory +// entry. +// Corresponds to [types.JamfInventoryEntry]. +type JamfInventoryEntry struct { + // FilterRSQL is a Jamf Pro API RSQL filter string. + FilterRSQL string `yaml:"filter_rsql,omitempty"` + // SyncPeriodPartial is the period for PARTIAL syncs. + // Zero means "server default", negative means "disabled". + SyncPeriodPartial time.Duration `yaml:"sync_period_partial,omitempty"` + // SyncPeriodFull is the period for FULL syncs. + // Zero means "server default", negative means "disabled". + SyncPeriodFull time.Duration `yaml:"sync_period_full,omitempty"` + // OnMissing is the trigger for devices missing from the MDM inventory view. + // See [types.JamfInventoryEntry.OnMissing]. + OnMissing string `yaml:"on_missing,omitempty"` +} + +func (j *JamfService) toJamfSpecV1() (*types.JamfSpecV1, error) { + switch { + case j == nil: + return nil, trace.BadParameter("jamf_service is nil") + case j.ListenAddress != "": + return nil, trace.BadParameter("jamf listen_addr not supported") + case j.PasswordFile == "": + return nil, trace.BadParameter("jamf password_file required") + } + + // Read password from file. + pwdBytes, err := os.ReadFile(j.PasswordFile) + if err != nil { + return nil, trace.BadParameter("jamf password_file: %v", err) + } + pwd := string(pwdBytes) + if pwd == "" { + return nil, trace.BadParameter("jamf password_file is empty") + } + // Trim trailing \n? + if l := len(pwd); pwd[l-1] == '\n' { + pwd = pwd[:l-1] + } + + // Assemble spec. + inventory := make([]*types.JamfInventoryEntry, len(j.Inventory)) + for i, e := range j.Inventory { + inventory[i] = &types.JamfInventoryEntry{ + FilterRsql: e.FilterRSQL, + SyncPeriodPartial: types.Duration(e.SyncPeriodPartial), + SyncPeriodFull: types.Duration(e.SyncPeriodFull), + OnMissing: e.OnMissing, + } + } + spec := &types.JamfSpecV1{ + Enabled: j.Enabled(), + Name: j.Name, + SyncDelay: types.Duration(j.SyncDelay), + ApiEndpoint: j.APIEndpoint, + Username: j.Username, + Password: pwd, + Inventory: inventory, + } + + // Validate. + if err := types.ValidateJamfSpecV1(spec); err != nil { + return nil, trace.BadParameter("jamf_service %v", err) + } + + return spec, nil +} diff --git a/lib/service/service.go b/lib/service/service.go index c102c61672209..d8c44b0315fc6 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -1181,7 +1181,8 @@ func NewTeleport(cfg *servicecfg.Config) (*TeleportProcess, error) { // enterpriseServicesEnabled will return true of any enterprise services are enabled. func (process *TeleportProcess) enterpriseServicesEnabled() bool { - return modules.GetModules().BuildType() == modules.BuildEnterprise && (process.Config.Okta.Enabled) + return modules.GetModules().BuildType() == modules.BuildEnterprise && + (process.Config.Okta.Enabled || process.Config.Jamf.Enabled()) } // notifyParent notifies parent process that this process has started diff --git a/lib/service/service_test.go b/lib/service/service_test.go index 1668de2e7849d..f3e47856ca958 100644 --- a/lib/service/service_test.go +++ b/lib/service/service_test.go @@ -1272,8 +1272,22 @@ func TestEnterpriseServicesEnabled(t *testing.T) { }, expected: false, }, + { + name: "jamf enabled", + enterprise: true, + config: &servicecfg.Config{ + Jamf: servicecfg.JamfConfig{ + Spec: &types.JamfSpecV1{ + Enabled: true, + ApiEndpoint: "https://example.jamfcloud.com", + Username: "llama", + Password: "supersecret!!1!ONE", + }, + }, + }, + expected: true, + }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { buildType := modules.BuildOSS diff --git a/lib/service/servicecfg/config.go b/lib/service/servicecfg/config.go index f95e06ee0d190..8514810b69cde 100644 --- a/lib/service/servicecfg/config.go +++ b/lib/service/servicecfg/config.go @@ -109,6 +109,9 @@ type Config struct { // Okta defines the okta service configuration. Okta OktaConfig + // Jamf defines the Jamf MDM service configuration. + Jamf JamfConfig + // Tracing defines the tracing service configuration. Tracing TracingConfig @@ -681,6 +684,7 @@ func verifyEnabledService(cfg *Config) error { cfg.WindowsDesktop.Enabled, cfg.Discovery.Enabled, cfg.Okta.Enabled, + cfg.Jamf.Enabled(), cfg.OpenSSH.Enabled, } @@ -691,5 +695,5 @@ func verifyEnabledService(cfg *Config) error { } return trace.BadParameter( - "config: enable at least one of auth_service, ssh_service, proxy_service, app_service, database_service, kubernetes_service, windows_desktop_service, discovery_service, or okta_service") + "config: enable at least one of auth_service, ssh_service, proxy_service, app_service, database_service, kubernetes_service, windows_desktop_service, discovery_service, okta_service or jamf_service") } diff --git a/lib/service/servicecfg/config_test.go b/lib/service/servicecfg/config_test.go index 9bef4521caa1f..80c517bd1fcf3 100644 --- a/lib/service/servicecfg/config_test.go +++ b/lib/service/servicecfg/config_test.go @@ -24,6 +24,7 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/backend/lite" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/fixtures" @@ -476,23 +477,23 @@ func TestHostLabelMatching(t *testing.T) { func TestValidateConfig(t *testing.T) { tests := []struct { - desc string - config *Config - err string + desc string + config *Config + wantErr string }{ { desc: "invalid version", config: &Config{ Version: "v1.1", }, - err: fmt.Sprintf("version must be one of %s", strings.Join(defaults.TeleportConfigVersions, ", ")), + wantErr: fmt.Sprintf("version must be one of %s", strings.Join(defaults.TeleportConfigVersions, ", ")), }, { desc: "no service enabled", config: &Config{ Version: defaults.TeleportConfigVersionV2, }, - err: "config: enable at least one of auth_service, ssh_service, proxy_service, app_service, database_service, kubernetes_service, windows_desktop_service, discovery_service, or okta_service", + wantErr: "config: enable at least one of auth_service, ssh_service, proxy_service, app_service, database_service, kubernetes_service, windows_desktop_service, discovery_service, okta_service ", }, { desc: "no auth_servers or proxy_server specified", @@ -502,7 +503,7 @@ func TestValidateConfig(t *testing.T) { Enabled: true, }, }, - err: "config: auth_server or proxy_server is required", + wantErr: "config: auth_server or proxy_server is required", }, { desc: "no auth_servers specified", @@ -512,7 +513,7 @@ func TestValidateConfig(t *testing.T) { Enabled: true, }, }, - err: "config: auth_servers is required", + wantErr: "config: auth_servers is required", }, { desc: "specifying proxy_server with the wrong config version", @@ -523,7 +524,7 @@ func TestValidateConfig(t *testing.T) { }, ProxyServer: *utils.MustParseAddr("0.0.0.0"), }, - err: "config: proxy_server is supported from config version v3 onwards", + wantErr: "config: proxy_server is supported from config version v3 onwards", }, { desc: "specifying auth_server when app_service is enabled", @@ -535,7 +536,7 @@ func TestValidateConfig(t *testing.T) { DataDir: "/", authServers: []utils.NetAddr{*utils.MustParseAddr("0.0.0.0")}, }, - err: "config: when app_service is enabled, proxy_server must be specified instead of auth_server", + wantErr: "config: when app_service is enabled, proxy_server must be specified instead of auth_server", }, { desc: "specifying auth_server when db_service is enabled", @@ -547,17 +548,17 @@ func TestValidateConfig(t *testing.T) { DataDir: "/", authServers: []utils.NetAddr{*utils.MustParseAddr("0.0.0.0")}, }, - err: "config: when db_service is enabled, proxy_server must be specified instead of auth_server", + wantErr: "config: when db_service is enabled, proxy_server must be specified instead of auth_server", }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { err := ValidateConfig(test.config) - if test.err == "" { + if test.wantErr == "" { require.NoError(t, err) } else { - require.EqualError(t, err, test.err) + require.ErrorContains(t, err, test.wantErr) } }) } @@ -614,11 +615,25 @@ func TestVerifyEnabledService(t *testing.T) { config: &Config{Okta: OktaConfig{Enabled: true}}, errAssertionFunc: require.NoError, }, + { + desc: "jamf enabled", + config: &Config{ + Jamf: JamfConfig{ + Spec: &types.JamfSpecV1{ + Enabled: true, + ApiEndpoint: "https://example.jamfcloud.com", + Username: "llama", + Password: "supersecret!!1!ONE", + }, + }, + }, + errAssertionFunc: require.NoError, + }, { desc: "nothing enabled", config: &Config{}, - errAssertionFunc: func(tt require.TestingT, err error, i ...interface{}) { - require.True(t, trace.IsBadParameter(err)) + errAssertionFunc: func(t require.TestingT, err error, _ ...interface{}) { + require.True(t, trace.IsBadParameter(err), "err is not a BadParameter error: %T", err) }, }, } diff --git a/lib/service/servicecfg/jamf.go b/lib/service/servicecfg/jamf.go new file mode 100644 index 0000000000000..6e4feefaadaac --- /dev/null +++ b/lib/service/servicecfg/jamf.go @@ -0,0 +1,30 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package servicecfg + +import "github.com/gravitational/teleport/api/types" + +// JamfConfig is the configuration for the Jamf MDM service. +type JamfConfig struct { + // Spec is the configuration spec. + Spec *types.JamfSpecV1 + // ExitOnSync controls whether the service performs a single sync operation + // before exiting. + ExitOnSync bool +} + +func (j *JamfConfig) Enabled() bool { + return j != nil && j.Spec != nil && j.Spec.Enabled +}