From 24e0ef47c8dc3ad19f98875ff64b7688132c291b Mon Sep 17 00:00:00 2001 From: Nico <32375741+nulmete@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:12:04 -0300 Subject: [PATCH] Fix observer query bypass: prevent cross-team targeting (#40717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #36093 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. # Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Queries/reports ### Team user with team report (observer_can_run = true) Created user with the following assignments: Screenshot 2026-03-02 at 4 58 47 PM Created report on **Workstations (canary)** fleet with **observers_can_run = true** Screenshot 2026-03-02 at 5 09 25 PM Logged in with newly-created user, selected the report above to run it as a live report. - Verified that **Servers (canary)** is disabled => user is **Observer** on that fleet, but query belongs to **Workstations (canary)**. - All the other fleets are enabled: - User is **Observer+ or more** in those fleets. - User is **Observer** in **Workstations (canary)** => enabled because report belongs to this fleet, AND **observer_can_run = true**. Screenshot 2026-03-02 at 5 07 29 PM ### Global user with team report (observer_can_run = true) - Created global Observer user. - Accessed same report created above for **Workstations (canary)** fleet with **observers_can_run = true**. - Logged in with newly-created user, selected the report above to run it as a live report. - Verified that the only target available is **Workstations (canary)**: Screenshot 2026-03-03 at 10 47
05 AM ### Global user with global report (observer_can_run = true) Global Observer user can target all fleets. Screenshot 2026-03-03 at 10 56
03 AM Screenshot 2026-03-03 at 10 57 50 AM ### Global user with global report (observer_can_run = false) Global Observer user can't target any fleet. Screenshot 2026-03-03 at 10 59 57 AM Screenshot 2026-03-03 at 11 00 06 AM ## Policies On the FE, the same component is used to display the targets for Live Policies, so just making sure that I didn't introduce any regression. ### Global technician user, all fleets policy Can select all fleets. Screenshot 2026-03-03 at 11 13
40 AM ### Team user with team policy Created user: - **Technician** on **Servers**. - **Observer** on **Servers (canary)**. Screenshot 2026-03-03 at 11 18 11 AM Can only select **Servers** as a target: Screenshot 2026-03-03 at 11 18 56 AM --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Lucas Manuel Rodriguez --- .../36093-live-report-cross-team-targeting | 1 + .../LiveQuery/SelectTargets.tests.tsx | 538 ++++++++++++++++++ .../components/LiveQuery/SelectTargets.tsx | 79 ++- .../TargetChipSelector/TargetChipSelector.tsx | 3 + .../LiveQuery/TargetChipSelector/_styles.scss | 10 + .../live/LiveQueryPage/LiveQueryPage.tsx | 1 + .../queries/live/LiveQueryPage/_styles.scss | 5 + server/authz/policy.rego | 30 +- server/authz/policy_test.go | 31 +- 9 files changed, 666 insertions(+), 32 deletions(-) create mode 100644 changes/36093-live-report-cross-team-targeting create mode 100644 frontend/components/LiveQuery/SelectTargets.tests.tsx 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 = ({