diff --git a/changes/36093-live-report-cross-team-targeting b/changes/36093-live-report-cross-team-targeting new file mode 100644 index 0000000000..55e6f9e73a --- /dev/null +++ b/changes/36093-live-report-cross-team-targeting @@ -0,0 +1 @@ +* Fixed observer query bypass by restricting live query/report team targeting to only teams where the user has sufficient permissions, including global observers who are now limited to the query's own team when `observer_can_run` is true. diff --git a/frontend/components/LiveQuery/SelectTargets.tests.tsx b/frontend/components/LiveQuery/SelectTargets.tests.tsx new file mode 100644 index 0000000000..5a984a2056 --- /dev/null +++ b/frontend/components/LiveQuery/SelectTargets.tests.tsx @@ -0,0 +1,538 @@ +import React from "react"; + +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; + +import mockServer from "test/mock-server"; +import { baseUrl, createCustomRenderer } from "test/test-utils"; +import createMockUser from "__mocks__/userMock"; + +import SelectTargets from "./SelectTargets"; + +const MOCK_LABELS = [ + { id: 1, name: "All Hosts", label_type: "builtin", description: "" }, + { id: 2, name: "macOS", label_type: "builtin", description: "" }, +]; + +const MOCK_TEAMS = [ + { id: 1, name: "Team Alpha", host_count: 10, user_count: 5 }, + { id: 2, name: "Team Beta", host_count: 20, user_count: 8 }, +]; + +const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => { + return HttpResponse.json({ labels: MOCK_LABELS }); +}); + +const teamsHandler = http.get(baseUrl("/teams"), () => { + return HttpResponse.json({ teams: MOCK_TEAMS }); +}); + +const defaultProps = { + baseClass: "select-targets", + selectedTargets: [], + targetedHosts: [], + targetedLabels: [], + targetedTeams: [], + goToQueryEditor: jest.fn(), + goToRunQuery: jest.fn(), + setSelectedTargets: jest.fn(), + setTargetedHosts: jest.fn(), + setTargetedLabels: jest.fn(), + setTargetedTeams: jest.fn(), + setTargetsTotalCount: jest.fn(), +}; + +const getTeamButton = (name: string) => + screen.getByText(name).closest("button"); + +describe("SelectTargets - team disabling", () => { + beforeEach(() => { + mockServer.use(labelSummariesHandler, teamsHandler); + }); + + describe("plain observer (not observer+)", () => { + const plainObserverOnBothTeams = createMockUser({ + global_role: null, + teams: [ + { ...MOCK_TEAMS[0], role: "observer" }, + { ...MOCK_TEAMS[1], role: "observer" }, + ], + }); + + it("disables all observer teams for live policies", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: plainObserverOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeDisabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("disables observer teams when query does not have observer_can_run", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: plainObserverOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeDisabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("enables only the query's own team when observer_can_run is true", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: plainObserverOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("disables observer teams targeting a different team than the query's team", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: plainObserverOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + // Team Alpha is not the query's team, so disabled for observer + expect(getTeamButton("Team Alpha")).toBeDisabled(); + // Team Beta IS the query's team, so enabled + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + }); + + describe("observer+ user", () => { + const observerPlusOnBothTeams = createMockUser({ + global_role: null, + teams: [ + { ...MOCK_TEAMS[0], role: "observer_plus" }, + { ...MOCK_TEAMS[1], role: "observer_plus" }, + ], + }); + + it("enables all teams for observer+ even on live policies", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: observerPlusOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + + it("enables all teams for observer+ on queries", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: observerPlusOnBothTeams, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + }); + + describe("multi-team user with mixed roles (admin on team 1, observer on team 2)", () => { + const adminObsUser = createMockUser({ + global_role: null, + teams: [ + { ...MOCK_TEAMS[0], role: "admin" }, + { ...MOCK_TEAMS[1], role: "observer" }, + ], + }); + + it("disables only the observer team for live policies", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: adminObsUser, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("disables observer team when observer_can_run query belongs to a different team", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: adminObsUser, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + // Query belongs to team 1 (admin team), observer_can_run is true + // Team 2 (observer) should still be disabled because query belongs to team 1 + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("enables observer team when observer_can_run query belongs to that team", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: adminObsUser, + isPremiumTier: true, + isOnGlobalTeam: false, + }, + }, + }); + + // Query belongs to team 2 (observer team) and observer_can_run is true + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + }); + + describe("global observer", () => { + const globalObserver = createMockUser({ + global_role: "observer", + teams: [], + }); + + it("disables all teams (including Unassigned) for live policies", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalObserver, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Unassigned")).toBeDisabled(); + expect(getTeamButton("Team Alpha")).toBeDisabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("disables all teams when query does not have observer_can_run", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalObserver, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Unassigned")).toBeDisabled(); + expect(getTeamButton("Team Alpha")).toBeDisabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("enables only the query's own team when observer_can_run is true", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalObserver, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Unassigned")).toBeDisabled(); + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeDisabled(); + }); + }); + + it("enables all teams for a global observer_can_run query (no team_id)", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalObserver, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Unassigned")).toBeEnabled(); + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + }); + + describe("helper text visibility", () => { + it("shows helper text when some fleets are disabled", async () => { + const plainObserver = createMockUser({ + global_role: "observer", + teams: [], + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: plainObserver, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByText("Results limited to fleets you can access.") + ).toBeInTheDocument(); + }); + }); + + it("does not show helper text when no fleets are disabled", async () => { + const globalAdmin = createMockUser({ + global_role: "admin", + teams: [], + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalAdmin, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + // Wait for teams to render to confirm loading is done before asserting absence + await waitFor(() => { + expect(getTeamButton("Team Alpha")).toBeInTheDocument(); + }); + expect( + screen.queryByText("Results limited to fleets you can access.") + ).not.toBeInTheDocument(); + }); + }); + + describe("global observer+", () => { + const globalObserverPlus = createMockUser({ + global_role: "observer_plus", + teams: [], + }); + + it("enables all teams for global observer+ even on live policies", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: globalObserverPlus, + isPremiumTier: true, + isOnGlobalTeam: true, + }, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(getTeamButton("Unassigned")).toBeEnabled(); + expect(getTeamButton("Team Alpha")).toBeEnabled(); + expect(getTeamButton("Team Beta")).toBeEnabled(); + }); + }); + }); +}); diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index 63c3d3c6eb..efee6504b3 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -54,6 +54,7 @@ interface ISelectTargetsProps { setTargetsTotalCount: React.Dispatch>; isLivePolicy?: boolean; isObserverCanRunQuery?: boolean; + queryTeamId?: number | null; } interface ILabelsByType { @@ -124,6 +125,7 @@ const SelectTargets = ({ setTargetsTotalCount, isLivePolicy, isObserverCanRunQuery, + queryTeamId, }: ISelectTargetsProps): JSX.Element => { const isMountedRef = useRef(false); const { isPremiumTier, isOnGlobalTeam, currentUser } = useContext(AppContext); @@ -359,7 +361,8 @@ const SelectTargets = ({ const renderTargetEntitySection = ( entityType: string, - entityList: ISelectLabel[] | ISelectTeam[] + entityList: ISelectLabel[] | ISelectTeam[], + disabledIds?: Set ): JSX.Element => { const isTeamsSection = entityType === "teams"; const displayType = isTeamsSection ? "fleets" : entityType; @@ -415,6 +418,11 @@ const SelectTargets = ({ return ( <> {entityType &&

{capitalize(displayType)}

} + {isTeamsSection && !!disabledIds?.size && ( +

+ Results limited to fleets you can access. +

+ )} {isSearchEnabled && ( <> t.id === entity.id)} onClick={handleButtonSelect} + disabled={disabledIds?.has(entity.id)} /> ); })} @@ -531,31 +540,46 @@ const SelectTargets = ({ const resultsTableConfig = generateTableHeaders(); const selectedHostsTableConfig = generateTableHeaders(handleRowRemove); - // Filter out observer teams that break live query/policy API - const filterTeamObserverTeams = () => { - // API blocks live policy if a team level user is able to select the team they are an observer on - if (isLivePolicy) { - return ( - teams?.filter( - (team) => - !permissions.isTeamObserver(currentUser, team.id) || - permissions.isTeamObserverPlus(currentUser, team.id) - ) || [] - ); + const shouldDisableForObserver = (teamId: number): boolean => { + if (isLivePolicy) return true; + if (!isObserverCanRunQuery) return true; + if (queryTeamId != null && queryTeamId !== teamId) return true; + return false; + }; + + const getDisabledTeamIds = (): Set => { + const disabled = new Set(); + + const isGlobalPlainObserver = currentUser?.global_role === "observer"; + + if (isGlobalPlainObserver) { + // Global plain observers have the same restrictions as team-level + // observers but applied to ALL teams/fleets (including "Unassigned") + const allTeamIds = [...(teams?.map((t) => t.id) || []), 0]; // 0 = "Unassigned" + allTeamIds.forEach((teamId) => { + if (shouldDisableForObserver(teamId)) { + disabled.add(teamId); + } + }); + return disabled; } - // API blocks live query if a team level user is able to select the team they are an observer on - // AND the query does not have observer can run enabled - return ( - teams?.filter( - (team) => - !permissions.isTeamObserver(currentUser, team.id) || - permissions.isTeamObserverPlus(currentUser, team.id) || - isObserverCanRunQuery - ) || [] - ); + // Team/fleet-level plain observer logic + teams?.forEach((team) => { + const isPlainObserver = + permissions.isTeamObserver(currentUser, team.id) && + !permissions.isTeamObserverPlus(currentUser, team.id); + if (!isPlainObserver) return; + + if (shouldDisableForObserver(team.id)) { + disabled.add(team.id); + } + }); + return disabled; }; + const disabledTeamIds = getDisabledTeamIds(); + if (isLoadingLabels || isLoadingTeams) { return ; } @@ -570,11 +594,12 @@ const SelectTargets = ({ renderTargetEntitySection("Platforms", labels.platforms)} {!!teams?.length && (isOnGlobalTeam - ? renderTargetEntitySection("teams", [ - { id: 0, name: "Unassigned" }, - ...teams, - ]) - : renderTargetEntitySection("teams", filterTeamObserverTeams()))} + ? renderTargetEntitySection( + "teams", + [{ id: 0, name: "Unassigned" }, ...teams], + disabledTeamIds + ) + : renderTargetEntitySection("teams", teams, disabledTeamIds))} {!!labels?.other?.length && renderTargetEntitySection("labels", labels.other)} diff --git a/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx index 96490f4544..0eb3a8d59a 100644 --- a/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx +++ b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx @@ -16,6 +16,7 @@ interface ITargetChipSelectorProps { onClick: ( value: ISelectLabel | ISelectTeam ) => React.MouseEventHandler; + disabled?: boolean; } const isBuiltInLabel = ( @@ -28,6 +29,7 @@ const TargetChipSelector = ({ entity, isSelected, onClick, + disabled, }: ITargetChipSelectorProps): JSX.Element => { const displayText = (): string => { if (isBuiltInLabel(entity)) { @@ -44,6 +46,7 @@ const TargetChipSelector = ({