Add additional test coverage for team labels (#37793)

There will be one more follow-up for set-aside behavior.
This commit is contained in:
Ian Littman 2026-01-05 09:01:12 -06:00 committed by GitHub
parent 6080ca6a1e
commit ec4c1088d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1159 additions and 78 deletions

View file

@ -362,7 +362,7 @@ allow {
# Team admins, maintainers, observer_plus, observers and gitops can read global labels.
allow {
object.type == "label"
is_null(object.team_id)
any([is_null(object.team_id), object.team_id == 0]) # allow specifying team ID 0 for listing exclusively global labels
# If role is admin, maintainer, observer_plus or observer on any team.
team_role(subject, subject.teams[_].id) == [admin, maintainer, observer_plus, observer, gitops][_]
action == read

View file

@ -346,8 +346,7 @@ func (ds *Datastore) UpdateLabelMembershipByHostIDs(ctx context.Context, label f
// Split hostIds into batches to avoid parameter limit in MySQL.
for _, hostIds := range batchHostIds(hostIds) {
if label.TeamID != nil { // team labels can only be applied to hosts on that team
hostTeamCheckSql := `SELECT COUNT(id) FROM hosts WHERE team_id != ? AND id IN (` +
strings.TrimRight(strings.Repeat("?,", len(hostIds)), ",") + ")"
hostTeamCheckSql := `SELECT COUNT(id) FROM hosts WHERE (team_id != ? OR team_id IS NULL) AND id IN (?)`
hostTeamCheckSql, args, err := sqlx.In(hostTeamCheckSql, label.TeamID, hostIds)
if err != nil {
return ctxerr.Wrap(ctx, err, "build host team membership check IN statement")
@ -502,7 +501,7 @@ func (ds *Datastore) GetLabelSpecs(ctx context.Context, filter fleet.TeamFilter)
func (ds *Datastore) GetLabelSpec(ctx context.Context, filter fleet.TeamFilter, name string) (*fleet.LabelSpec, error) {
var specs []*fleet.LabelSpec
query, params, err := applyLabelTeamFilter(`
SELECT l.id, l.name, l.description, l.query, l.platform, l.label_type, l.label_membership_type
SELECT l.id, l.name, l.description, l.query, l.platform, l.label_type, l.label_membership_type, l.team_id
FROM labels l
WHERE l.name = ?`, filter, name)
if err != nil {
@ -608,7 +607,7 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string, filter fleet.
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
var labelID uint
query, params, err := applyLabelTeamFilter(`select id FROM labels WHERE name = ?`, filter, name)
query, params, err := applyLabelTeamFilter(`select l.id FROM labels l WHERE l.name = ?`, filter, name)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting label id to delete")
}

View file

@ -233,8 +233,6 @@ func testLabelsAddAllHosts(deferred bool, t *testing.T, db *Datastore) {
}
func testLabelsSearch(t *testing.T, db *Datastore) {
// TODO test team filtering
specs := []*fleet.LabelSpec{
{ID: 1, Name: "foo"},
{ID: 2, Name: "bar"},
@ -256,8 +254,6 @@ func testLabelsSearch(t *testing.T, db *Datastore) {
err := db.ApplyLabelSpecs(context.Background(), specs)
require.Nil(t, err)
// TODO add team checking
user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
filter := fleet.TeamFilter{User: user}
@ -287,11 +283,81 @@ func testLabelsSearch(t *testing.T, db *Datastore) {
require.Nil(t, err)
assert.Len(t, labels, 1)
assert.Contains(t, labels, &all.Label)
// Test team filtering
team1, err := db.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := db.NewTeam(context.Background(), &fleet.Team{Name: "team2"})
require.NoError(t, err)
// Create team labels
team1Label, err := db.NewLabel(context.Background(), &fleet.Label{
Name: "team1-foo",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team2Label, err := db.NewLabel(context.Background(), &fleet.Label{
Name: "team2-foo",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
// Global admin should see all labels (global + team labels), including the All Hosts label
labels, err = db.SearchLabels(context.Background(), filter, "foo")
require.NoError(t, err)
assert.Len(t, labels, 5) // foo, foo-bar, All Hosts, team1-foo, team2-foo
// Filter to team1 only - should see global labels + team1 labels
team1Filter := fleet.TeamFilter{User: user, TeamID: &team1.ID}
labels, err = db.SearchLabels(context.Background(), team1Filter, "foo")
require.NoError(t, err)
assert.Len(t, labels, 4) // foo, foo-bar, All Hosts, team1-foo
foundTeam1Label := false
foundTeam2Label := false
for _, l := range labels {
if l.ID == team1Label.ID {
foundTeam1Label = true
}
if l.ID == team2Label.ID {
foundTeam2Label = true
}
}
assert.True(t, foundTeam1Label, "team1 label should be found")
assert.False(t, foundTeam2Label, "team2 label should not be found")
// Filter to global only (team_id = 0)
globalOnlyFilter := fleet.TeamFilter{User: user, TeamID: ptr.Uint(0)}
labels, err = db.SearchLabels(context.Background(), globalOnlyFilter, "foo")
require.NoError(t, err)
assert.Len(t, labels, 3) // foo, foo-bar, All Hosts
for _, l := range labels {
assert.Nil(t, l.TeamID, "should only have global labels")
}
// Team user can only see their team's labels + global labels
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleObserver}}}
team1UserFilter := fleet.TeamFilter{User: team1User}
labels, err = db.SearchLabels(context.Background(), team1UserFilter, "foo")
require.NoError(t, err)
assert.Len(t, labels, 4) // foo, foo-bar, All Hosts, team1-foo
for _, l := range labels {
if l.TeamID != nil {
assert.Equal(t, team1.ID, *l.TeamID, "team user should only see their team's labels")
}
}
// Team user trying to access another team's labels should fail
team1UserTeam2Filter := fleet.TeamFilter{User: team1User, TeamID: &team2.ID}
_, err = db.SearchLabels(context.Background(), team1UserTeam2Filter, "foo")
require.ErrorContains(t, err, errInaccessibleTeam.Error()) // not ErrorIs due to UserError wrapping
}
func testLabelsListHostsInLabel(t *testing.T, db *Datastore) {
// TODO test label filtering
h1, err := db.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
@ -385,6 +451,56 @@ func testLabelsListHostsInLabel(t *testing.T, db *Datastore) {
listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{MDMIDFilter: ptr.Uint(kandjiID)}, 1)
listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{MDMNameFilter: ptr.String(fleet.WellKnownMDMSimpleMDM)}, 2)
listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{MDMNameFilter: ptr.String(fleet.WellKnownMDMSimpleMDM), MDMEnrollmentStatusFilter: fleet.MDMEnrollStatusEnrolled}, 1)
// Test team label filtering
team1, err := db.NewTeam(context.Background(), &fleet.Team{Name: "team1_listhosts"})
require.NoError(t, err)
// Create a team label
teamLabel, err := db.NewLabel(context.Background(), &fleet.Label{
Name: "team1-label",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
// Create a host on team1
h4, err := db.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("4"),
NodeKey: ptr.String("4"),
UUID: "4",
Hostname: "team1host.local",
Platform: "darwin",
TeamID: &team1.ID,
})
require.NoError(t, err)
// Add host to team label
err = db.RecordLabelQueryExecutions(context.Background(), h4, map[uint]*bool{teamLabel.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// Global admin can list hosts in team label
listHostsInLabelCheckCount(t, db, filter, teamLabel.ID, fleet.HostListOptions{}, 1)
// Team user can list hosts in their team's label
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleObserver}}}
team1Filter := fleet.TeamFilter{User: team1User, IncludeObserver: true}
listHostsInLabelCheckCount(t, db, team1Filter, teamLabel.ID, fleet.HostListOptions{}, 1)
// Trying to list a team label that the user doesn't have access to returns empty
team2, err := db.NewTeam(context.Background(), &fleet.Team{Name: "team2_listhosts"})
require.NoError(t, err)
team2User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team2.ID}, Role: fleet.RoleObserver}}}
team2Filter := fleet.TeamFilter{User: team2User, IncludeObserver: true}
// Team2 user cannot see team1's label, so they get no results
team2Hosts, err := db.ListHostsInLabel(context.Background(), team2Filter, teamLabel.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Nil(t, team2Hosts) // Returns nil when label is not accessible
}
func listHostsInLabelCheckCount(
@ -705,38 +821,134 @@ func testLabelsGetSpec(t *testing.T, ds *Datastore) {
}
func testLabelsApplySpecsRoundtrip(t *testing.T, ds *Datastore) {
// TODO test team labels
expectedSpecs := setupLabelSpecsTest(t, ds)
globalSpecs := setupLabelSpecsTest(t, ds)
globalOnlyFilter := fleet.TeamFilter{}
specs, err := ds.GetLabelSpecs(context.Background(), globalOnlyFilter)
require.Nil(t, err)
test.ElementsMatchSkipTimestampsID(t, expectedSpecs, specs)
test.ElementsMatchSkipTimestampsID(t, globalSpecs, specs)
// Should be idempotent
err = ds.ApplyLabelSpecs(context.Background(), expectedSpecs)
err = ds.ApplyLabelSpecs(context.Background(), globalSpecs)
require.Nil(t, err)
specs, err = ds.GetLabelSpecs(context.Background(), globalOnlyFilter)
require.Nil(t, err)
test.ElementsMatchSkipTimestampsID(t, expectedSpecs, specs)
test.ElementsMatchSkipTimestampsID(t, globalSpecs, specs)
// Test team labels
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1_roundtrip"})
require.NoError(t, err)
team2, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team2_roundtrip"})
require.NoError(t, err)
// Create team label specs; these wouldn't normally coexist in the same call but that gets handled upstream;
// it doesn't hurt anything to set labels cross-team at the data store level (which assumes auth has already
// happened).
teamSpecs := []*fleet.LabelSpec{
{Name: "team1-label", Query: "SELECT 1", TeamID: &team1.ID},
{Name: "team2-label", Query: "SELECT 2", TeamID: &team2.ID},
}
err = ds.ApplyLabelSpecs(context.Background(), teamSpecs)
require.NoError(t, err)
// Global filter should still only return global labels
specs, err = ds.GetLabelSpecs(context.Background(), globalOnlyFilter)
require.NoError(t, err)
test.ElementsMatchSkipTimestampsID(t, globalSpecs, specs)
// Admin user filter should return all labels (global + team)
user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
adminFilter := fleet.TeamFilter{User: user}
specs, err = ds.GetLabelSpecs(context.Background(), adminFilter)
require.NoError(t, err)
require.Len(t, specs, len(globalSpecs)+2)
// Team1 filter should return only the team1 label
team1Filter := fleet.TeamFilter{User: user, TeamID: &team1.ID}
specs, err = ds.GetLabelSpecs(context.Background(), team1Filter)
require.NoError(t, err)
require.Len(t, specs, 1)
require.Equal(t, "team1-label", specs[0].Name)
require.Equal(t, team1.ID, *specs[0].TeamID)
// Team user can only see their team's labels + global labels with no filter applied
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}}
team1UserFilter := fleet.TeamFilter{User: team1User}
specs, err = ds.GetLabelSpecs(context.Background(), team1UserFilter)
require.NoError(t, err)
require.Len(t, specs, len(globalSpecs)+1)
foundTeam1Label := false
for _, s := range specs {
if s.Name == "team1-label" {
foundTeam1Label = true
require.Equal(t, team1.ID, *s.TeamID)
}
if s.Name == "team2-label" {
t.Fatal("team2 label should not be in team1 filter results")
}
}
require.True(t, foundTeam1Label, "team1 label should be found")
}
func testLabelsIDsByName(t *testing.T, ds *Datastore) {
setupLabelSpecsTest(t, ds)
// TODO test team labels
labels, err := ds.LabelIDsByName(context.Background(), []string{"foo", "bar", "bing"}, fleet.TeamFilter{})
require.Nil(t, err)
assert.Equal(t, map[string]uint{"foo": 1, "bar": 2, "bing": 3}, labels)
// Test team labels
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1_idsbyname"})
require.NoError(t, err)
team2, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team2_idsbyname"})
require.NoError(t, err)
// Create team labels
team1Label, err := ds.NewLabel(context.Background(), &fleet.Label{
Name: "team1-idsbyname",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team2Label, err := ds.NewLabel(context.Background(), &fleet.Label{
Name: "team2-idsbyname",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
// Global admin can see all labels
adminUser := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
adminFilter := fleet.TeamFilter{User: adminUser}
labels, err = ds.LabelIDsByName(context.Background(), []string{"foo", "team1-idsbyname", "team2-idsbyname"}, adminFilter)
require.NoError(t, err)
require.Len(t, labels, 3)
assert.Equal(t, team1Label.ID, labels["team1-idsbyname"])
assert.Equal(t, team2Label.ID, labels["team2-idsbyname"])
// Team1 filter should only return global + team1 labels
team1Filter := fleet.TeamFilter{User: adminUser, TeamID: &team1.ID}
labels, err = ds.LabelIDsByName(context.Background(), []string{"foo", "team1-idsbyname", "team2-idsbyname"}, team1Filter)
require.NoError(t, err)
require.Len(t, labels, 2) // foo and team1-idsbyname
assert.Equal(t, team1Label.ID, labels["team1-idsbyname"])
_, hasTeam2 := labels["team2-idsbyname"]
assert.False(t, hasTeam2, "team2 label should not be visible with team1 filter")
// Team user can only see their team's labels + global labels
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}}
team1UserFilter := fleet.TeamFilter{User: team1User}
labels, err = ds.LabelIDsByName(context.Background(), []string{"foo", "team1-idsbyname", "team2-idsbyname"}, team1UserFilter)
require.NoError(t, err)
require.Len(t, labels, 2) // foo and team1-idsbyname
assert.Equal(t, team1Label.ID, labels["team1-idsbyname"])
}
func testLabelsByName(t *testing.T, ds *Datastore) {
setupLabelSpecsTest(t, ds)
// TODO test team labels
names := []string{"foo", "bar", "bing"}
labels, err := ds.LabelsByName(context.Background(), names, fleet.TeamFilter{})
require.NoError(t, err)
@ -756,10 +968,146 @@ func testLabelsByName(t *testing.T, ds *Datastore) {
assert.Empty(t, labels[name].Description)
}
}
// Test team labels
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1_byname"})
require.NoError(t, err)
team2, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team2_byname"})
require.NoError(t, err)
// Create team labels
team1Label, err := ds.NewLabel(context.Background(), &fleet.Label{
Name: "team1-byname",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team2Label, err := ds.NewLabel(context.Background(), &fleet.Label{
Name: "team2-byname",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
// Global admin can see all labels
adminUser := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
adminFilter := fleet.TeamFilter{User: adminUser}
labelNames := []string{"foo", "team1-byname", "team2-byname"}
labels, err = ds.LabelsByName(context.Background(), labelNames, adminFilter)
require.NoError(t, err)
require.Len(t, labels, 3)
assert.Equal(t, team1Label.ID, labels["team1-byname"].ID)
assert.Equal(t, team2Label.ID, labels["team2-byname"].ID)
// Team1 filter should only return global + team1 labels
team1Filter := fleet.TeamFilter{User: adminUser, TeamID: &team1.ID}
labels, err = ds.LabelsByName(context.Background(), labelNames, team1Filter)
require.NoError(t, err)
require.Len(t, labels, 2) // foo and team1-byname
assert.Equal(t, team1Label.ID, labels["team1-byname"].ID)
_, hasTeam2 := labels["team2-byname"]
assert.False(t, hasTeam2, "team2 label should not be visible with team1 filter")
// Team user can only see their team's labels + global labels
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}}
team1UserFilter := fleet.TeamFilter{User: team1User}
labels, err = ds.LabelsByName(context.Background(), labelNames, team1UserFilter)
require.NoError(t, err)
require.Len(t, labels, 2) // foo and team1-byname
assert.Equal(t, team1Label.ID, labels["team1-byname"].ID)
_, hasTeam2 = labels["team2-byname"]
assert.False(t, hasTeam2, "team2 label should not be visible to team1 user")
}
func testLabelByName(t *testing.T, ds *Datastore) {
// TODO implement, including team filtering
ctx := context.Background()
// Setup: create global labels
globalLabel, err := ds.NewLabel(ctx, &fleet.Label{
Name: "global-label",
Query: "SELECT 1",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
// Create teams
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1_labelbyname"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2_labelbyname"})
require.NoError(t, err)
// Create team labels
team1Label, err := ds.NewLabel(ctx, &fleet.Label{
Name: "team1-labelbyname",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
_, err = ds.NewLabel(ctx, &fleet.Label{ // should never be retrieved
Name: "team2-labelbyname",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
adminUser := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}}
// Global admin can get global label
label, err := ds.LabelByName(ctx, "global-label", fleet.TeamFilter{User: adminUser})
require.NoError(t, err)
assert.Equal(t, globalLabel.ID, label.ID)
// Global admin can get team label
label, err = ds.LabelByName(ctx, "team1-labelbyname", fleet.TeamFilter{User: adminUser})
require.NoError(t, err)
assert.Equal(t, team1Label.ID, label.ID)
// Team1 user can get global label
label, err = ds.LabelByName(ctx, "global-label", fleet.TeamFilter{User: team1User})
require.NoError(t, err)
assert.Equal(t, globalLabel.ID, label.ID)
// Team1 user can get team1 label
label, err = ds.LabelByName(ctx, "team1-labelbyname", fleet.TeamFilter{User: team1User})
require.NoError(t, err)
assert.Equal(t, team1Label.ID, label.ID)
// Team1 user cannot get team2 label
_, err = ds.LabelByName(ctx, "team2-labelbyname", fleet.TeamFilter{User: team1User})
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err), "expected not found error for inaccessible team label")
// Filter to team - team1 filter can see team1 label
team1Filter := fleet.TeamFilter{User: adminUser, TeamID: &team1.ID}
label, err = ds.LabelByName(ctx, "team1-labelbyname", team1Filter)
require.NoError(t, err)
assert.Equal(t, team1Label.ID, label.ID)
// Filter to team - team1 filter cannot see team2 label
_, err = ds.LabelByName(ctx, "team2-labelbyname", team1Filter)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
// Global-only filter (team_id = 0) can see global label
globalOnlyFilter := fleet.TeamFilter{User: adminUser, TeamID: ptr.Uint(0)}
label, err = ds.LabelByName(ctx, "global-label", globalOnlyFilter)
require.NoError(t, err)
assert.Equal(t, globalLabel.ID, label.ID)
// Global-only filter cannot see team label
_, err = ds.LabelByName(ctx, "team1-labelbyname", globalOnlyFilter)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
// Non-existent label returns not found
_, err = ds.LabelByName(ctx, "nonexistent-label", fleet.TeamFilter{User: adminUser})
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
}
func testLabelsSave(t *testing.T, db *Datastore) {
@ -893,8 +1241,6 @@ func testLabelsRecordNonexistentQueryLabelExecution(t *testing.T, db *Datastore)
}
func testDeleteLabel(t *testing.T, db *Datastore) {
// TODO test team label filtering
ctx := context.Background()
l, err := db.NewLabel(ctx, &fleet.Label{
Name: t.Name(),
@ -954,6 +1300,52 @@ func testDeleteLabel(t *testing.T, db *Datastore) {
err = db.DeleteLabel(ctx, l2.Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.Error(t, err)
require.True(t, fleet.IsForeignKey(err))
// Test team label filtering
team1, err := db.NewTeam(ctx, &fleet.Team{Name: "team1_delete"})
require.NoError(t, err)
team2, err := db.NewTeam(ctx, &fleet.Team{Name: "team2_delete"})
require.NoError(t, err)
// Create team labels
team1Label, err := db.NewLabel(ctx, &fleet.Label{
Name: "team1-delete-label",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team2Label, err := db.NewLabel(ctx, &fleet.Label{
Name: "team2-delete-label",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
adminUser := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
team1User := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}}
// Global admin can delete team labels
err = db.DeleteLabel(ctx, team1Label.Name, fleet.TeamFilter{User: adminUser})
require.NoError(t, err)
// Verify it's deleted
_, err = db.LabelByName(ctx, team1Label.Name, fleet.TeamFilter{User: adminUser})
require.True(t, fleet.IsNotFound(err))
// Team user cannot delete label from another team (not found because not visible)
err = db.DeleteLabel(ctx, team2Label.Name, fleet.TeamFilter{User: team1User})
require.True(t, fleet.IsNotFound(err))
// Verify team2 label still exists
label, err := db.LabelByName(ctx, team2Label.Name, fleet.TeamFilter{User: adminUser})
require.NoError(t, err)
require.Equal(t, team2Label.ID, label.ID)
// Admin with team filter can delete
err = db.DeleteLabel(ctx, team2Label.Name, fleet.TeamFilter{User: adminUser, TeamID: &team2.ID})
require.NoError(t, err)
}
func testLabelsSummaryAndListTeamFiltering(t *testing.T, db *Datastore) {
@ -1861,8 +2253,6 @@ func labelIDFromName(t *testing.T, ds fleet.Datastore, name string) uint {
}
func testUpdateLabelMembershipByHostIDs(t *testing.T, ds *Datastore) {
// TODO validate team label host validation behavior
ctx := context.Background()
filter := fleet.TeamFilter{User: test.UserAdmin}
@ -2027,6 +2417,86 @@ func testUpdateLabelMembershipByHostIDs(t *testing.T, ds *Datastore) {
require.Equal(t, strconv.Itoa(int(host1.ID)), labelSpec.Hosts[0]) //nolint:gosec // dismiss G115
require.Equal(t, strconv.Itoa(int(host2.ID)), labelSpec.Hosts[1]) //nolint:gosec // dismiss G115
require.Equal(t, strconv.Itoa(int(host3.ID)), labelSpec.Hosts[2]) //nolint:gosec // dismiss G115
// Test team label host validation behavior
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1_membership"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2_membership"})
require.NoError(t, err)
// Create hosts on different teams
hostTeam1, err := ds.NewHost(ctx, &fleet.Host{
OsqueryHostID: ptr.String("team1host"),
NodeKey: ptr.String("team1host"),
UUID: "team1host",
Hostname: "team1host.local",
Platform: "darwin",
TeamID: &team1.ID,
})
require.NoError(t, err)
hostTeam2, err := ds.NewHost(ctx, &fleet.Host{
OsqueryHostID: ptr.String("team2host"),
NodeKey: ptr.String("team2host"),
UUID: "team2host",
Hostname: "team2host.local",
Platform: "darwin",
TeamID: &team2.ID,
})
require.NoError(t, err)
hostNoTeam, err := ds.NewHost(ctx, &fleet.Host{
OsqueryHostID: ptr.String("noteamhost"),
NodeKey: ptr.String("noteamhost"),
UUID: "noteamhost",
Hostname: "noteamhost.local",
Platform: "darwin",
TeamID: nil,
})
require.NoError(t, err)
// Create a team label
teamLabel, err := ds.NewLabel(ctx, &fleet.Label{
Name: "team1-membership-label",
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeManual,
TeamID: &team1.ID,
})
require.NoError(t, err)
// Adding a team1 host to a team1 label should succeed
teamLabelResult, teamHostIDs, err := ds.UpdateLabelMembershipByHostIDs(ctx, *teamLabel, []uint{hostTeam1.ID}, filter)
require.NoError(t, err)
require.Equal(t, 1, teamLabelResult.HostCount)
require.Len(t, teamHostIDs, 1)
require.Equal(t, hostTeam1.ID, teamHostIDs[0])
// Adding a team2 host to a team1 label should fail
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *teamLabel, []uint{hostTeam2.ID}, filter)
require.Error(t, err)
require.Contains(t, err.Error(), "supplied hosts are on a different team than the label")
// Adding a no-team host to a team1 label should fail
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *teamLabel, []uint{hostNoTeam.ID}, filter)
require.Error(t, err)
require.Contains(t, err.Error(), "supplied hosts are on a different team than the label")
// Adding mixed hosts (team1 + team2) to a team1 label should fail
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *teamLabel, []uint{hostTeam1.ID, hostTeam2.ID}, filter)
require.Error(t, err)
require.Contains(t, err.Error(), "supplied hosts are on a different team than the label")
// Global label can have hosts from any team
globalMembershipLabel, err := ds.NewLabel(ctx, &fleet.Label{
Name: "global-membership-label",
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeManual,
TeamID: nil,
})
require.NoError(t, err)
globalLabelResult, globalHostIDs, err := ds.UpdateLabelMembershipByHostIDs(ctx, *globalMembershipLabel, []uint{hostTeam1.ID, hostTeam2.ID, hostNoTeam.ID}, filter)
require.NoError(t, err)
require.Equal(t, 3, globalLabelResult.HostCount)
require.Len(t, globalHostIDs, 3)
}
func testApplyLabelSpecsForSerialUUID(t *testing.T, ds *Datastore) {
@ -2585,5 +3055,239 @@ func testUpdateLabelMembershipForTransferredHost(t *testing.T, ds *Datastore) {
}
func testSetAsideLabels(t *testing.T, ds *Datastore) {
// TODO
ctx := context.Background()
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1_setaside"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2_setaside"})
require.NoError(t, err)
i := 0
newUser := func(u fleet.User) *fleet.User {
i++
u.Name = fmt.Sprintf("SetAsideUser%d", i)
u.Email = fmt.Sprintf("%s@example.com", u.Name)
u.Password = []byte("foobar")
persisted, err := ds.NewUser(ctx, &u)
require.NoError(t, err)
return persisted
}
// Create users that are reused across tests
globalAdmin := newUser(fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)})
team1Admin := newUser(fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleAdmin}}})
team1Maintainer := newUser(fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer}}})
multiTeamUser := newUser(fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer},
{Team: fleet.Team{ID: team2.ID}, Role: fleet.RoleMaintainer},
}})
type labelSpec struct {
name string
teamID *uint
authorID *uint
}
type testCase struct {
name string
labels []labelSpec // labels to create for this test
notOnTeamID *uint // team ID being applied to
labelNames []string // names to pass to SetAsideLabels (if nil, uses labels[*].name)
user *fleet.User
expectError bool
}
cases := []testCase{
{
name: "empty names list is a no-op",
labels: nil,
notOnTeamID: nil,
labelNames: []string{},
user: globalAdmin,
expectError: false,
},
{
name: "global admin can set aside global labels when applying to a team",
labels: []labelSpec{{name: "global-setaside-1", teamID: nil, authorID: nil}},
notOnTeamID: &team1.ID,
user: globalAdmin,
expectError: false,
},
{
name: "global maintainer can set aside global labels",
labels: []labelSpec{{name: "global-setaside-2", teamID: nil, authorID: nil}},
notOnTeamID: &team1.ID,
user: newUser(fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}),
expectError: false,
},
{
name: "global gitops can set aside global labels",
labels: []labelSpec{{name: "global-setaside-3", teamID: nil, authorID: nil}},
notOnTeamID: &team1.ID,
user: newUser(fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}),
expectError: false,
},
{
name: "global observer cannot set aside global labels",
labels: []labelSpec{{name: "global-setaside-4", teamID: nil, authorID: nil}},
notOnTeamID: &team1.ID,
user: newUser(fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}),
expectError: true,
},
{
name: "team admin can set aside their own team's labels",
labels: []labelSpec{{name: "team1-setaside-1", teamID: &team1.ID, authorID: nil}},
notOnTeamID: &team2.ID,
user: team1Admin,
expectError: false,
},
{
name: "team maintainer can set aside their own team's labels",
labels: []labelSpec{{name: "team1-setaside-2", teamID: &team1.ID, authorID: nil}},
notOnTeamID: &team2.ID,
user: team1Maintainer,
expectError: false,
},
{
name: "team gitops can set aside their own team's labels",
labels: []labelSpec{{name: "team1-setaside-3", teamID: &team1.ID, authorID: nil}},
notOnTeamID: &team2.ID,
user: newUser(fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleGitOps}}}),
expectError: false,
},
{
name: "team observer cannot set aside team labels",
labels: []labelSpec{{name: "team1-setaside-4", teamID: &team1.ID, authorID: nil}},
notOnTeamID: &team2.ID,
user: newUser(fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleObserver}}}),
expectError: true,
},
{
name: "cannot set aside labels from the same team we're applying to",
labels: []labelSpec{{name: "team1-setaside-5", teamID: &team1.ID, authorID: nil}},
notOnTeamID: &team1.ID,
user: team1Admin,
expectError: true,
},
{
name: "team admin cannot set aside labels when applying to the same team, even if that user authored",
labels: []labelSpec{{name: "team1-setaside-6", teamID: &team1.ID, authorID: &team1Admin.ID}},
notOnTeamID: &team1.ID,
user: team1Admin,
expectError: true,
},
{
name: "cannot set aside global labels when applying to global",
labels: []labelSpec{{name: "global-setaside-5", teamID: nil, authorID: nil}},
notOnTeamID: nil,
user: globalAdmin,
expectError: true,
},
{
name: "team user with write role can set aside their authored global labels",
labels: []labelSpec{{name: "global-authored-setaside", teamID: nil, authorID: &team1Maintainer.ID}},
notOnTeamID: &team1.ID,
user: team1Maintainer,
expectError: false,
},
{
name: "team user cannot set aside non-authored global labels",
labels: []labelSpec{{name: "global-nonauthored-setaside", teamID: nil, authorID: &globalAdmin.ID}},
notOnTeamID: &team1.ID,
user: team1Maintainer,
expectError: true,
},
{
name: "multi-team user can set aside labels from teams they have write access to",
labels: []labelSpec{{name: "team2-setaside-1", teamID: &team2.ID, authorID: nil}},
notOnTeamID: &team1.ID,
user: newUser(fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer},
{Team: fleet.Team{ID: team2.ID}, Role: fleet.RoleAdmin},
}}),
expectError: false,
},
{
name: "multi-team user cannot set aside labels from teams they don't have write access to",
labels: []labelSpec{{name: "team2-setaside-2", teamID: &team2.ID, authorID: nil}},
notOnTeamID: &team1.ID,
user: newUser(fleet.User{Teams: []fleet.UserTeam{
{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleMaintainer},
{Team: fleet.Team{ID: team2.ID}, Role: fleet.RoleObserver},
}}),
expectError: true,
},
{
name: "non-existent label should fail",
labels: nil,
notOnTeamID: &team1.ID,
labelNames: []string{"nonexistent-label"},
user: globalAdmin,
expectError: true,
},
{
name: "multiple labels can be set aside at once",
labels: []labelSpec{
{name: "multi-setaside-1", teamID: nil, authorID: &multiTeamUser.ID},
{name: "multi-setaside-2", teamID: &team2.ID, authorID: nil},
},
notOnTeamID: &team1.ID,
user: multiTeamUser,
expectError: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Create labels for this test case
var expectedLabelNames []string
for _, spec := range tc.labels {
// Build expected renamed name based on label's team ID
var expectedSuffix string
if spec.teamID == nil {
expectedSuffix = "__team_0"
} else {
expectedSuffix = fmt.Sprintf("__team_%d", *spec.teamID)
}
expectedLabelNames = append(expectedLabelNames, spec.name+expectedSuffix)
_, err := ds.NewLabel(ctx, &fleet.Label{
Name: spec.name,
Query: "SELECT 1",
TeamID: spec.teamID,
AuthorID: spec.authorID,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
}
// Determine label names to use, and which to expect
labelNames := tc.labelNames
if labelNames == nil {
for _, spec := range tc.labels {
labelNames = append(labelNames, spec.name)
}
}
err := ds.SetAsideLabels(ctx, tc.notOnTeamID, labelNames, *tc.user)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Original name should not exist
oldLabels, _ := ds.LabelsByName(ctx, labelNames, fleet.TeamFilter{User: globalAdmin})
require.Empty(t, oldLabels)
// All renamed labels should exist
renamedLabels, _ := ds.LabelsByName(ctx, expectedLabelNames, fleet.TeamFilter{User: globalAdmin})
require.Len(t, renamedLabels, len(expectedLabelNames))
for _, expected := range expectedLabelNames {
_, ok := renamedLabels[expected]
require.Truef(t, ok, "Missing renamed label %s", expected)
}
})
}
}

View file

@ -3203,7 +3203,7 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label
})
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't %s labels. Labels not found: %s. All labels must exist.",
"Couldn't %s labels. Labels not found: %s. All labels must exist and be either global or on the same team as the host.",
action,
strings.Join(labelsNotFound, ", "),
),

View file

@ -4563,8 +4563,6 @@ func (s *integrationTestSuite) TestGetMacadminsData() {
}
func (s *integrationTestSuite) TestLabels() {
// TODO team labels
t := s.T()
// create some hosts to use for manual labels
@ -5382,8 +5380,6 @@ func (s *integrationTestSuite) TestLabelSpecs() {
// get a non-existing label spec
s.DoJSON("GET", "/api/latest/fleet/spec/labels/zzz", nil, http.StatusNotFound, &getResp)
// TODO team labels
}
func (s *integrationTestSuite) TestUsers() {
@ -13479,8 +13475,6 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
}
func (s *integrationTestSuite) TestAddingRemovingManualLabels() {
// TODO team labels
t := s.T()
ctx := context.Background()
@ -13610,13 +13604,13 @@ func (s *integrationTestSuite) TestAddingRemovingManualLabels() {
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.")
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.")
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},
@ -13641,13 +13635,13 @@ func (s *integrationTestSuite) TestAddingRemovingManualLabels() {
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.")
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.")
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"},
@ -13778,6 +13772,7 @@ func (s *integrationTestSuite) TestAddingRemovingManualLabels() {
}, http.StatusOK, &removeLabelsFromHostResp)
teamHost2Labels = getHostLabels(teamHost2)
require.Empty(t, teamHost2Labels)
}
func (s *integrationTestSuite) TestDebugDB() {

View file

@ -20,6 +20,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
@ -855,6 +856,282 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecsPermissions() {
s.Do("POST", "/api/latest/fleet/spec/teams", editTeam2Spec, http.StatusForbidden)
}
func (s *integrationEnterpriseTestSuite) TestTeamLabels() {
t := s.T()
// Create teams for testing team labels
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team2"})
require.NoError(t, err)
// Create hosts on different teams
teamHosts := s.createHosts(t, "darwin", "darwin")
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team1.ID, []uint{teamHosts[0].ID})))
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team2.ID, []uint{teamHosts[1].ID})))
// Create team labels using datastore directly (LabelPayload doesn't support TeamID)
team1Label, err := s.ds.NewLabel(context.Background(), &fleet.Label{
Name: t.Name() + "_team1_label",
Query: "SELECT 1",
TeamID: &team1.ID,
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team1LabelID := team1Label.ID
// Create a team2 label using datastore
team2Label, err := s.ds.NewLabel(context.Background(), &fleet.Label{
Name: "__team2_label",
Query: "SELECT 2",
TeamID: &team2.ID,
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
})
require.NoError(t, err)
team2LabelID := team2Label.ID
// Get the team label via API
var getResp getLabelResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", team1LabelID), nil, http.StatusOK, &getResp)
require.Equal(t, team1LabelID, getResp.Label.ID)
require.NotNil(t, getResp.Label.TeamID)
require.Equal(t, team1.ID, *getResp.Label.TeamID)
// List labels should include team labels for admin
var listResp listLabelsResponse
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp)
foundTeamLabel := false
foundTeam2Label := false
for _, lbl := range listResp.Labels {
if lbl.ID == team1LabelID {
foundTeamLabel = true
require.NotNil(t, lbl.TeamID)
require.Equal(t, team1.ID, *lbl.TeamID)
}
if lbl.ID == team2LabelID {
foundTeam2Label = true
require.NotNil(t, lbl.TeamID)
require.Equal(t, team2.ID, *lbl.TeamID)
}
}
require.True(t, foundTeamLabel, "team label should be in list")
require.True(t, foundTeam2Label, "team 2 label should be in list")
// Filter to specific team
listResp = listLabelsResponse{}
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(team1.ID))
foundTeamLabel = false
for _, lbl := range listResp.Labels {
if lbl.ID == team1LabelID {
foundTeamLabel = true
}
// Should not find team2 labels when filtering to team1
if lbl.TeamID != nil && *lbl.TeamID == team2.ID {
t.Fatal("should not find team2 labels when filtering to team1")
}
}
require.True(t, foundTeamLabel, "team1 label should be in filtered list")
// Filter to global only (team_id=0) should not include team labels
listResp = listLabelsResponse{}
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "team_id", "0")
for _, lbl := range listResp.Labels {
require.Nil(t, lbl.TeamID, "global-only filter should not return team labels")
}
// Modify team label
var modResp modifyLabelResponse
newName := t.Name() + "_team1_label_modified"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", team1LabelID), &fleet.ModifyLabelPayload{
Name: &newName,
}, http.StatusOK, &modResp)
require.Equal(t, newName, modResp.Label.Name)
// Delete team labels
var delResp deleteLabelResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", team1LabelID), nil, http.StatusOK, &delResp)
// Verify it's deleted
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", team1LabelID), nil, http.StatusNotFound, &getResp)
// Delete team2 label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", team2Label.Name), nil, http.StatusOK, &delResp)
// Verify it's deleted
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", team2LabelID), nil, http.StatusNotFound, &getResp)
/////// LABEL SPECS
// Apply a team label spec
teamLabelName := strings.ReplaceAll(t.Name(), "/", "_") + "_team1_label"
var applyResp applyLabelSpecsResponse
// make sure we fail loudly when we spec the request wrong
s.DoJSON("POST", "/api/latest/fleet/spec/labels", applyLabelSpecsRequest{
Specs: []*fleet.LabelSpec{
{
Name: teamLabelName,
Query: "select 1",
Platform: "darwin",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
TeamID: &team1.ID,
},
},
}, http.StatusUnprocessableEntity, &applyResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/spec/labels?team_id=%d", team1.ID), applyLabelSpecsRequest{
Specs: []*fleet.LabelSpec{
{
Name: teamLabelName,
Query: "select 1",
Platform: "darwin",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
},
},
}, http.StatusOK, &applyResp)
// List label specs should include team label for admin
var specsResp getLabelSpecsResponse
s.DoJSON("GET", "/api/latest/fleet/spec/labels", nil, http.StatusOK, &specsResp)
foundTeamLabel = false
for _, spec := range specsResp.Specs {
if spec.Name == teamLabelName {
foundTeamLabel = true
require.NotNil(t, spec.TeamID)
require.Equal(t, team1.ID, *spec.TeamID)
}
}
require.True(t, foundTeamLabel, "team label spec should be in list")
// Get the specific team label spec
var getSpecResp getLabelSpecResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/spec/labels/%s", url.PathEscape(teamLabelName)), nil, http.StatusOK, &getSpecResp)
require.Equal(t, teamLabelName, getSpecResp.Spec.Name)
require.NotNil(t, getSpecResp.Spec.TeamID)
require.Equal(t, team1.ID, *getSpecResp.Spec.TeamID)
// Filter specs to specific team
s.DoJSON("GET", "/api/latest/fleet/spec/labels", nil, http.StatusOK, &specsResp, "team_id", fmt.Sprint(team1.ID))
foundTeamLabel = false
for _, spec := range specsResp.Specs {
if spec.Name == teamLabelName {
foundTeamLabel = true
}
// Team2 labels should not be present
if spec.TeamID != nil && *spec.TeamID == team2.ID {
t.Fatal("should not find team2 labels when filtering to team1")
}
}
require.True(t, foundTeamLabel, "team1 label spec should be in filtered list")
// Filter specs to global only (team_id=0)
s.DoJSON("GET", "/api/latest/fleet/spec/labels", nil, http.StatusOK, &specsResp, "team_id", "0")
for _, spec := range specsResp.Specs {
require.Nil(t, spec.TeamID, "global-only filter should not return team labels")
}
/////// TEAM MANUAL LABELS RESTRICTIONS
ctx := context.Background()
// Create a global host for testing restrictions
globalHost, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String(t.Name() + "_global_host"),
NodeKey: ptr.String(t.Name() + "_global_host"),
UUID: t.Name() + "_global_host",
Hostname: t.Name() + "_global_host.local",
Platform: "darwin",
})
require.NoError(t, err)
// Create a team admin user for team1
teamAdminEmail := t.Name() + "_team_admin@example.com"
teamAdmin := &fleet.User{
Name: teamAdminEmail,
Email: teamAdminEmail,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, teamAdmin.SetPassword(test.GoodPassword, 10, 10))
teamAdmin, err = s.ds.NewUser(ctx, teamAdmin)
require.NoError(t, err)
// Create a team label (manual) on team1
teamManualLabel, err := s.ds.NewLabel(ctx, &fleet.Label{
Name: t.Name() + "_teamManualLabel1",
LabelMembershipType: fleet.LabelMembershipTypeManual,
TeamID: &team1.ID,
})
require.NoError(t, err)
// Helper function to get host label names
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)
labels := make([]string, 0, len(hostResp.Host.Labels))
for _, lbl := range hostResp.Host.Labels {
labels = append(labels, lbl.Name)
}
return labels
}
// Admin can add team label to team host (teamHosts[0] is on team1)
var addLabelsToHostResp addLabelsToHostResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHosts[0].ID), addLabelsToHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusOK, &addLabelsToHostResp)
team1HostLabels := getHostLabels(teamHosts[0])
require.Contains(t, team1HostLabels, teamManualLabel.Name)
// Cannot add team1 label to team2 host (teamHosts[1] is on team2)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHosts[1].ID), addLabelsToHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusBadRequest, &addLabelsToHostResp)
// Cannot add team1 label to global host
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), addLabelsToHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusBadRequest, &addLabelsToHostResp)
// Team admin can add their team's label to their team's host
// First remove the label added by admin
var removeLabelsFromHostResp removeLabelsFromHostResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHosts[0].ID), removeLabelsFromHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusOK, &removeLabelsFromHostResp)
s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHosts[0].ID), addLabelsToHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusOK, &addLabelsToHostResp)
team1HostLabels = getHostLabels(teamHosts[0])
require.Contains(t, team1HostLabels, teamManualLabel.Name)
// Team admin can remove their team's label from their team's host
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHosts[0].ID), removeLabelsFromHostRequest{
Labels: []string{teamManualLabel.Name},
}, http.StatusOK, &removeLabelsFromHostResp)
team1HostLabels = getHostLabels(teamHosts[0])
require.NotContains(t, team1HostLabels, teamManualLabel.Name)
// Reset token to admin
s.token = s.getTestAdminToken()
// Clean up teams
require.NoError(t, s.ds.DeleteTeam(context.Background(), team1.ID))
require.NoError(t, s.ds.DeleteTeam(context.Background(), team2.ID))
}
func (s *integrationEnterpriseTestSuite) TestTeamSchedule() {
t := s.T()

View file

@ -343,7 +343,7 @@ func getTeamIDOrZeroForGlobal(stringID *string) *uint {
}
func (svc *Service) ListLabels(ctx context.Context, opt fleet.ListOptions, teamID *uint, includeHostCounts bool) ([]*fleet.Label, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
if err := svc.authz.Authorize(ctx, &fleet.Label{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
@ -352,6 +352,10 @@ func (svc *Service) ListLabels(ctx context.Context, opt fleet.ListOptions, teamI
return nil, fleet.ErrNoContext
}
if !license.IsPremium(ctx) && teamID != nil && *teamID > 0 {
return nil, fleet.ErrMissingLicense
}
// TODO(mna): ListLabels doesn't currently return the hostIDs members of the
// label, the quick approach would be an N+1 queries endpoint. Leaving like
// that for now because we're in a hurry before merge freeze but the solution
@ -405,7 +409,7 @@ func getLabelsSummaryEndpoint(ctx context.Context, request interface{}, svc flee
}
func (svc *Service) LabelsSummary(ctx context.Context, teamID *uint) ([]*fleet.LabelSummary, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
if err := svc.authz.Authorize(ctx, &fleet.Label{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
@ -414,6 +418,10 @@ func (svc *Service) LabelsSummary(ctx context.Context, teamID *uint) ([]*fleet.L
return nil, fleet.ErrNoContext
}
if !license.IsPremium(ctx) && teamID != nil && *teamID > 0 {
return nil, fleet.ErrMissingLicense
}
return svc.ds.LabelsSummary(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID})
}
@ -682,6 +690,13 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe
}
// make sure we're only upserting labels on the team we specified; individual spec teams aren't used on writes
if spec.TeamID != nil {
return fleet.NewUserMessageError(
ctxerr.New(
ctx,
"When applying team label specs, provide the team label by URL query string parameter rather than within the JSON request body",
), http.StatusUnprocessableEntity)
}
spec.TeamID = teamID
regularSpecs = append(regularSpecs, spec)
}
@ -749,10 +764,14 @@ func getLabelSpecsEndpoint(ctx context.Context, request interface{}, svc fleet.S
}
func (svc *Service) GetLabelSpecs(ctx context.Context, teamID *uint) ([]*fleet.LabelSpec, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
if err := svc.authz.Authorize(ctx, &fleet.Label{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
if !license.IsPremium(ctx) && teamID != nil && *teamID > 0 {
return nil, fleet.ErrMissingLicense
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext

View file

@ -9,6 +9,7 @@ import (
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"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"
@ -41,18 +42,40 @@ func TestLabelsAuth(t *testing.T) {
ds.ApplyLabelSpecsFunc = func(ctx context.Context, specs []*fleet.LabelSpec) error {
return nil
}
team1ID := uint(1)
team2ID := uint(2)
team1LabelID := uint(3)
team2LabelID := uint(4)
team1Label := fleet.Label{ID: team1LabelID, Name: "team1-label", TeamID: &team1ID}
team2Label := fleet.Label{ID: team2LabelID, Name: "team2-label", TeamID: &team2ID}
// Update LabelFunc to handle team labels
ds.LabelFunc = func(ctx context.Context, id uint, filter fleet.TeamFilter) (*fleet.LabelWithTeamName, []uint, error) {
switch id {
case uint(1):
return &fleet.LabelWithTeamName{Label: fleet.Label{ID: id, AuthorID: &filter.User.ID}}, nil, nil
case uint(2):
return &fleet.LabelWithTeamName{Label: fleet.Label{ID: id}}, nil, nil
case team1LabelID: // team1 label
return &fleet.LabelWithTeamName{Label: team1Label}, nil, nil
case team2LabelID: // team2 label
return &fleet.LabelWithTeamName{Label: team2Label}, nil, nil
}
return nil, nil, ctxerr.Wrap(ctx, notFoundErr{"label", fleet.ErrorWithUUID{}})
}
ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) {
return &fleet.Label{ID: 2, Name: name}, nil // for deletes, TODO add cases for authorship/team differences
switch name {
case "team1-label":
return &team1Label, nil
case "team2-label":
return &team2Label, nil
default:
return &fleet.Label{ID: 2, Name: name}, nil
}
}
ds.ListLabelsFunc = func(ctx context.Context, filter fleet.TeamFilter, opts fleet.ListOptions, includeHostCounts bool) ([]*fleet.Label, error) {
return nil, nil
@ -76,6 +99,10 @@ func TestLabelsAuth(t *testing.T) {
shouldFailGlobalWrite bool
shouldFailGlobalRead bool
shouldFailGlobalWriteIfAuthor bool
shouldFailTeam1Write bool
shouldFailTeam1Read bool
shouldFailTeam2Write bool
shouldFailTeam2Read bool
}{
{
"global admin",
@ -83,6 +110,10 @@ func TestLabelsAuth(t *testing.T) {
false,
false,
false,
false,
false,
false,
false,
},
{
"global maintainer",
@ -90,6 +121,10 @@ func TestLabelsAuth(t *testing.T) {
false,
false,
false,
false,
false,
false,
false,
},
{
"global observer",
@ -97,20 +132,32 @@ func TestLabelsAuth(t *testing.T) {
true,
false,
true,
true,
false,
true,
false,
},
{
"team maintainer",
"team 1 maintainer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
true,
false,
false,
false,
false,
true,
true,
},
{
"team observer",
"team 1 observer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
true,
false,
true,
true,
false,
true,
true,
},
}
@ -118,8 +165,6 @@ func TestLabelsAuth(t *testing.T) {
otherLabel, _, err := svc.NewLabel(viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleMaintainer)}}), fleet.LabelPayload{Name: "Other label", Query: "SELECT 0"})
require.NoError(t, err)
// TODO create other-team label
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
@ -127,47 +172,89 @@ func TestLabelsAuth(t *testing.T) {
myLabel, _, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: t.Name(), Query: `SELECT 1`})
checkAuthErr(t, tt.shouldFailGlobalWriteIfAuthor, err) // team write users can still create global labels
_, _, err = svc.ModifyLabel(ctx, otherLabel.ID, fleet.ModifyLabelPayload{})
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
if myLabel != nil {
_, _, err = svc.ModifyLabel(ctx, myLabel.ID, fleet.ModifyLabelPayload{})
checkAuthErr(t, tt.shouldFailGlobalWriteIfAuthor, err)
}
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{}, nil, nil)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
_, _, err = svc.GetLabel(ctx, otherLabel.ID)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.GetLabelSpecs(ctx, nil)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.GetLabelSpec(ctx, "abc")
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.ListLabels(ctx, fleet.ListOptions{}, nil, true)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.LabelsSummary(ctx, nil)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.ListHostsInLabel(ctx, 1, fleet.HostListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
err = svc.DeleteLabel(ctx, "abc")
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
err = svc.DeleteLabelByID(ctx, otherLabel.ID)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
if myLabel != nil {
err = svc.DeleteLabelByID(ctx, myLabel.ID)
checkAuthErr(t, tt.shouldFailGlobalWriteIfAuthor, err)
}
// TODO add team label permissions
for _, tc := range []struct {
label fleet.Label
shouldFailWrite bool
shouldFailRead bool
}{
{*otherLabel, tt.shouldFailGlobalWrite, tt.shouldFailGlobalRead},
{team1Label, tt.shouldFailTeam1Write, tt.shouldFailTeam1Read},
{team2Label, tt.shouldFailTeam2Write, tt.shouldFailTeam2Read},
} {
t.Run(tc.label.Name, func(t *testing.T) {
ctx2 := ctx
if tc.label.TeamID != nil {
// license check is after auth check
if !tc.shouldFailRead {
_, err = svc.GetLabelSpecs(ctx2, tc.label.TeamID)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
_, err = svc.ListLabels(ctx2, fleet.ListOptions{}, tc.label.TeamID, true)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
_, err = svc.LabelsSummary(ctx2, tc.label.TeamID)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
}
if !tc.shouldFailWrite {
require.ErrorIs(t, svc.ApplyLabelSpecs(ctx2, []*fleet.LabelSpec{}, tc.label.TeamID, nil), fleet.ErrMissingLicense)
// We'll let global admins clean up team labels if they downgraded to Free
require.NoError(t, svc.DeleteLabel(ctx2, tc.label.Name))
require.NoError(t, svc.DeleteLabelByID(ctx2, tc.label.ID))
}
ctx2 = license.NewContext(ctx2, &fleet.LicenseInfo{Tier: fleet.TierPremium})
}
err = svc.ApplyLabelSpecs(ctx2, []*fleet.LabelSpec{}, tc.label.TeamID, nil)
checkAuthErr(t, tc.shouldFailWrite, err)
_, err = svc.GetLabelSpecs(ctx2, tc.label.TeamID)
checkAuthErr(t, tc.shouldFailRead, err)
_, err = svc.ListLabels(ctx2, fleet.ListOptions{}, tc.label.TeamID, true)
checkAuthErr(t, tc.shouldFailRead, err)
_, err = svc.LabelsSummary(ctx2, tc.label.TeamID)
checkAuthErr(t, tc.shouldFailRead, err)
err = svc.DeleteLabel(ctx2, tc.label.Name)
checkAuthErr(t, tc.shouldFailWrite, err)
err = svc.DeleteLabelByID(ctx2, tc.label.ID)
checkAuthErr(t, tc.shouldFailWrite, err)
})
}
// filtering is done in the data store for these; NOT enforcing premium license checks here
_, err = svc.GetLabelSpec(ctx, "abc")
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.ListHostsInLabel(ctx, 1, fleet.HostListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.GetLabel(ctx, 1)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.ModifyLabel(ctx, otherLabel.ID, fleet.ModifyLabelPayload{})
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
// global label listing should work if you can read global labels
_, err = svc.GetLabelSpecs(ctx, ptr.Uint(0))
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.ListLabels(ctx, fleet.ListOptions{}, ptr.Uint(0), true)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
}
}