From 7b671ac2a36a4bc09a31fadf45b4a29cc7bc428f Mon Sep 17 00:00:00 2001 From: Michal Nicpon <39177923+michalnicp@users.noreply.github.com> Date: Mon, 21 Mar 2022 13:16:47 -0600 Subject: [PATCH] Add team failing policies webhook (#4633) * add config to teams * update api docs * update tests --- changes/issue-3267-add-team-webhook | 1 + cmd/fleet/serve.go | 6 +- cmd/fleetctl/apply_test.go | 4 +- cmd/fleetctl/get_test.go | 35 ++- docs/Using-Fleet/REST-API.md | 62 +++- ee/server/service/service_teams.go | 21 +- server/config/config_test.go | 6 +- server/datastore/cached_mysql/cached_mysql.go | 2 +- .../cached_mysql/cached_mysql_test.go | 20 +- .../tables/20220309133956_AddTeamConfig.go | 28 ++ .../20220309133956_AddTeamConfig_test.go | 70 +++++ server/datastore/mysql/schema.sql | 6 +- server/datastore/mysql/teams.go | 26 +- server/datastore/mysql/teams_test.go | 6 +- server/fleet/teams.go | 109 ++++++- server/fleet/users.go | 70 +++++ server/service/integration_enterprise_test.go | 8 +- server/service/osquery.go | 22 +- server/service/targets.go | 73 +++++ server/service/users_test.go | 4 +- server/webhooks/failing_policies.go | 272 +++++++++++------- server/webhooks/failing_policies_test.go | 259 +++++++++++------ 22 files changed, 844 insertions(+), 266 deletions(-) create mode 100644 changes/issue-3267-add-team-webhook create mode 100644 server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig.go create mode 100644 server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig_test.go diff --git a/changes/issue-3267-add-team-webhook b/changes/issue-3267-add-team-webhook new file mode 100644 index 0000000000..c2abb259c2 --- /dev/null +++ b/changes/issue-3267-add-team-webhook @@ -0,0 +1 @@ +* Add ability to configure team failing policies webhook diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 5ef2ac91e8..03a0f7e609 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -856,7 +856,7 @@ func cronWebhooks( // We set the db lock durations to match the intervalReload. maybeTriggerHostStatus(ctx, ds, logger, identifier, appConfig, intervalReload) - maybeTriggerGlobalFailingPoliciesWebhook(ctx, ds, logger, identifier, appConfig, intervalReload, failingPoliciesSet) + maybeTriggerFailingPoliciesWebhook(ctx, ds, logger, identifier, appConfig, intervalReload, failingPoliciesSet) level.Debug(logger).Log("loop", "done") } @@ -883,7 +883,7 @@ func maybeTriggerHostStatus( } } -func maybeTriggerGlobalFailingPoliciesWebhook( +func maybeTriggerFailingPoliciesWebhook( ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, @@ -897,7 +897,7 @@ func maybeTriggerGlobalFailingPoliciesWebhook( return } - if err := webhooks.TriggerGlobalFailingPoliciesWebhook( + if err := webhooks.TriggerFailingPoliciesWebhook( ctx, ds, kitlog.With(logger, "webhook", "failing_policies"), appConfig, failingPoliciesSet, time.Now(), ); err != nil { level.Error(logger).Log("err", "triggering failing policies webhook", "details", err) diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 091d0e460d..87b3e05d22 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -170,8 +170,8 @@ spec: newAgentOpts := json.RawMessage(`{"config":{"something":"else"}}`) require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", tmpFile.Name()})) - assert.JSONEq(t, string(agentOpts), string(*teamsByName["team2"].AgentOptions)) - assert.JSONEq(t, string(newAgentOpts), string(*teamsByName["team1"].AgentOptions)) + assert.JSONEq(t, string(agentOpts), string(*teamsByName["team2"].Config.AgentOptions)) + assert.JSONEq(t, string(newAgentOpts), string(*teamsByName["team1"].Config.AgentOptions)) assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)]) } diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index b8db34dd6e..ccb9715982 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -86,8 +86,8 @@ spec: ` assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "user_roles"})) - assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "user_roles", "--yaml"})) - assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "user_roles", "--json"})) + assert.YAMLEq(t, expectedYaml, runAppForTest(t, []string{"get", "user_roles", "--yaml"})) + assert.JSONEq(t, expectedJson, runAppForTest(t, []string{"get", "user_roles", "--json"})) } func TestGetTeams(t *testing.T) { @@ -130,12 +130,14 @@ func TestGetTeams(t *testing.T) { UserCount: 99, }, { - ID: 43, - CreatedAt: created_at, - Name: "team2", - Description: "team2 description", - UserCount: 87, - AgentOptions: &agentOpts, + ID: 43, + CreatedAt: created_at, + Name: "team2", + Description: "team2 description", + UserCount: 87, + Config: fleet.TeamConfig{ + AgentOptions: &agentOpts, + }, }, }, nil } @@ -153,13 +155,18 @@ apiVersion: v1 kind: team spec: team: - agent_options: null created_at: "1999-03-10T02:45:06.371Z" description: team1 description host_count: 0 id: 42 name: team1 user_count: 99 + webhook_settings: + failing_policies_webhook: + destination_url: "" + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: null --- apiVersion: v1 kind: team @@ -178,9 +185,15 @@ spec: id: 43 name: team2 user_count: 87 + webhook_settings: + failing_policies_webhook: + destination_url: "" + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: null ` - expectedJson := `{"kind":"team","apiVersion":"v1","spec":{"team":{"id":42,"created_at":"1999-03-10T02:45:06.371Z","name":"team1","description":"team1 description","agent_options":null,"user_count":99,"host_count":0}}} -{"kind":"team","apiVersion":"v1","spec":{"team":{"id":43,"created_at":"1999-03-10T02:45:06.371Z","name":"team2","description":"team2 description","agent_options":{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}},"user_count":87,"host_count":0}}} + expectedJson := `{"kind":"team","apiVersion":"v1","spec":{"team":{"id":42,"created_at":"1999-03-10T02:45:06.371Z","name":"team1","description":"team1 description","webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"user_count":99,"host_count":0}}} +{"kind":"team","apiVersion":"v1","spec":{"team":{"id":43,"created_at":"1999-03-10T02:45:06.371Z","name":"team2","description":"team2 description","agent_options":{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}},"webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"user_count":87,"host_count":0}}} ` if tt.shouldHaveExpiredBanner { expectedJson = expiredBanner.String() + expectedJson diff --git a/docs/Using-Fleet/REST-API.md b/docs/Using-Fleet/REST-API.md index 4897ee6094..4a0dcc9e7c 100644 --- a/docs/Using-Fleet/REST-API.md +++ b/docs/Using-Fleet/REST-API.md @@ -5673,6 +5673,14 @@ _Available in Fleet Premium_ }, "overrides": {} } + }, + "webhook_settings": { + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + } } } } @@ -5731,13 +5739,21 @@ _Available in Fleet Premium_ }, "decorators": { "load": [ - "SELECT uuid AS host_uuid FROM system_info;", - "SELECT hostname AS hostname FROM system_info;" + "select uuid as host_uuid from system_info;", + "select hostname as hostname from system_info;" ] } }, "overrides": {} } + }, + "webhook_settings": { + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + } } } ] @@ -5752,12 +5768,18 @@ _Available in Fleet Premium_ #### Parameters -| Name | Type | In | Description | -| -------- | ------ | ---- | --------------------------------------------- | -| id | string | body | **Required.** The desired team's ID. | -| name | string | body | The team's name. | -| host_ids | list | body | A list of hosts that belong to the team. | -| user_ids | list | body | A list of users that are members of the team. | +| Name | Type | In | Description | +| --- | --- | --- | --- | +| id | string | body | **Required.** The desired team's ID. | +| name | string | body | The team's name. | +| host_ids | list | body | A list of hosts that belong to the team. | +| user_ids | list | body | A list of users that are members of the team. | +| webhook_settings | object | body | Webhook settings contains for the team. | +|   failing_policies_webhook | object | body | Failing policies webhook settings. | +|     enable_failing_policies_webhook | boolean | body | Whether or not the failing policies webhook is enabled. | +|     destination_url | string | body | The URL to deliver the webhook requests to. | +|     policy_ids | array | body | List of policy IDs to enable failing policies webhook. | +|     host_batch_size | integer | body | Maximum number of hosts to batch on failing policy webhook requests. The default, 0, means no batching (all hosts failing a policy are sent on one request). | #### Example (add users to a team) @@ -5806,6 +5828,14 @@ _Available in Fleet Premium_ }, "overrides": {} } + }, + "webhook_settings": { + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + } } } } @@ -5858,6 +5888,14 @@ _Available in Fleet Premium_ }, "overrides": {} } + }, + "webhook_settings": { + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + } } } } @@ -5932,6 +5970,14 @@ _Available in Fleet Premium_ }, "overrides": {} } + }, + "webhook_settings": { + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + } } } } diff --git a/ee/server/service/service_teams.go b/ee/server/service/service_teams.go index 5de0a0f933..3388228a72 100644 --- a/ee/server/service/service_teams.go +++ b/ee/server/service/service_teams.go @@ -26,7 +26,9 @@ func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Te return nil, err } team := &fleet.Team{ - AgentOptions: globalConfig.AgentOptions, + Config: fleet.TeamConfig{ + AgentOptions: globalConfig.AgentOptions, + }, } if p.Name == nil { @@ -87,6 +89,9 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if payload.Description != nil { team.Description = *payload.Description } + if payload.WebhookSettings != nil { + team.Config.WebhookSettings = *payload.WebhookSettings + } return svc.ds.SaveTeam(ctx, team) } @@ -102,9 +107,9 @@ func (svc *Service) ModifyTeamAgentOptions(ctx context.Context, teamID uint, opt } if options != nil { - team.AgentOptions = &options + team.Config.AgentOptions = &options } else { - team.AgentOptions = nil + team.Config.AgentOptions = nil } return svc.ds.SaveTeam(ctx, team) @@ -345,9 +350,11 @@ func (svc Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec) agentOptions = config.AgentOptions } _, err = svc.ds.NewTeam(ctx, &fleet.Team{ - Name: spec.Name, - AgentOptions: agentOptions, - Secrets: secrets, + Name: spec.Name, + Config: fleet.TeamConfig{ + AgentOptions: agentOptions, + }, + Secrets: secrets, }) if err != nil { return err @@ -359,7 +366,7 @@ func (svc Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec) } team.Name = spec.Name - team.AgentOptions = spec.AgentOptions + team.Config.AgentOptions = spec.AgentOptions team.Secrets = secrets _, err = svc.ds.SaveTeam(ctx, team) diff --git a/server/config/config_test.go b/server/config/config_test.go index 7fa98ed060..4136bf9026 100644 --- a/server/config/config_test.go +++ b/server/config/config_test.go @@ -21,6 +21,10 @@ func TestConfigRoundtrip(t *testing.T) { // Newly added config values will automatically be tested in this // function because of the reflection on the config struct. + // viper tries to load config from the environment too, clear it in case + // any config values are set in the environment. + os.Clearenv() + cmd := &cobra.Command{} // Leaving this flag unset means that no attempt will be made to load // the config file @@ -60,7 +64,7 @@ func TestConfigRoundtrip(t *testing.T) { // Marshal the generated config buf, err := yaml.Marshal(original) - require.Nil(t, err) + require.NoError(t, err) t.Log(string(buf)) // Manually load the serialized config diff --git a/server/datastore/cached_mysql/cached_mysql.go b/server/datastore/cached_mysql/cached_mysql.go index a8b583bef0..f354ff7e71 100644 --- a/server/datastore/cached_mysql/cached_mysql.go +++ b/server/datastore/cached_mysql/cached_mysql.go @@ -236,7 +236,7 @@ func (ds *cachedMysql) SaveTeam(ctx context.Context, team *fleet.Team) (*fleet.T key := fmt.Sprintf(teamAgentOptionsKey, team.ID) - ds.c.Set(key, team.AgentOptions, ds.teamAgentOptionsExp) + ds.c.Set(key, team.Config.AgentOptions, ds.teamAgentOptionsExp) return team, nil } diff --git a/server/datastore/cached_mysql/cached_mysql_test.go b/server/datastore/cached_mysql/cached_mysql_test.go index 8b232697ff..5425d783a0 100644 --- a/server/datastore/cached_mysql/cached_mysql_test.go +++ b/server/datastore/cached_mysql/cached_mysql_test.go @@ -276,10 +276,12 @@ func TestCachedTeamAgentOptions(t *testing.T) { `) testTeam := &fleet.Team{ - ID: 1, - CreatedAt: time.Now(), - Name: "test", - AgentOptions: &testOptions, + ID: 1, + CreatedAt: time.Now(), + Name: "test", + Config: fleet.TeamConfig{ + AgentOptions: &testOptions, + }, } deleted := false @@ -306,10 +308,12 @@ func TestCachedTeamAgentOptions(t *testing.T) { {} `) updateTeam := &fleet.Team{ - ID: testTeam.ID, - CreatedAt: testTeam.CreatedAt, - Name: testTeam.Name, - AgentOptions: &updateOptions, + ID: testTeam.ID, + CreatedAt: testTeam.CreatedAt, + Name: testTeam.Name, + Config: fleet.TeamConfig{ + AgentOptions: &updateOptions, + }, } _, err = ds.SaveTeam(context.Background(), updateTeam) diff --git a/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig.go b/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig.go new file mode 100644 index 0000000000..9577ae956a --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig.go @@ -0,0 +1,28 @@ +package tables + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20220309133956, Down_20220309133956) +} + +func Up_20220309133956(tx *sql.Tx) error { + if _, err := tx.Exec(`ALTER TABLE teams ADD COLUMN config JSON`); err != nil { + return errors.Wrap(err, "add config column to teams table") + } + if _, err := tx.Exec(`UPDATE teams SET config = JSON_SET('{}', '$.agent_options', agent_options)`); err != nil { + return errors.Wrap(err, "migrate agent_options") + } + if _, err := tx.Exec(`ALTER TABLE teams DROP COLUMN agent_options`); err != nil { + return errors.Wrap(err, "drop agent_options column in teams table") + } + return nil +} + +func Down_20220309133956(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig_test.go b/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig_test.go new file mode 100644 index 0000000000..88040b584a --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20220309133956_AddTeamConfig_test.go @@ -0,0 +1,70 @@ +package tables + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "testing" + + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +type Team20220309133956 struct { + Name string `db:"name"` + Config TeamConfig20220309133956 `db:"config"` +} + +type TeamConfig20220309133956 struct { + AgentOptions *json.RawMessage `json:"agent_options" db:"agent_options"` +} + +// Scan implements the sql.Scanner interface +func (t *TeamConfig20220309133956) Scan(val interface{}) error { + switch v := val.(type) { + case []byte: + return json.Unmarshal(v, t) + case string: + return json.Unmarshal([]byte(v), t) + case nil: // sql NULL + return nil + default: + return fmt.Errorf("unsupported type: %T", v) + } +} + +// Value implements the sql.Valuer interface +func (t TeamConfig20220309133956) Value() (driver.Value, error) { + return json.Marshal(t) +} + +func TestUp_20220309133956(t *testing.T) { + db := applyUpToPrev(t) + + teams := []Team20220309133956{ + { + Name: "test1", + }, + { + Name: "test2", + Config: TeamConfig20220309133956{ + AgentOptions: ptr.RawMessage(json.RawMessage(`{"config": {"options": {"logger_plugin": "tls", "pack_delimiter": "/", "logger_tls_period": 10, "distributed_plugin": "tls", "disable_distributed": false, "logger_tls_endpoint": "/api/v1/osquery/log", "distributed_interval": 10, "distributed_tls_max_attempts": 3}, "decorators": {"load": ["SELECT uuid AS host_uuid FROM system_info;", "SELECT hostname AS hostname FROM system_info;"]}}, "overrides": {}}`)), + }, + }, + } + + _, err := db.Exec(` +INSERT INTO teams (name, agent_options) +VALUES (?, ?), (?, ?) +`, teams[0].Name, teams[0].Config.AgentOptions, teams[1].Name, teams[1].Config.AgentOptions) + require.NoError(t, err) + + applyNext(t, db) + + var actual []Team20220309133956 + err = db.Select(&actual, `SELECT name, config from teams`) + require.NoError(t, err) + + require.JSONEq(t, string(*teams[1].Config.AgentOptions), string(*actual[1].Config.AgentOptions)) + require.Equal(t, teams, actual) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index a884e3fad0..56a7dd9c6e 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -328,9 +328,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=127 DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB AUTO_INCREMENT=128 DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220316155700,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `network_interfaces` ( @@ -596,7 +596,7 @@ CREATE TABLE `teams` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `name` varchar(255) NOT NULL, `description` varchar(1023) NOT NULL DEFAULT '', - `agent_options` json DEFAULT NULL, + `config` json DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index 45a2938c9f..d0b3f924be 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -20,16 +20,16 @@ func (ds *Datastore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team query := ` INSERT INTO teams ( name, - agent_options, - description - ) VALUES ( ?, ?, ? ) + description, + config + ) VALUES (?, ?, ?) ` result, err := tx.ExecContext( ctx, query, team.Name, - team.AgentOptions, team.Description, + team.Config, ) if err != nil { return ctxerr.Wrap(ctx, err, "insert team") @@ -173,13 +173,15 @@ func saveUsersForTeamDB(ctx context.Context, exec sqlx.ExecerContext, team *flee func (ds *Datastore) SaveTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { query := ` - UPDATE teams SET - name = ?, - agent_options = ?, - description = ? - WHERE id = ? - ` - _, err := tx.ExecContext(ctx, query, team.Name, team.AgentOptions, team.Description, team.ID) +UPDATE teams +SET + name = ?, + description = ?, + config = ? +WHERE + id = ? +` + _, err := tx.ExecContext(ctx, query, team.Name, team.Description, team.Config, team.ID) if err != nil { return ctxerr.Wrap(ctx, err, "saving team") } @@ -291,7 +293,7 @@ func amountTeamsDB(ctx context.Context, db sqlx.QueryerContext) (int, error) { // TeamAgentOptions loads the agents options of a team. func (ds *Datastore) TeamAgentOptions(ctx context.Context, tid uint) (*json.RawMessage, error) { - sql := `SELECT agent_options FROM teams WHERE id = ?` + sql := `SELECT config->"$.agent_options" FROM teams WHERE id = ?` var agentOptions *json.RawMessage if err := sqlx.GetContext(ctx, ds.reader, &agentOptions, sql, tid); err != nil { return nil, ctxerr.Wrap(ctx, err, "select team") diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 1e7c354b6c..f99eb6e755 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -307,8 +307,10 @@ func testTeamsAgentOptions(t *testing.T, ds *Datastore) { agentOptions := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`) team2, err := ds.NewTeam(context.Background(), &fleet.Team{ - Name: "team2", - AgentOptions: &agentOptions, + Name: "team2", + Config: fleet.TeamConfig{ + AgentOptions: &agentOptions, + }, }) require.NoError(t, err) diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d73530d7ff..68469d39fd 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -1,7 +1,9 @@ package fleet import ( + "database/sql/driver" "encoding/json" + "fmt" "time" ) @@ -12,9 +14,10 @@ const ( ) type TeamPayload struct { - Name *string `json:"name"` - Description *string `json:"description"` - Secrets []*EnrollSecret `json:"secrets"` + Name *string `json:"name"` + Description *string `json:"description"` + Secrets []*EnrollSecret `json:"secrets"` + WebhookSettings *TeamWebhookSettings `json:"webhook_settings"` // Note AgentOptions must be set by a separate endpoint. } @@ -30,9 +33,8 @@ type Team struct { // Name is the human friendly name of the team. Name string `json:"name" db:"name"` // Description is an optional description for the team. - Description string `json:"description" db:"description"` - // AgentOptions is the options for osquery and Orbit. - AgentOptions *json.RawMessage `json:"agent_options" db:"agent_options"` + Description string `json:"description" db:"description"` + Config TeamConfig `json:"-" db:"config"` // see json.MarshalJSON/UnmarshalJSON implementations // Derived from JOINs @@ -48,6 +50,101 @@ type Team struct { Secrets []*EnrollSecret `json:"secrets,omitempty"` } +func (t Team) MarshalJSON() ([]byte, error) { + // The reason for not embedding TeamConfig above, is that it also implements sql.Scanner/Valuer. + // We do not want it be promoted to the parent struct, because it causes issues when using sqlx for scanning. + // Also need to implement json.Marshaler/Unmarshaler on each type that embeds Team so because it will be promoted + // to the parent struct. + x := struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + TeamConfig // inline this using struct embedding + UserCount int `json:"user_count"` + Users []TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []Host `json:"hosts,omitempty"` + Secrets []*EnrollSecret `json:"secrets,omitempty"` + }{ + ID: t.ID, + CreatedAt: t.CreatedAt, + Name: t.Name, + Description: t.Description, + TeamConfig: t.Config, + UserCount: t.UserCount, + Users: t.Users, + HostCount: t.HostCount, + Hosts: t.Hosts, + Secrets: t.Secrets, + } + + return json.Marshal(x) +} + +func (t *Team) UnmarshalJSON(b []byte) error { + var x struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + TeamConfig // inline this using struct embedding + UserCount int `json:"user_count"` + Users []TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []Host `json:"hosts,omitempty"` + Secrets []*EnrollSecret `json:"secrets,omitempty"` + } + + if err := json.Unmarshal(b, &x); err != nil { + return err + } + + *t = Team{ + ID: x.ID, + CreatedAt: x.CreatedAt, + Name: x.Name, + Description: x.Description, + Config: x.TeamConfig, + UserCount: x.UserCount, + Users: x.Users, + HostCount: x.HostCount, + Hosts: x.Hosts, + Secrets: x.Secrets, + } + + return nil +} + +type TeamConfig struct { + // AgentOptions is the options for osquery and Orbit. + AgentOptions *json.RawMessage `json:"agent_options,omitempty"` + WebhookSettings TeamWebhookSettings `json:"webhook_settings"` +} + +type TeamWebhookSettings struct { + FailingPoliciesWebhook FailingPoliciesWebhookSettings `json:"failing_policies_webhook"` +} + +// Scan implements the sql.Scanner interface +func (t *TeamConfig) Scan(val interface{}) error { + switch v := val.(type) { + case []byte: + return json.Unmarshal(v, t) + case string: + return json.Unmarshal([]byte(v), t) + case nil: // sql NULL + return nil + default: + return fmt.Errorf("unsupported type: %T", v) + } +} + +// Value implements the sql.Valuer interface +func (t TeamConfig) Value() (driver.Value, error) { + return json.Marshal(t) +} + type TeamSummary struct { ID uint `json:"id"` Name string `json:"name"` diff --git a/server/fleet/users.go b/server/fleet/users.go index 67fcb6d296..8ce9106b8d 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -1,8 +1,10 @@ package fleet import ( + "encoding/json" "errors" "fmt" + "time" "unicode" "github.com/fleetdm/fleet/v4/server" @@ -47,6 +49,74 @@ type UserTeam struct { Role string `json:"role" db:"role"` } +func (u UserTeam) MarshalJSON() ([]byte, error) { + x := struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + TeamConfig + UserCount int `json:"user_count"` + Users []TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []Host `json:"hosts,omitempty"` + Secrets []*EnrollSecret `json:"secrets,omitempty"` + Role string `json:"role"` + }{ + ID: u.ID, + CreatedAt: u.CreatedAt, + Name: u.Name, + Description: u.Description, + TeamConfig: u.Config, + UserCount: u.UserCount, + Users: u.Users, + HostCount: u.HostCount, + Hosts: u.Hosts, + Secrets: u.Secrets, + Role: u.Role, + } + + return json.Marshal(x) +} + +func (u *UserTeam) UnmarshalJSON(b []byte) error { + var x struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + TeamConfig + UserCount int `json:"user_count"` + Users []TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []Host `json:"hosts,omitempty"` + Secrets []*EnrollSecret `json:"secrets,omitempty"` + Role string `json:"role"` + } + + if err := json.Unmarshal(b, &x); err != nil { + return err + } + + *u = UserTeam{ + Team: Team{ + ID: x.ID, + CreatedAt: x.CreatedAt, + Name: x.Name, + Description: x.Description, + Config: x.TeamConfig, + UserCount: x.UserCount, + Users: x.Users, + HostCount: x.HostCount, + Hosts: x.Hosts, + Secrets: x.Secrets, + }, + Role: x.Role, + } + + return nil +} + // UserListOptions is additional options that can be set for listing users. type UserListOptions struct { ListOptions diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1cde853c5c..e1923110e7 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -59,7 +59,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { require.NoError(t, err) assert.Len(t, team.Secrets, 0) - require.JSONEq(t, string(agentOpts), string(*team.AgentOptions)) + require.JSONEq(t, string(agentOpts), string(*team.Config.AgentOptions)) // creates a team with default agent options user, err := s.ds.UserByEmail(context.Background(), "admin1@example.com") @@ -81,8 +81,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { defaultOpts := `{"config": {"options": {"logger_plugin": "tls", "pack_delimiter": "/", "logger_tls_period": 10, "distributed_plugin": "tls", "disable_distributed": false, "logger_tls_endpoint": "/api/v1/osquery/log", "distributed_interval": 10, "distributed_tls_max_attempts": 3}, "decorators": {"load": ["SELECT uuid AS host_uuid FROM system_info;", "SELECT hostname AS hostname FROM system_info;"]}}, "overrides": {}}` assert.Len(t, team.Secrets, 0) - require.NotNil(t, team.AgentOptions) - require.JSONEq(t, defaultOpts, string(*team.AgentOptions)) + require.NotNil(t, team.Config.AgentOptions) + require.JSONEq(t, defaultOpts, string(*team.Config.AgentOptions)) // updates secrets teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{Name: "team2", Secrets: []fleet.EnrollSecret{{Secret: "ABC"}}}}} @@ -436,7 +436,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { opts := map[string]string{"x": "y"} s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/teams/%d/agent_options", tm1ID), opts, http.StatusOK, &tmResp) var m map[string]string - require.NoError(t, json.Unmarshal(*tmResp.Team.AgentOptions, &m)) + require.NoError(t, json.Unmarshal(*tmResp.Team.Config.AgentOptions, &m)) assert.Equal(t, opts, m) // modify team agent options - unknown team diff --git a/server/service/osquery.go b/server/service/osquery.go index 39e459fb04..6cadfe9407 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -837,9 +837,27 @@ func (svc *Service) SubmitDistributedQueryResults( } if len(policyResults) > 0 { + + // filter policy results for webhooks + var policyIDs []uint if ac.WebhookSettings.FailingPoliciesWebhook.Enable { - incomingResults := filterPolicyResults(policyResults, ac.WebhookSettings.FailingPoliciesWebhook.PolicyIDs) - if failingPolicies, passingPolicies, err := svc.ds.FlippingPoliciesForHost(ctx, host.ID, incomingResults); err != nil { + policyIDs = append(policyIDs, ac.WebhookSettings.FailingPoliciesWebhook.PolicyIDs...) + } + + if host.TeamID != nil { + team, err := svc.ds.Team(ctx, *host.TeamID) + if err != nil { + logging.WithErr(ctx, err) + } else { + if team.Config.WebhookSettings.FailingPoliciesWebhook.Enable { + policyIDs = append(policyIDs, team.Config.WebhookSettings.FailingPoliciesWebhook.PolicyIDs...) + } + } + } + + filteredResults := filterPolicyResults(policyResults, policyIDs) + if len(filteredResults) > 0 { + if failingPolicies, passingPolicies, err := svc.ds.FlippingPoliciesForHost(ctx, host.ID, filteredResults); err != nil { logging.WithErr(ctx, err) } else { // Register the flipped policies on a goroutine to not block the hosts on redis requests. diff --git a/server/service/targets.go b/server/service/targets.go index f7a0cba1a5..96176fc057 100644 --- a/server/service/targets.go +++ b/server/service/targets.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "time" "github.com/fleetdm/fleet/v4/server/contexts/viewer" @@ -41,6 +42,78 @@ type teamSearchResult struct { Count int `json:"count"` } +func (t teamSearchResult) MarshalJSON() ([]byte, error) { + x := struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + fleet.TeamConfig + UserCount int `json:"user_count"` + Users []fleet.TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []fleet.Host `json:"hosts,omitempty"` + Secrets []*fleet.EnrollSecret `json:"secrets,omitempty"` + DisplayText string `json:"display_text"` + Count int `json:"count"` + }{ + ID: t.ID, + CreatedAt: t.CreatedAt, + Name: t.Name, + Description: t.Description, + TeamConfig: t.Config, + UserCount: t.UserCount, + Users: t.Users, + HostCount: t.HostCount, + Hosts: t.Hosts, + Secrets: t.Secrets, + DisplayText: t.DisplayText, + Count: t.Count, + } + + return json.Marshal(x) +} + +func (t *teamSearchResult) UnmarshalJSON(b []byte) error { + var x struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Description string `json:"description"` + fleet.TeamConfig + UserCount int `json:"user_count"` + Users []fleet.TeamUser `json:"users,omitempty"` + HostCount int `json:"host_count"` + Hosts []fleet.Host `json:"hosts,omitempty"` + Secrets []*fleet.EnrollSecret `json:"secrets,omitempty"` + DisplayText string `json:"display_text"` + Count int `json:"count"` + } + + if err := json.Unmarshal(b, &x); err != nil { + return err + } + + *t = teamSearchResult{ + Team: &fleet.Team{ + ID: x.ID, + CreatedAt: x.CreatedAt, + Name: x.Name, + Description: x.Description, + Config: x.TeamConfig, + UserCount: x.UserCount, + Users: x.Users, + HostCount: x.HostCount, + Hosts: x.Hosts, + Secrets: x.Secrets, + }, + DisplayText: x.DisplayText, + Count: x.Count, + } + + return nil +} + type targetsData struct { Hosts []hostSearchResult `json:"hosts"` Labels []labelSearchResult `json:"labels"` diff --git a/server/service/users_test.go b/server/service/users_test.go index a83474df62..fcaae96d9d 100644 --- a/server/service/users_test.go +++ b/server/service/users_test.go @@ -667,12 +667,12 @@ func TestAuthenticatedUser(t *testing.T) { createTestUsers(t, ds) svc := newTestService(t, ds, nil, nil) admin1, err := ds.UserByEmail(context.Background(), "admin1@example.com") - assert.Nil(t, err) + require.NoError(t, err) admin1Session, err := ds.NewSession(context.Background(), &fleet.Session{ UserID: admin1.ID, Key: "admin1", }) - assert.Nil(t, err) + require.NoError(t, err) ctx := context.Background() ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin1, Session: admin1Session}) diff --git a/server/webhooks/failing_policies.go b/server/webhooks/failing_policies.go index 839dd9d137..f445395512 100644 --- a/server/webhooks/failing_policies.go +++ b/server/webhooks/failing_policies.go @@ -17,8 +17,8 @@ import ( "github.com/go-kit/kit/log/level" ) -// TriggerGlobalFailingPoliciesWebhook performs the webhook requests for failing policies. -func TriggerGlobalFailingPoliciesWebhook( +// TriggerFailingPoliciesWebhook performs the webhook requests for failing policies. +func TriggerFailingPoliciesWebhook( ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, @@ -26,92 +26,203 @@ func TriggerGlobalFailingPoliciesWebhook( failingPoliciesSet fleet.FailingPolicySet, now time.Time, ) error { - if !appConfig.WebhookSettings.FailingPoliciesWebhook.Enable { - return nil - } - - level.Debug(logger).Log("enabled", "true") - serverURL, err := url.Parse(appConfig.ServerSettings.ServerURL) if err != nil { return ctxerr.Wrap(ctx, err, "invalid server url") } - globalPoliciesURL := appConfig.WebhookSettings.FailingPoliciesWebhook.DestinationURL - if globalPoliciesURL == "" { - level.Info(logger).Log("msg", "empty global destination_url") - return nil - } - policies, err := filterPolicies(ctx, ds, - appConfig.WebhookSettings.FailingPoliciesWebhook.PolicyIDs, - failingPoliciesSet, - logger, - ) - if err != nil { - return ctxerr.Wrap(ctx, err, "filtering policies") - } - for _, policy := range policies { - if err := sendFailingPoliciesBatchedPOSTs(ctx, policy, failingPoliciesSet, postData{ - serverURL: serverURL, - now: now, - webhookURL: globalPoliciesURL, - }, appConfig.WebhookSettings.FailingPoliciesWebhook.HostBatchSize, logger); err != nil { - return ctxerr.Wrapf(ctx, err, "sending POSTs for policy set %d", policy.ID) + + globalSettings := appConfig.WebhookSettings.FailingPoliciesWebhook + var globalPolicyIDs map[uint]struct{} + var globalWebhookURL *url.URL + if globalSettings.Enable { + globalPolicyIDs = make(map[uint]struct{}, len(globalSettings.PolicyIDs)) + for _, policyID := range globalSettings.PolicyIDs { + globalPolicyIDs[policyID] = struct{}{} + } + globalWebhookURL, err = url.Parse(globalSettings.DestinationURL) + if err != nil { + return ctxerr.Wrapf(ctx, err, "parse global webhook url: %s", globalSettings.DestinationURL) } } - return nil -} -type postData struct { - serverURL *url.URL - now time.Time - webhookURL string + // team caches + teamSettings := make(map[uint]fleet.FailingPoliciesWebhookSettings) + teamPolicyIDs := make(map[uint]map[uint]struct{}) + teamWebhookURLs := make(map[uint]*url.URL) + getTeam := func(teamID uint) error { + settings, ok := teamSettings[teamID] + if ok { + return nil + } + + team, err := ds.Team(ctx, teamID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "get team: %d", teamID) + } + + settings = team.Config.WebhookSettings.FailingPoliciesWebhook + teamSettings[teamID] = settings + + if settings.Enable { + policyIDs := make(map[uint]struct{}, len(settings.PolicyIDs)) + for _, policyID := range settings.PolicyIDs { + policyIDs[policyID] = struct{}{} + } + teamPolicyIDs[teamID] = policyIDs + + webhookURL, err := url.Parse(settings.DestinationURL) + if err != nil { + return ctxerr.Wrapf(ctx, err, "parse webhook url: %s", settings.DestinationURL) + } + teamWebhookURLs[teamID] = webhookURL + } + + return nil + } + + policySets, err := failingPoliciesSet.ListSets() + if err != nil { + return ctxerr.Wrap(ctx, err, "list policies set") + } + + for _, policyID := range policySets { + policy, err := ds.Policy(ctx, policyID) + switch { + case errors.Is(err, sql.ErrNoRows): + level.Debug(logger).Log("msg", "skipping failing policy, deleted", "policyID", policyID) + if err := failingPoliciesSet.RemoveSet(policy.ID); err != nil { + level.Error(logger).Log("msg", "failed to remove policy from set", "policyID", policyID, "err", err) + } + continue + case err != nil: + return ctxerr.Wrapf(ctx, err, "get policy: %d", policyID) + default: + // Ok + } + + if policy.TeamID != nil { + // team policy + err := getTeam(*policy.TeamID) + switch { + case errors.Is(err, sql.ErrNoRows): + // shouldn't happen, unless the team was deleted after the policy was retrieved above + level.Debug(logger).Log("msg", "team does not exist", "teamID", *policy.TeamID) + continue + case err != nil: + level.Error(logger).Log("msg", "failed to get team", "teamID", *policy.TeamID, "err", err) + continue + } + + settings := teamSettings[*policy.TeamID] + if !settings.Enable { + continue + } + + _, ok := teamPolicyIDs[*policy.TeamID][policy.ID] + if !ok { + level.Debug(logger).Log("msg", "skipping failing policy, not found in team policy IDs", "policyID", policyID) + if err := failingPoliciesSet.RemoveSet(policy.ID); err != nil { + level.Error(logger).Log("msg", "failed to remove policy from set", "policyID", policyID, "err", err) + } + continue + } + + webhookURL := teamWebhookURLs[*policy.TeamID] + + err = sendFailingPoliciesBatchedPOSTs( + ctx, + policy, + failingPoliciesSet, + settings.HostBatchSize, + serverURL, + webhookURL, + now, + logger, + ) + if err != nil { + level.Error(logger).Log("msg", "failed to send failing policies webhook requests", "policyID", policy.ID, "err", err) + } + + continue + } + + // global policy + _, ok := globalPolicyIDs[policy.ID] + if !ok { + level.Debug(logger).Log("msg", "skipping failing policy, not found in global policy IDs", "policyID", policyID) + if err := failingPoliciesSet.RemoveSet(policy.ID); err != nil { + level.Error(logger).Log("msg", "failed to remove policy from set", "policyID", policyID, "err", err) + } + continue + } + + err = sendFailingPoliciesBatchedPOSTs( + ctx, + policy, + failingPoliciesSet, + globalSettings.HostBatchSize, + serverURL, + globalWebhookURL, + now, + logger, + ) + if err != nil { + level.Error(logger).Log("msg", "failed to send failing policies webhook requests", "policyID", policy.ID, "err", err) + } + } + + return nil } func sendFailingPoliciesBatchedPOSTs( ctx context.Context, policy *fleet.Policy, failingPoliciesSet fleet.FailingPolicySet, - postData postData, hostBatchSize int, + serverURL *url.URL, + webhookURL *url.URL, + now time.Time, logger kitlog.Logger, ) error { hosts, err := failingPoliciesSet.ListHosts(policy.ID) if err != nil { - return ctxerr.Wrapf(ctx, err, "listing hosts for global failing policies set %d", policy.ID) + return ctxerr.Wrapf(ctx, err, "listing hosts for failing policies set %d", policy.ID) } if len(hosts) == 0 { - level.Debug(logger).Log("id", policy.ID, "msg", "no hosts") + level.Debug(logger).Log("msg", "no hosts", "policyID", policy.ID) return nil } - if hostBatchSize == 0 { - hostBatchSize = len(hosts) - } sort.Slice(hosts, func(i, j int) bool { return hosts[i].ID < hosts[j].ID }) - for len(hosts) > 0 { - j := hostBatchSize - if l := len(hosts); j > l { - j = l + + if hostBatchSize == 0 { + hostBatchSize = len(hosts) + } + for i := 0; i < len(hosts); i += hostBatchSize { + end := i + hostBatchSize + if end > len(hosts) { + end = len(hosts) } - batch := hosts[:j] + batch := hosts[i:end] + failingHosts := make([]FailingHost, len(batch)) - for i := range batch { - failingHosts[i] = makeFailingHost(batch[i], *postData.serverURL) + for i, host := range batch { + failingHosts[i] = makeFailingHost(host, serverURL) } + payload := FailingPoliciesPayload{ - Timestamp: postData.now, + Timestamp: now, Policy: policy, - FailingHosts: failingHosts[:j], + FailingHosts: failingHosts, } - level.Debug(logger).Log("payload", payload, "url", postData.webhookURL, "batch", len(batch)) - if err := server.PostJSONWithTimeout(ctx, postData.webhookURL, &payload); err != nil { - return ctxerr.Wrapf(ctx, err, "posting to '%s'", postData.webhookURL) + level.Debug(logger).Log("payload", payload, "url", webhookURL.String(), "batch", len(batch)) + if err := server.PostJSONWithTimeout(ctx, webhookURL.String(), &payload); err != nil { + return ctxerr.Wrapf(ctx, err, "posting to %q", webhookURL) } if err := failingPoliciesSet.RemoveHosts(policy.ID, batch); err != nil { return ctxerr.Wrapf(ctx, err, "removing hosts %+v from failing policies set %d", batch, policy.ID) } - hosts = hosts[j:] } return nil } @@ -128,59 +239,12 @@ type FailingHost struct { URL string `json:"url"` } -func makeFailingHost(host fleet.PolicySetHost, serverURL url.URL) FailingHost { - serverURL.Path = path.Join(serverURL.Path, "hosts", strconv.Itoa(int(host.ID))) +func makeFailingHost(host fleet.PolicySetHost, serverURL *url.URL) FailingHost { + u := *serverURL + u.Path = path.Join(serverURL.Path, "hosts", strconv.FormatUint(uint64(host.ID), 10)) return FailingHost{ ID: host.ID, Hostname: host.Hostname, - URL: serverURL.String(), + URL: u.String(), } } - -// filterPolicies fetches the policies from the policy set and filters out those -// that are not configured for webhook anymore or are deleted. -// -// The filtered out policies are removed from the set. -func filterPolicies( - ctx context.Context, - ds fleet.Datastore, - configuredPolicyIDs []uint, - failingPoliciesSet fleet.FailingPolicySet, - logger kitlog.Logger, -) ([]*fleet.Policy, error) { - configuredPolicyIDsSet := make(map[uint]struct{}) - for _, policyID := range configuredPolicyIDs { - configuredPolicyIDsSet[policyID] = struct{}{} - } - policySets, err := failingPoliciesSet.ListSets() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "listing global policies set") - } - var policies []*fleet.Policy - var gcSet []uint - for _, policyID := range policySets { - if _, ok := configuredPolicyIDsSet[policyID]; !ok { - level.Debug(logger).Log("msg", "skipping policy from set, not in config", "id", policyID) - gcSet = append(gcSet, policyID) - continue - } - switch policy, err := ds.Policy(ctx, policyID); { - case err == nil: - policies = append(policies, policy) - case errors.Is(err, sql.ErrNoRows): - level.Debug(logger).Log("msg", "skipping policy from set, deleted", "id", policyID) - gcSet = append(gcSet, policyID) - default: - return nil, ctxerr.Wrapf(ctx, err, "failing to load global failing policies set %d", policyID) - } - } - // Remove the policies that are present in the set but: - // - are not present in the config (user disabled automation for them), or, - // - do not exist anymore (user deleted the policy). - for _, policyID := range gcSet { - if err := failingPoliciesSet.RemoveSet(policyID); err != nil { - return nil, ctxerr.Wrapf(ctx, err, "removing global policy %d from policy set", policyID) - } - } - return policies, nil -} diff --git a/server/webhooks/failing_policies_test.go b/server/webhooks/failing_policies_test.go index 622cb0bdd4..fdda454106 100644 --- a/server/webhooks/failing_policies_test.go +++ b/server/webhooks/failing_policies_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "sort" "testing" "time" @@ -86,7 +85,7 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) { require.NoError(t, err) mockClock := time.Now() - err = TriggerGlobalFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, mockClock) + err = TriggerFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, mockClock) require.NoError(t, err) timestamp, err := mockClock.MarshalJSON() require.NoError(t, err) @@ -130,11 +129,167 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) { requestBody = "" - err = TriggerGlobalFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, mockClock) + err = TriggerFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, mockClock) require.NoError(t, err) assert.Empty(t, requestBody) } +func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) { + // webhook server + webhookBody := "" + webhookCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + requestBodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + webhookBody = string(requestBodyBytes) + })) + t.Cleanup(func() { + ts.Close() + }) + + ds := new(mock.Store) + + teamID := uint(1) + + policiesByID := map[uint]*fleet.Policy{ + 1: { + PolicyData: fleet.PolicyData{ + ID: 1, + Name: "policy1", + Query: "select 1", + Description: "policy1 description", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + TeamID: &teamID, + Resolution: ptr.String("policy1 resolution"), + Platform: "darwin", + }, + }, + 2: { + PolicyData: fleet.PolicyData{ + ID: 2, + Name: "policy2", + Query: "select 2", + Description: "policy2 description", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + TeamID: &teamID, + Resolution: ptr.String("policy2 resolution"), + Platform: "darwin", + }, + }, + 3: { + PolicyData: fleet.PolicyData{ + ID: 2, + Name: "policy3", + Query: "select 3", + Description: "policy3 description", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + TeamID: nil, // global policy + Resolution: ptr.String("policy3 resolution"), + Platform: "darwin", + }, + }, + } + + ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) { + policy, ok := policiesByID[id] + if !ok { + return nil, ctxerr.Wrap(ctx, sql.ErrNoRows) + } + return policy, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == teamID { + return &fleet.Team{ + ID: teamID, + Config: fleet.TeamConfig{ + WebhookSettings: fleet.TeamWebhookSettings{ + FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{ + Enable: true, + DestinationURL: ts.URL, + PolicyIDs: []uint{1}, + }, + }, + }, + }, nil + } + return nil, ctxerr.Wrap(ctx, sql.ErrNoRows) + } + + ac := &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + ServerURL: "https://fleet.example.com", + }, + } + + failingPolicySet := service.NewMemFailingPolicySet() + err := failingPolicySet.AddHost(1, fleet.PolicySetHost{ + ID: 1, + Hostname: "host1", + }) + require.NoError(t, err) + err = failingPolicySet.AddHost(2, fleet.PolicySetHost{ + ID: 2, + Hostname: "host2", + }) + require.NoError(t, err) + + now := time.Now() + err = TriggerFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, now) + require.NoError(t, err) + + timestamp, err := now.MarshalJSON() + require.NoError(t, err) + + // Request body as defined in #2756. + require.True(t, webhookCalled, "webhook was not called") + require.JSONEq( + t, fmt.Sprintf(`{ + "timestamp": %s, + "policy": { + "id": 1, + "name": "policy1", + "query": "select 1", + "description": "policy1 description", + "author_id": 1, + "author_name": "Alice", + "author_email": "alice@example.com", + "team_id": 1, + "resolution": "policy1 resolution", + "platform": "darwin", + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "passing_host_count": 0, + "failing_host_count": 0 + }, + "hosts": [ + { + "id": 1, + "hostname": "host1", + "url": "https://fleet.example.com/hosts/1" + } + ] +}`, timestamp), webhookBody) + + hosts, err := failingPolicySet.ListHosts(1) + require.NoError(t, err) + assert.Empty(t, hosts) + + webhookBody = "" + + err = TriggerFailingPoliciesWebhook(context.Background(), ds, kitlog.NewNopLogger(), ac, failingPolicySet, now) + require.NoError(t, err) + assert.Empty(t, webhookBody) +} + func TestSendBatchedPOSTs(t *testing.T) { allHosts := []uint{} requestCount := 0 @@ -244,13 +399,18 @@ func TestSendBatchedPOSTs(t *testing.T) { err := failingPolicySet.AddHost(p.ID, host) require.NoError(t, err) } - err := sendFailingPoliciesBatchedPOSTs(context.Background(), - p, failingPolicySet, postData{ - serverURL: serverURL, - now: now, - webhookURL: ts.URL, - }, + + webhookURL, err := url.Parse(ts.URL) + require.NoError(t, err) + + err = sendFailingPoliciesBatchedPOSTs( + context.Background(), + p, + failingPolicySet, tc.batchSize, + serverURL, + webhookURL, + now, kitlog.NewNopLogger(), ) require.NoError(t, err) @@ -265,84 +425,3 @@ func TestSendBatchedPOSTs(t *testing.T) { }) } } - -func TestFilterPolicies(t *testing.T) { - ds := new(mock.Store) - for _, tc := range []struct { - name string - set []uint - cfg []uint - nonExisting []uint - expIDs []uint - }{ - { - name: "one-non-configured", - set: []uint{1, 2, 6}, - cfg: []uint{1, 2, 3}, - nonExisting: []uint{}, - expIDs: []uint{1, 2}, - }, - { - name: "none-configured", - set: []uint{1, 2, 6}, - cfg: []uint{}, - nonExisting: []uint{}, - expIDs: []uint{}, - }, - { - name: "one-non-existing-and-one-non-configured", - set: []uint{1, 2, 6}, - cfg: []uint{1, 2}, - nonExisting: []uint{1}, - expIDs: []uint{2}, - }, - { - name: "empty-set", - set: []uint{}, - cfg: []uint{1, 2}, - nonExisting: []uint{1}, - expIDs: []uint{}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - failingPoliciesSet := service.NewMemFailingPolicySet() - for _, policyID := range tc.set { - err := failingPoliciesSet.AddHost(policyID, fleet.PolicySetHost{ID: 1}) - require.NoError(t, err) - } - ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) { - for _, nonID := range tc.nonExisting { - if nonID == id { - return nil, sql.ErrNoRows - } - } - return &fleet.Policy{ - PolicyData: fleet.PolicyData{ - ID: id, - }, - }, nil - } - policies, err := filterPolicies( - context.Background(), - ds, - tc.cfg, - failingPoliciesSet, - kitlog.NewNopLogger(), - ) - require.NoError(t, err) - require.Len(t, policies, len(tc.expIDs)) - sets, err := failingPoliciesSet.ListSets() - sort.Slice(sets, func(i, j int) bool { - return sets[i] < sets[j] - }) - sort.Slice(policies, func(i, j int) bool { - return policies[i].ID < policies[j].ID - }) - require.NoError(t, err) - for i := range policies { - require.Equal(t, tc.expIDs[i], policies[i].ID) - require.Equal(t, sets[i], policies[i].ID) - } - }) - } -}