fleet/server/service/labels_test.go
Scott Gress 7eebc22693
Add author ID to labels (#27055)
For #27035 

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [ ] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality

## Details

This PR adds an `author_id` column to the `labels` table, and adds the
associated properties to the `Label` and `LabelSpec` types. When a new
label is created via the UI or API, an author ID is set on the label if
one can be inferred from the context. Otherwise, the author ID is set to
`null`.

## Authz and Automated testing

Additional backend authorization logic is introduced in a follow-on PR,
https://github.com/fleetdm/fleet/pull/27089, because rconciling all of
the test updates between this PR and
https://github.com/fleetdm/fleet/pull/27038 was getting complicated.

## Manual Testing

* Tested in the UI by creating a new label on the Hosts page
* Tested via Gitops by merging this branch with
https://github.com/fleetdm/fleet/pull/27038 and doing `fleetctl gitops`
with a global config with `labels:` in it.
2025-03-20 16:05:16 -05:00

433 lines
12 KiB
Go

package service
import (
"context"
"testing"
"time"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLabelsAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
ds.NewLabelFunc = func(ctx context.Context, lbl *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) {
return lbl, nil
}
ds.SaveLabelFunc = func(ctx context.Context, lbl *fleet.Label, filter fleet.TeamFilter) (*fleet.Label, []uint, error) {
return lbl, nil, nil
}
ds.DeleteLabelFunc = func(ctx context.Context, nm string) error {
return nil
}
ds.ApplyLabelSpecsFunc = func(ctx context.Context, specs []*fleet.LabelSpec) error {
return nil
}
ds.LabelFunc = func(ctx context.Context, id uint, filter fleet.TeamFilter) (*fleet.Label, []uint, error) {
return &fleet.Label{}, nil, nil
}
ds.ListLabelsFunc = func(ctx context.Context, filter fleet.TeamFilter, opts fleet.ListOptions) ([]*fleet.Label, error) {
return nil, nil
}
ds.LabelsSummaryFunc = func(ctx context.Context) ([]*fleet.LabelSummary, error) {
return nil, nil
}
ds.ListHostsInLabelFunc = func(ctx context.Context, filter fleet.TeamFilter, lid uint, opts fleet.HostListOptions) ([]*fleet.Host, error) {
return nil, nil
}
ds.GetLabelSpecsFunc = func(ctx context.Context) ([]*fleet.LabelSpec, error) {
return nil, nil
}
ds.GetLabelSpecFunc = func(ctx context.Context, name string) (*fleet.LabelSpec, error) {
return &fleet.LabelSpec{}, nil
}
testCases := []struct {
name string
user *fleet.User
shouldFailWrite bool
shouldFailRead bool
}{
{
"global admin",
&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
false,
},
{
"global maintainer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
false,
false,
},
{
"global observer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
true,
false,
},
{
"team maintainer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
false,
false,
},
{
"team observer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
true,
false,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, _, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: t.Name(), Query: `SELECT 1`})
checkAuthErr(t, tt.shouldFailWrite, err)
_, _, err = svc.ModifyLabel(ctx, 1, fleet.ModifyLabelPayload{})
checkAuthErr(t, tt.shouldFailWrite, err)
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{})
checkAuthErr(t, tt.shouldFailWrite, err)
_, _, err = svc.GetLabel(ctx, 1)
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.GetLabelSpecs(ctx)
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.GetLabelSpec(ctx, "abc")
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.ListLabels(ctx, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.LabelsSummary((ctx))
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.ListHostsInLabel(ctx, 1, fleet.HostListOptions{})
checkAuthErr(t, tt.shouldFailRead, err)
err = svc.DeleteLabel(ctx, "abc")
checkAuthErr(t, tt.shouldFailWrite, err)
err = svc.DeleteLabelByID(ctx, 1)
checkAuthErr(t, tt.shouldFailWrite, err)
})
}
}
func TestLabelsWithDS(t *testing.T) {
ds := mysql.CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *mysql.Datastore)
}{
{"GetLabel", testLabelsGetLabel},
{"ListLabels", testLabelsListLabels},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testLabelsGetLabel(t *testing.T, ds *mysql.Datastore) {
svc, ctx := newTestService(t, ds, nil, nil)
label := &fleet.Label{
Name: "foo",
Query: "select * from foo;",
}
label, err := ds.NewLabel(ctx, label)
assert.Nil(t, err)
assert.NotZero(t, label.ID)
labelVerify, _, err := svc.GetLabel(test.UserContext(ctx, test.UserAdmin), label.ID)
assert.Nil(t, err)
assert.Equal(t, label.ID, labelVerify.ID)
assert.Nil(t, label.AuthorID)
}
func testLabelsListLabels(t *testing.T, ds *mysql.Datastore) {
svc, ctx := newTestService(t, ds, nil, nil)
require.NoError(t, ds.MigrateData(context.Background()))
labels, err := svc.ListLabels(test.UserContext(ctx, test.UserAdmin), fleet.ListOptions{Page: 0, PerPage: 1000})
require.NoError(t, err)
require.Len(t, labels, 8)
labelsSummary, err := svc.LabelsSummary(test.UserContext(ctx, test.UserAdmin))
require.NoError(t, err)
require.Len(t, labelsSummary, 8)
}
func TestApplyLabelSpecsWithBuiltInLabels(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
user := &fleet.User{
ID: 3,
Email: "foo@bar.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
}
svc, ctx := newTestService(t, ds, nil, nil)
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
name := "foo"
description := "bar"
query := "select * from foo;"
platform := ""
labelType := fleet.LabelTypeBuiltIn
labelMembershipType := fleet.LabelMembershipTypeDynamic
spec := &fleet.LabelSpec{
Name: name,
Description: description,
Query: query,
LabelType: labelType,
LabelMembershipType: labelMembershipType,
}
ds.LabelsByNameFunc = func(ctx context.Context, names []string) (map[string]*fleet.Label, error) {
return map[string]*fleet.Label{
name: {
Name: name,
Description: description,
Query: query,
Platform: platform,
LabelType: labelType,
LabelMembershipType: labelMembershipType,
},
}, nil
}
// all good
err := svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
require.NoError(t, err)
const errorMessage = "cannot modify or add built-in label"
// not ok -- built-in label name doesn't exist
name = "not-foo"
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorContains(t, err, errorMessage)
name = "foo"
// not ok -- description does not match
description = "not-bar"
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorContains(t, err, errorMessage)
description = "bar"
// not ok -- query does not match
query = "select * from not-foo;"
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorContains(t, err, errorMessage)
query = "select * from foo;"
// not ok -- label type does not match
labelType = fleet.LabelTypeRegular
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorContains(t, err, errorMessage)
labelType = fleet.LabelTypeBuiltIn
// not ok -- label membership type does not match
labelMembershipType = fleet.LabelMembershipTypeManual
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorContains(t, err, errorMessage)
labelMembershipType = fleet.LabelMembershipTypeDynamic
// not ok -- DB error
ds.LabelsByNameFunc = func(ctx context.Context, names []string) (map[string]*fleet.Label, error) {
return nil, assert.AnError
}
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})
assert.ErrorIs(t, err, assert.AnError)
}
func TestLabelsWithReplica(t *testing.T) {
opts := &testing_utils.DatastoreTestOptions{DummyReplica: true}
ds := mysql.CreateMySQLDSWithOptions(t, opts)
defer ds.Close()
svc, ctx := newTestService(t, ds, nil, nil)
user, err := ds.NewUser(ctx, &fleet.User{
Name: "Adminboi",
Password: []byte("p4ssw0rd.123"),
Email: "admin@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
})
require.NoError(t, err)
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
// create a couple hosts
h1, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "host1",
HardwareSerial: uuid.NewString(),
UUID: uuid.NewString(),
Platform: "darwin",
LastEnrolledAt: time.Now(),
DetailUpdatedAt: time.Now(),
})
require.NoError(t, err)
h2, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "host2",
HardwareSerial: uuid.NewString(),
UUID: uuid.NewString(),
Platform: "darwin",
LastEnrolledAt: time.Now(),
DetailUpdatedAt: time.Now(),
})
require.NoError(t, err)
// make the newly-created hosts available to the reader
opts.RunReplication()
lbl, hostIDs, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: "label1", Hosts: []string{"host1", "host2"}})
require.NoError(t, err)
require.ElementsMatch(t, []uint{h1.ID, h2.ID}, hostIDs)
require.Equal(t, 2, lbl.HostCount)
require.Equal(t, user.ID, *lbl.AuthorID)
// make the newly-created label available to the reader
opts.RunReplication("labels", "label_membership")
lbl, hostIDs, err = svc.ModifyLabel(ctx, lbl.ID, fleet.ModifyLabelPayload{Hosts: []string{"host1"}})
require.NoError(t, err)
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
require.Equal(t, 1, lbl.HostCount)
require.Equal(t, user.ID, *lbl.AuthorID)
// reading this label without replication returns the old data as it only uses the reader
lbl, hostIDs, err = svc.GetLabel(ctx, lbl.ID)
require.NoError(t, err)
require.ElementsMatch(t, []uint{h1.ID, h2.ID}, hostIDs)
require.Equal(t, 2, lbl.HostCount)
require.Equal(t, user.ID, *lbl.AuthorID)
// running the replication makes the updated data available
opts.RunReplication("labels", "label_membership")
lbl, hostIDs, err = svc.GetLabel(ctx, lbl.ID)
require.NoError(t, err)
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
require.Equal(t, 1, lbl.HostCount)
require.Equal(t, user.ID, *lbl.AuthorID)
}
func TestBatchValidateLabels(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
t.Run("no auth context", func(t *testing.T) {
_, err := svc.BatchValidateLabels(context.Background(), nil)
require.ErrorContains(t, err, "Authentication required")
})
authCtx := authz_ctx.AuthorizationContext{}
ctx = authz_ctx.NewContext(ctx, &authCtx)
t.Run("no auth checked", func(t *testing.T) {
_, err := svc.BatchValidateLabels(ctx, nil)
require.ErrorContains(t, err, "Authentication required")
})
// validator requires that an authz check has been performed upstream so we'll set it now for
// the rest of the tests
authCtx.SetChecked()
mockLabels := map[string]uint{
"foo": 1,
"bar": 2,
"baz": 3,
}
mockLabelIdent := func(name string, id uint) fleet.LabelIdent {
return fleet.LabelIdent{LabelID: id, LabelName: name}
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
res := make(map[string]uint)
if names == nil {
return res, nil
}
for _, name := range names {
if id, ok := mockLabels[name]; ok {
res[name] = id
}
}
return res, nil
}
testCases := []struct {
name string
labelNames []string
expectLabels map[string]fleet.LabelIdent
expectError string
}{
{
"no labels",
nil,
nil,
"",
},
{
"include labels",
[]string{"foo", "bar"},
map[string]fleet.LabelIdent{
"foo": mockLabelIdent("foo", 1),
"bar": mockLabelIdent("bar", 2),
},
"",
},
{
"non-existent label",
[]string{"foo", "qux"},
nil,
"some or all the labels provided don't exist",
},
{
"duplicate label",
[]string{"foo", "foo"},
map[string]fleet.LabelIdent{
"foo": mockLabelIdent("foo", 1),
},
"",
},
{
"empty slice",
[]string{},
nil,
"",
},
{
"empty string",
[]string{""},
nil,
"some or all the labels provided don't exist",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got, err := svc.BatchValidateLabels(ctx, tt.labelNames)
if tt.expectError != "" {
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectLabels, got)
}
})
}
}