diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 5efcdbdf25..4a60e08dbd 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -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 diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index f2c0bd557a..24841dfe5d 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -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") } diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index ba8d3c5e14..1bc57630b5 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -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) + } + }) + } } diff --git a/server/service/hosts.go b/server/service/hosts.go index ca79e16fb1..5baee2e1e6 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -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, ", "), ), diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index bc18c80960..7a9ef2a0be 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -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() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index b7361336a5..af6997e7bd 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -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() diff --git a/server/service/labels.go b/server/service/labels.go index 886a248f11..8c204d3a4b 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -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 diff --git a/server/service/labels_test.go b/server/service/labels_test.go index 180680e827..7e0a747baa 100644 --- a/server/service/labels_test.go +++ b/server/service/labels_test.go @@ -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) }) } }