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)).~
This commit is contained in:
Lucas Manuel Rodriguez 2024-04-16 06:37:58 -03:00 committed by GitHub
parent b45079e261
commit e7f61305a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 711 additions and 1 deletions

View file

@ -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`.

View file

@ -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 | ✅ | ✅ | ✅ | ✅ | |

View file

@ -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"

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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{})

View file

@ -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
}

View file

@ -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)
}

View file

@ -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{})

View file

@ -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",