From e7f61305a9a1122af1960965cf9a01921c864f3e Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Tue, 16 Apr 2024 06:37:58 -0300 Subject: [PATCH] New APIs to add/remove manual labels to/from a host (#18283) #16767 To create a manual label: ```sh cat labels.yml --- apiVersion: v1 kind: label spec: name: Manually Managed Example label_membership_type: manual hosts: - lucass-macbook-pro.local ``` To add/delete a manual label to/from a host: ``` curl -k -v -X POST -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/hosts/1/labels -d '{"labels": ["Manually Managed Example"]}' curl -k -v -X DELETE -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/hosts/1/labels -d '{"labels": ["Manually Managed Example"]}' ``` API draft changes: https://github.com/fleetdm/fleet/pull/16979/files Figma with error strings: https://www.figma.com/file/JiWoAiuHlkt76s3o3Uyz6h/%2316767-API-endpoint-for-updating-a-host's-manual-labels?type=design&node-id=2-130&mode=design&t=pxRPhrn6E1bOCrEd-0 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ~- [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.~ - [X] Added/updated tests - ~[ ] If database migrations are included, checked table schema to confirm autoupdate~ - ~For database migrations:~ - ~[ ] Checked schema for all modified table for columns that will auto-update timestamps during migration.~ - ~[ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.~ - ~[ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).~ - [x] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ --- changes/16767-updating-host-labels | 1 + docs/Using Fleet/manage-access.md | 2 + server/authz/policy.rego | 15 + server/datastore/mysql/labels.go | 33 ++ server/datastore/mysql/labels_test.go | 92 ++++++ server/fleet/authz.go | 2 + server/fleet/datastore.go | 7 + server/fleet/service.go | 15 + server/mock/datastore_mock.go | 24 ++ server/service/handler.go | 2 + server/service/hosts.go | 172 ++++++++++ server/service/integration_core_test.go | 300 ++++++++++++++++++ server/service/integration_enterprise_test.go | 45 +++ server/test/new_objects.go | 2 +- 14 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 changes/16767-updating-host-labels diff --git a/changes/16767-updating-host-labels b/changes/16767-updating-host-labels new file mode 100644 index 0000000000..32c1e635cc --- /dev/null +++ b/changes/16767-updating-host-labels @@ -0,0 +1 @@ +* Added endpoints to add/remove manual labels to/from a host. `POST /api/v1/fleet/hosts/:id/labels` and `DELETE /api/v1/fleet/hosts/:id/labels`. diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 802c7c36f5..fe6af40380 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -40,6 +40,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View a host by identifier | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | ✅ | | | Target hosts using labels | ✅ | ✅ | ✅ | ✅ | | +| Add/remove manual labels to/from hosts | | | ✅ | ✅ | ✅ | | Add and delete hosts | | | ✅ | ✅ | | | Transfer hosts between teams\* | | | ✅ | ✅ | ✅ | | Create, edit, and delete labels | | | ✅ | ✅ | ✅ | @@ -124,6 +125,7 @@ Users with access to multiple teams can be assigned different roles for each tea | View a host by identifier | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | ✅ | | | Target hosts using labels | ✅ | ✅ | ✅ | ✅ | | +| Add/remove manual labels to/from hosts | | | ✅ | ✅ | ✅ | | Add and delete hosts | | | ✅ | ✅ | | | Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | ✅ | | | Filter hosts by software | ✅ | ✅ | ✅ | ✅ | | diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 04860deb5c..c5d742fba5 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -13,6 +13,7 @@ import input.subject read := "read" list := "list" write := "write" +write_host_label := "write_host_label" # User specific actions write_role := "write_role" @@ -272,6 +273,13 @@ allow { action == write } +# Global admin, mantainers and gitops can write labels to hosts. +allow { + object.type == "host" + subject.global_role == [admin, maintainer, gitops][_] + action == write_host_label +} + # Allow read for global observer and observer_plus, selective_read for gitops. allow { object.type == "host" @@ -295,6 +303,13 @@ allow { action == write } +# Team admins, maintainers and gitops can write labels to hosts of their own team. +allow { + object.type == "host" + team_role(subject, object.team_id) == [admin, maintainer, gitops][_] + action == write_host_label +} + # Allow read for host health for global admin/maintainer, team admins, observer. allow { object.type == "host_health" diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 95cea4bc7d..d07290e23c 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -1015,3 +1015,36 @@ func (ds *Datastore) HostMemberOfAllLabels(ctx context.Context, hostID uint, lab return ok, nil } + +func (ds *Datastore) AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error { + if len(labelIDs) == 0 { + return nil + } + sql := `INSERT INTO label_membership (host_id, label_id) VALUES ` + sql += strings.Repeat(`(?, ?),`, len(labelIDs)) + sql = strings.TrimSuffix(sql, ",") + sql += ` ON DUPLICATE KEY UPDATE updated_at = NOW()` + args := make([]interface{}, 0, len(labelIDs)*2) + for _, labelID := range labelIDs { + args = append(args, hostID, labelID) + } + if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert into label_membership") + } + return nil +} + +func (ds *Datastore) RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error { + if len(labelIDs) == 0 { + return nil + } + sql := `DELETE FROM label_membership WHERE host_id = ? AND label_id IN (?)` + sql, args, err := sqlx.In(sql, hostID, labelIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build label_membership IN query") + } + if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete from label_membership") + } + return nil +} diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index ea9d455c9a..f5cb921593 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -68,6 +68,7 @@ func TestLabels(t *testing.T) { {"ListHostsInLabelDiskEncryptionStatus", testListHostsInLabelDiskEncryptionStatus}, {"HostMemberOfAllLabels", testHostMemberOfAllLabels}, {"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings}, + {"AddDeleteLabelsToFromHost", testAddDeleteLabelsToFromHost}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1429,3 +1430,94 @@ func testLabelsListHostsInLabelOSSettings(t *testing.T, db *Datastore) { checkHosts(t, hosts, []uint{h2.ID}) }) } + +func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) { + ctx := context.Background() + host1, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + Platform: "darwin", + }) + require.NoError(t, err) + host2, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("2"), + NodeKey: ptr.String("2"), + UUID: "2", + Hostname: "bar.local", + Platform: "windows", + }) + require.NoError(t, err) + + err = ds.AddLabelsToHost(ctx, host1.ID, nil) + require.NoError(t, err) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, nil) + require.NoError(t, err) + + label1, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label1", + Query: "SELECT 1;", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + label2, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label2", + Query: "SELECT 2;", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + + // Removing a label and multiple labels that the host is not a member of. + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + + // Adding and removing labels. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + getLabelUpdatedAt := func(updatedAt *time.Time) func(q sqlx.ExtContext) error { + return func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, updatedAt, `SELECT updated_at FROM label_membership WHERE host_id = ? AND label_id = ?`, host1.ID, label1.ID) + } + } + var labelUpdatedAt1 time.Time + ExecAdhocSQL(t, ds, getLabelUpdatedAt(&labelUpdatedAt1)) + time.Sleep(1 * time.Second) + // Add a label that the host is already member of. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + var labelUpdatedAt2 time.Time + ExecAdhocSQL(t, ds, getLabelUpdatedAt(&labelUpdatedAt2)) + require.True(t, labelUpdatedAt2.After(labelUpdatedAt1)) + labels, err := ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Len(t, labels, 1) + require.Equal(t, "label1", labels[0].Name) + labels2, err := ds.ListLabelsForHost(ctx, host2.ID) + require.NoError(t, err) + require.Empty(t, labels2) + + // Removing a label that the host is a member of + // and one that the host is not a member of. + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Empty(t, labels) + + // Add and remove multiple labels. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Len(t, labels, 2) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Empty(t, labels) +} diff --git a/server/fleet/authz.go b/server/fleet/authz.go index e94f902202..811aad2366 100644 --- a/server/fleet/authz.go +++ b/server/fleet/authz.go @@ -7,6 +7,8 @@ const ( ActionList = "list" // ActionWrite refers to writing (CRUD operations) an entity. ActionWrite = "write" + // ActionWriteHostLabel refers to writing labels on hosts. + ActionWriteHostLabel = "write_host_label" // // User specific actions diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0b6d83f228..61a41a4d63 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -176,6 +176,13 @@ type Datastore interface { // GetLabelSpec returns the spec for the named label. GetLabelSpec(ctx context.Context, name string) (*LabelSpec, error) + // AddLabelsToHost adds the given label IDs membership to the host. + // If a host is already a member of the label then this will update the row's updated_at. + AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error + // RemoveLabelsFromHost removes the given label IDs membership from the host. + // If a host is already not a member of a label then such label will be ignored. + RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error + NewLabel(ctx context.Context, Label *Label, opts ...OptionalArg) (*Label, error) SaveLabel(ctx context.Context, label *Label) (*Label, error) DeleteLabel(ctx context.Context, name string) error diff --git a/server/fleet/service.go b/server/fleet/service.go index e05fa0b376..ad87a4f786 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -388,6 +388,21 @@ type Service interface { HostEncryptionKey(ctx context.Context, id uint) (*HostDiskEncryptionKey, error) + // AddLabelsToHost adds the given label names to the host's label membership. + // + // If a host is already a member of one of the labels then this operation will only + // update the membership row update time. + // + // Returns an error if any of the labels does not exist or if any of the labels + // are not manual. + AddLabelsToHost(ctx context.Context, id uint, labels []string) error + // RemoveLabelsFromHost removes the given label names from the host's label membership. + // Labels that the host are already not a member of are ignored. + // + // Returns an error if any of the labels does not exist or if any of the labels + // are not manual. + RemoveLabelsFromHost(ctx context.Context, id uint, labels []string) error + // OSVersions returns a list of operating systems and associated host counts, which may be // filtered using the following optional criteria: team id, platform, or name and version. // Name cannot be used without version, and conversely, version cannot be used without name. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 2fb532210e..9b5bc54a79 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -129,6 +129,10 @@ type GetLabelSpecsFunc func(ctx context.Context) ([]*fleet.LabelSpec, error) type GetLabelSpecFunc func(ctx context.Context, name string) (*fleet.LabelSpec, error) +type AddLabelsToHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) error + +type RemoveLabelsFromHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) error + type NewLabelFunc func(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, error) @@ -1071,6 +1075,12 @@ type DataStore struct { GetLabelSpecFunc GetLabelSpecFunc GetLabelSpecFuncInvoked bool + AddLabelsToHostFunc AddLabelsToHostFunc + AddLabelsToHostFuncInvoked bool + + RemoveLabelsFromHostFunc RemoveLabelsFromHostFunc + RemoveLabelsFromHostFuncInvoked bool + NewLabelFunc NewLabelFunc NewLabelFuncInvoked bool @@ -2623,6 +2633,20 @@ func (s *DataStore) GetLabelSpec(ctx context.Context, name string) (*fleet.Label return s.GetLabelSpecFunc(ctx, name) } +func (s *DataStore) AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error { + s.mu.Lock() + s.AddLabelsToHostFuncInvoked = true + s.mu.Unlock() + return s.AddLabelsToHostFunc(ctx, hostID, labelIDs) +} + +func (s *DataStore) RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error { + s.mu.Lock() + s.RemoveLabelsFromHostFuncInvoked = true + s.mu.Unlock() + return s.RemoveLabelsFromHostFunc(ctx, hostID, labelIDs) +} + func (s *DataStore) NewLabel(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { s.mu.Lock() s.NewLabelFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index ecd586e472..ddcb585839 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -396,6 +396,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/os_versions/{id:[0-9]+}", getOSVersionEndpoint, getOSVersionRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{query_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{}) + ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 14b7bb7119..90480ce322 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -2261,3 +2262,174 @@ func hostListOptionsFromFilters(filter *map[string]interface{}) (*fleet.HostList return &opt, labelID, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Host Labels +//////////////////////////////////////////////////////////////////////////////// + +type addLabelsToHostRequest struct { + ID uint `url:"id"` + Labels []string `json:"labels"` +} + +type addLabelsToHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r addLabelsToHostResponse) error() error { return r.Err } + +func addLabelsToHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*addLabelsToHostRequest) + if err := svc.AddLabelsToHost(ctx, req.ID, req.Labels); err != nil { + return addLabelsToHostResponse{Err: err}, nil + } + return addLabelsToHostResponse{}, nil +} + +func (svc *Service) AddLabelsToHost(ctx context.Context, id uint, labelNames []string) error { + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return ctxerr.Wrap(ctx, err, "load host") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil { + return ctxerr.Wrap(ctx, err) + } + + labelIDs, err := svc.validateLabelNames(ctx, "add", labelNames) + if err != nil { + return err + } + if len(labelIDs) == 0 { + return nil + } + + if err := svc.ds.AddLabelsToHost(ctx, host.ID, labelIDs); err != nil { + return ctxerr.Wrap(ctx, err, "add labels to host") + } + + return nil +} + +type removeLabelsFromHostRequest struct { + ID uint `url:"id"` + Labels []string `json:"labels"` +} + +type removeLabelsFromHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r removeLabelsFromHostResponse) error() error { return r.Err } + +func removeLabelsFromHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*removeLabelsFromHostRequest) + if err := svc.RemoveLabelsFromHost(ctx, req.ID, req.Labels); err != nil { + return removeLabelsFromHostResponse{Err: err}, nil + } + return removeLabelsFromHostResponse{}, nil +} + +func (svc *Service) RemoveLabelsFromHost(ctx context.Context, id uint, labelNames []string) error { + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return ctxerr.Wrap(ctx, err, "load host") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil { + return ctxerr.Wrap(ctx, err) + } + + labelIDs, err := svc.validateLabelNames(ctx, "remove", labelNames) + if err != nil { + return err + } + if len(labelIDs) == 0 { + return nil + } + + if err := svc.ds.RemoveLabelsFromHost(ctx, host.ID, labelIDs); err != nil { + return ctxerr.Wrap(ctx, err, "remove labels from host") + } + + return nil +} + +func (svc *Service) validateLabelNames(ctx context.Context, action string, labelNames []string) ([]uint, error) { + if len(labelNames) == 0 { + return nil, nil + } + + labelNames = server.RemoveDuplicatesFromSlice(labelNames) + + // Filter out empty label string. + for i, labelName := range labelNames { + if labelName == "" { + labelNames = append(labelNames[:i], labelNames[i+1:]...) + break + } + } + if len(labelNames) == 0 { + return nil, nil + } + + labels, err := svc.ds.LabelIDsByName(ctx, labelNames) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name") + } + + var labelsNotFound []string + for _, labelName := range labelNames { + if _, ok := labels[labelName]; !ok { + labelsNotFound = append(labelsNotFound, "\""+labelName+"\"") + } + } + + if len(labelsNotFound) > 0 { + sort.Slice(labelsNotFound, func(i, j int) bool { + // Ignore quotes to sort. + return labelsNotFound[i][1:len(labelsNotFound[i])-1] < labelsNotFound[j][1:len(labelsNotFound[j])-1] + }) + return nil, &fleet.BadRequestError{ + Message: fmt.Sprintf( + "Couldn't %s labels. Labels not found: %s. All labels must exist.", + action, + strings.Join(labelsNotFound, ", "), + ), + } + } + + var dynamicLabels []string + for labelName, labelID := range labels { + label, err := svc.ds.Label(ctx, labelID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "load label from id") + } + if label.LabelMembershipType != fleet.LabelMembershipTypeManual { + dynamicLabels = append(dynamicLabels, "\""+labelName+"\"") + } + } + + if len(dynamicLabels) > 0 { + sort.Slice(dynamicLabels, func(i, j int) bool { + // Ignore quotes to sort. + return dynamicLabels[i][1:len(dynamicLabels[i])-1] < dynamicLabels[j][1:len(dynamicLabels[j])-1] + }) + return nil, &fleet.BadRequestError{ + Message: fmt.Sprintf( + "Couldn't %s labels. Labels are dynamic: %s. Dynamic labels can not be assigned to hosts manually.", + action, + strings.Join(dynamicLabels, ", "), + ), + } + } + + labelIDs := make([]uint, 0, len(labels)) + for _, labelID := range labels { + labelIDs = append(labelIDs, labelID) + } + + return labelIDs, nil +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index d3d1e99db8..a5d7a6af93 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11010,3 +11010,303 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { require.Empty(t, listResp.Activities) require.Equal(t, &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, listResp.Meta) } + +func (s *integrationTestSuite) TestAddingRemovingManualLabels() { + t := s.T() + ctx := context.Background() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + newGlobalUserFunc := func(email string, globalRole string) *fleet.User { + user := &fleet.User{ + Name: email, + Email: email, + GlobalRole: &globalRole, + } + err = user.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + user, err = s.ds.NewUser(context.Background(), user) + require.NoError(t, err) + return user + } + newTeamUserFunc := func(email string, team *fleet.Team, teamRole string) *fleet.User { + user := &fleet.User{ + Name: email, + Email: email, + Teams: []fleet.UserTeam{ + { + Team: *team, + Role: teamRole, + }, + }, + } + err = user.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + user, err = s.ds.NewUser(context.Background(), user) + require.NoError(t, err) + return user + } + globalObserver := newGlobalUserFunc("global.observer@example.com", fleet.RoleObserver) + teamAdmin := newTeamUserFunc("team.admin@example.com", team1, fleet.RoleAdmin) + teamObserver := newTeamUserFunc("team.observer@example.com", team1, fleet.RoleObserver) + + newHostFunc := func(name string, teamID *uint) *fleet.Host { + host, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(name), + UUID: name, + Hostname: "foo.local." + name, + TeamID: teamID, + }) + require.NoError(t, err) + require.NotNil(t, host) + return host + } + host1 := newHostFunc("host1", nil) + host2 := newHostFunc("host2", nil) + teamHost2 := newHostFunc("teamHost2", &team1.ID) + + ls, err := s.ds.LabelIDsByName(ctx, []string{"All Hosts"}) + require.NoError(t, err) + require.Len(t, ls, 1) + allHostsLabelID, ok := ls["All Hosts"] + require.True(t, ok) + require.NotZero(t, allHostsLabelID) + + dynamicLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "dynamicLabel1", + Query: "SELECT 1;", + LabelMembershipType: fleet.LabelMembershipTypeDynamic, + }) + require.NoError(t, err) + manualLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel1", + Query: "SELECT 2;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + manualLabel2, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel2", + Query: "SELECT 3;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + + err = s.ds.RecordLabelQueryExecutions(context.Background(), host1, map[uint]*bool{allHostsLabelID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + getHostLabels := func(host *fleet.Host) []string { + var hostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + var labels []string + for _, label := range hostResp.Host.Labels { + labels = append(labels, label.Name) + } + return labels + } + + hostLabels1 := getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // No labels or empty labels is a no-op. + var addLabelsToHostResp addLabelsToHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), + json.RawMessage(`{}`), http.StatusOK, &addLabelsToHostResp, + ) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{}, + }, http.StatusOK, &addLabelsToHostResp) + var removeLabelsFromHostResp removeLabelsFromHostResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{}, + }, http.StatusOK, &removeLabelsFromHostResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{""}, + }, http.StatusOK, &addLabelsToHostResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"", ""}, + }, http.StatusOK, &addLabelsToHostResp) + + // A dynamic buitin label should fail to be added. + res := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"All Hosts"}, + }, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"All Hosts\". Dynamic labels can not be assigned to hosts manually.") + // An inexistent label should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"manualLabel2", "does not exist"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels not found: \"does not exist\". All labels must exist.") + // Multiple inexistent labels should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"manualLabel2", "does not exist", "does not exist 2"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels not found: \"does not exist\", \"does not exist 2\". All labels must exist.") + // A dynamic non-builtin label should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{dynamicLabel1.Name}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + // Multiple dynamic labels should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"All Hosts", dynamicLabel1.Name}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"All Hosts\", \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + + // A dynamic builtin label should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{"All Hosts"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels are dynamic: \"All Hosts\". Dynamic labels can not be assigned to hosts manually.") + // An inexistent label should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, "does not exist"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels not found: \"does not exist\". All labels must exist.") + // Multiple inexistent labels should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, "does not exist", "does not exist 2"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels not found: \"does not exist\", \"does not exist 2\". All labels must exist.") + // Multiple dynamic labels should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, dynamicLabel1.Name, "All Hosts"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels are dynamic: \"All Hosts\", \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + + // Add two manual labels to a host. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + // Add the same manual labels to a host should succeed. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 3) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + require.Equal(t, manualLabel2.Name, hostLabels1[2]) + hostLabels2 := getHostLabels(host2) + require.Empty(t, hostLabels2) + + // Remove the two manual labels from the host. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + // Remove the same manual labels from the host again. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // Add same label, should deduplicate. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 2) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + + // Adding an already added label should work. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 3) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + require.Equal(t, manualLabel2.Name, hostLabels1[2]) + + // Delete same label, should deduplicate. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + // Deleting a non-member label (manualLabel1) should work. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // Add to non-existent host + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", 999), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusNotFound, &addLabelsToHostResp) + // Delete from non-existent host + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", 999), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusNotFound, &removeLabelsFromHostResp) + + // Add labels to team host. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + // A global observer should not be allowed to add/remove a label. + oldToken := s.token + s.token = s.getTestToken(globalObserver.Email, test.GoodPassword) + t.Cleanup(func() { + s.token = oldToken + }) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team observer should not be allowed to add/remove a label. + s.token = s.getTestToken(teamObserver.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team admin should not be allowed to add/remove a label for a global host. + s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team admin should be allowed to add/remove a label for a team host. + s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + teamHost2Labels := getHostLabels(teamHost2) + require.Len(t, teamHost2Labels, 1) + require.Equal(t, manualLabel1.Name, teamHost2Labels[0]) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + teamHost2Labels = getHostLabels(teamHost2) + require.Empty(t, teamHost2Labels) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 56272fa1b6..39d15e08cc 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4222,6 +4222,19 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { Name: "Zoo", }) require.NoError(t, err) + team1Host, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(t.Name() + "2"), + UUID: t.Name() + "2", + Hostname: strings.Replace(t.Name()+"zoo.local", "/", "_", -1), + TeamID: &t1.ID, + }) + require.NoError(t, err) + globalHost, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(t.Name() + "3"), + UUID: t.Name() + "3", + Hostname: strings.Replace(t.Name()+"global.local", "/", "_", -1), + }) + require.NoError(t, err) acr := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "webhook_settings": { @@ -4328,6 +4341,12 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { require.NoError(t, u3.SetPassword(test.GoodPassword, 10, 10)) _, err = s.ds.NewUser(context.Background(), u3) require.NoError(t, err) + manualLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel1", + Query: "SELECT 2;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) // // Start running permission tests with user gitops1. @@ -4407,6 +4426,16 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { require.True(t, acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.Enable) require.Equal(t, "https://foobar.example.com", acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.DestinationURL) + // Attempt to add/remove manual labels to/from a host. + var addLabelsToHostResp addLabelsToHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + var removeLabelsFromHostResp removeLabelsFromHostResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + // Attempt to run live queries synchronously, should fail. s.DoJSON("GET", "/api/latest/fleet/queries/run", runLiveQueryRequest{ HostIDs: []uint{h1.ID}, @@ -4779,6 +4808,22 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { // Attempt to remove a query from the team's schedule, should allow. s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", t1.ID, ttsqr.Scheduled.ID), deleteTeamScheduleRequest{}, http.StatusOK, &deleteTeamScheduleResponse{}) + // Attempt to add/remove a manual label from a team host, should allow. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + // Attempt to add/remove a manual label from a global host, should not allow. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + // Attempt to read the global schedule, should fail. s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusForbidden, &getGlobalScheduleResponse{}) diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 29963e0773..6bf4953320 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -112,7 +112,7 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) { Name: "All Hosts", Query: "select 1", LabelType: fleet.LabelTypeBuiltIn, - LabelMembershipType: fleet.LabelMembershipTypeManual, + LabelMembershipType: fleet.LabelMembershipTypeDynamic, }, { Name: "macOS",