Fix observer query bypass: prevent cross-team targeting (#40717)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **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: <img width="596" height="285" alt="Screenshot 2026-03-02 at 4 58 47 PM" src="https://github.com/user-attachments/assets/a3a8e7dd-2bfc-40f9-948c-b26b016162ae" /> Created report on **Workstations (canary)** fleet with **observers_can_run = true** <img width="1020" height="711" alt="Screenshot 2026-03-02 at 5 09 25 PM" src="https://github.com/user-attachments/assets/58aa98c7-8cbd-4a7a-a159-f4b40a65f2c9" /> 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**. <img width="986" height="823" alt="Screenshot 2026-03-02 at 5 07 29 PM" src="https://github.com/user-attachments/assets/b6b7aa4b-5036-46e3-8497-3a77f93a3a2c" /> ### 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)**: <img width="1087" height="883" alt="Screenshot 2026-03-03 at 10 47 05 AM" src="https://github.com/user-attachments/assets/9fc8d4d4-6a38-4ecb-98fe-b56b46ac4f74" /> ### Global user with global report (observer_can_run = true) Global Observer user can target all fleets. <img width="1329" height="609" alt="Screenshot 2026-03-03 at 10 56 03 AM" src="https://github.com/user-attachments/assets/059d4eb2-546f-4a19-9eee-b64dd0250bf1" /> <img width="981" height="818" alt="Screenshot 2026-03-03 at 10 57 50 AM" src="https://github.com/user-attachments/assets/afa0ee58-3457-4838-a96e-dd508d924079" /> ### Global user with global report (observer_can_run = false) Global Observer user can't target any fleet. <img width="691" height="574" alt="Screenshot 2026-03-03 at 10 59 57 AM" src="https://github.com/user-attachments/assets/f328d547-ed06-4c30-ac22-5df7bb32240a" /> <img width="985" height="814" alt="Screenshot 2026-03-03 at 11 00 06 AM" src="https://github.com/user-attachments/assets/bb55da11-ea3f-40c7-bd98-652880d9e8f9" /> ## 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. <img width="1130" height="858" alt="Screenshot 2026-03-03 at 11 13 40 AM" src="https://github.com/user-attachments/assets/8d9d97c4-9946-4c4c-9a8a-d79c65d9cb33" /> ### Team user with team policy Created user: - **Technician** on **Servers**. - **Observer** on **Servers (canary)**. <img width="745" height="770" alt="Screenshot 2026-03-03 at 11 18 11 AM" src="https://github.com/user-attachments/assets/56973c34-49bb-4007-9fac-09cf5315bdff" /> Can only select **Servers** as a target: <img width="999" height="754" alt="Screenshot 2026-03-03 at 11 18 56 AM" src="https://github.com/user-attachments/assets/82d14a8f-46e1-41f5-9355-d717477c85d8" /> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Lucas Manuel Rodriguez
parent
dc7c23c59c
commit
24e0ef47c8
@@ -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.
|
||||
@@ -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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isLivePolicy
|
||||
isObserverCanRunQuery={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery={false}
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={2}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isLivePolicy
|
||||
isObserverCanRunQuery={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery={false}
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isLivePolicy
|
||||
isObserverCanRunQuery={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={2}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isLivePolicy
|
||||
isObserverCanRunQuery={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery={false}
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery
|
||||
queryTeamId={null}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery={false}
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isObserverCanRunQuery={false}
|
||||
queryTeamId={1}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<SelectTargets
|
||||
{...defaultProps}
|
||||
isLivePolicy
|
||||
isObserverCanRunQuery={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getTeamButton("Unassigned")).toBeEnabled();
|
||||
expect(getTeamButton("Team Alpha")).toBeEnabled();
|
||||
expect(getTeamButton("Team Beta")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ interface ISelectTargetsProps {
|
||||
setTargetsTotalCount: React.Dispatch<React.SetStateAction<number>>;
|
||||
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<number>
|
||||
): JSX.Element => {
|
||||
const isTeamsSection = entityType === "teams";
|
||||
const displayType = isTeamsSection ? "fleets" : entityType;
|
||||
@@ -415,6 +418,11 @@ const SelectTargets = ({
|
||||
return (
|
||||
<>
|
||||
{entityType && <h3>{capitalize(displayType)}</h3>}
|
||||
{isTeamsSection && !!disabledIds?.size && (
|
||||
<p className={`${baseClass}__team-help-text`}>
|
||||
Results limited to fleets you can access.
|
||||
</p>
|
||||
)}
|
||||
{isSearchEnabled && (
|
||||
<>
|
||||
<SearchField
|
||||
@@ -438,6 +446,7 @@ const SelectTargets = ({
|
||||
entity={entity}
|
||||
isSelected={targetList.some((t) => 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<number> => {
|
||||
const disabled = new Set<number>();
|
||||
|
||||
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 <Spinner />;
|
||||
}
|
||||
@@ -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)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ITargetChipSelectorProps {
|
||||
onClick: (
|
||||
value: ISelectLabel | ISelectTeam
|
||||
) => React.MouseEventHandler<HTMLButtonElement>;
|
||||
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 = ({
|
||||
<button
|
||||
className="target-chip-selector"
|
||||
data-selected={isSelected}
|
||||
disabled={disabled}
|
||||
onClick={(e) => onClick(entity)(e)}
|
||||
>
|
||||
<Icon name={isSelected ? "check" : "plus"} color="ui-fleet-black-75" />
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
color: $core-fleet-black;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
background-color: $core-fleet-white;
|
||||
box-shadow: inset 0 0 0 1px $ui-fleet-black-25;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// When tabbing
|
||||
&:focus-visible {
|
||||
outline: 1px solid $core-focused-outline;
|
||||
|
||||
@@ -183,6 +183,7 @@ const RunQueryPage = ({
|
||||
setTargetedTeams,
|
||||
setTargetsTotalCount,
|
||||
isObserverCanRunQuery: storedQuery?.observer_can_run,
|
||||
queryTeamId: storedQuery?.team_id ?? null,
|
||||
};
|
||||
|
||||
const step2Props = {
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__team-help-text {
|
||||
@include help-text;
|
||||
margin: 0 0 $pad-small;
|
||||
}
|
||||
|
||||
&__empty-entity-search {
|
||||
margin-top: $pad-small;
|
||||
font-size: $x-small;
|
||||
|
||||
@@ -559,12 +559,38 @@ allow {
|
||||
action == run_new
|
||||
}
|
||||
|
||||
# Global observers can run only if observers_can_run.
|
||||
# Global observers can run observer_can_run global queries (`null` team_id).
|
||||
allow {
|
||||
object.type == "targeted_query"
|
||||
object.observer_can_run == true
|
||||
subject.global_role == observer
|
||||
action = run
|
||||
|
||||
is_null(object.team_id)
|
||||
}
|
||||
|
||||
# Global observers can run observer_can_run team queries only targeting that team.
|
||||
allow {
|
||||
object.type == "targeted_query"
|
||||
object.observer_can_run == true
|
||||
subject.global_role == observer
|
||||
action = run
|
||||
|
||||
not is_null(object.team_id)
|
||||
not is_null(object.host_targets.teams)
|
||||
ok_teams := { tmid | tmid := object.host_targets.teams[_]; tmid == object.team_id }
|
||||
count(ok_teams) == count(object.host_targets.teams)
|
||||
}
|
||||
|
||||
# Global observers can run observer_can_run team queries when no target teams are specified.
|
||||
allow {
|
||||
object.type == "targeted_query"
|
||||
object.observer_can_run == true
|
||||
subject.global_role == observer
|
||||
action = run
|
||||
|
||||
not is_null(object.team_id)
|
||||
is_null(object.host_targets.teams)
|
||||
}
|
||||
|
||||
# Team admin, maintainer, technician, observer_plus and observer running a global observers_can_run query must have the targets
|
||||
@@ -593,7 +619,7 @@ allow {
|
||||
team_role(subject, object.team_id) == [admin, maintainer, technician, observer_plus, observer][_]
|
||||
|
||||
not is_null(object.host_targets.teams)
|
||||
ok_teams := { tmid | tmid := object.host_targets.teams[_]; team_role(subject, tmid) == [admin, maintainer, technician, observer_plus, observer][_] }
|
||||
ok_teams := { tmid | tmid := object.host_targets.teams[_]; team_role(subject, tmid) == [admin, maintainer, technician, observer_plus][_] } | { tmid | tmid := object.host_targets.teams[_]; tmid == object.team_id; team_role(subject, tmid) == observer }
|
||||
count(ok_teams) == count(object.host_targets.teams)
|
||||
}
|
||||
|
||||
|
||||
@@ -1291,6 +1291,23 @@ func TestAuthorizeQuery(t *testing.T) {
|
||||
HostTargets: fleet.HostTargets{TeamIDs: []uint{2}},
|
||||
Query: observerQueryOnTeam1,
|
||||
}
|
||||
observerQueryOnTeam1TargetedToTeam1AndTeam2 := &fleet.TargetedQuery{
|
||||
HostTargets: fleet.HostTargets{TeamIDs: []uint{1, 2}},
|
||||
Query: observerQueryOnTeam1,
|
||||
}
|
||||
observerQueryOnTeam2 := &fleet.Query{
|
||||
ID: 8,
|
||||
ObserverCanRun: true,
|
||||
TeamID: ptr.Uint(2),
|
||||
}
|
||||
observerQueryOnTeam2TargetedToTeam2 := &fleet.TargetedQuery{
|
||||
HostTargets: fleet.HostTargets{TeamIDs: []uint{2}},
|
||||
Query: observerQueryOnTeam2,
|
||||
}
|
||||
observerQueryOnTeam1EmptyTeams := &fleet.TargetedQuery{
|
||||
HostTargets: fleet.HostTargets{TeamIDs: []uint{}},
|
||||
Query: observerQueryOnTeam1,
|
||||
}
|
||||
|
||||
runTestCasesGroups(t, []tcGroup{
|
||||
{
|
||||
@@ -1343,8 +1360,12 @@ func TestAuthorizeQuery(t *testing.T) {
|
||||
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3, action: read, allow: true},
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3, action: write, allow: false},
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3TargetedToTeam3, action: run, allow: true},
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3TargetedToTeam2, action: run, allow: true},
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3TargetedToTeam3, action: run, allow: true}, // global observer can run observer_can_run query targeting query's own team
|
||||
{user: test.UserObserver, object: observerQueryOnTeam3TargetedToTeam2, action: run, allow: false}, // global observer cannot target team 2 for a team 3 query
|
||||
|
||||
{user: test.UserObserver, object: observerQueryOnTeam1EmptyTeams, action: run, allow: true}, // global observer can run team observer_can_run query with empty teams
|
||||
{user: test.UserObserver, object: observerQueryOnTeam1TargetedToTeam2, action: run, allow: false}, // global observer cannot target team 2 for a team 1 observer_can_run query
|
||||
{user: test.UserObserver, object: observerQueryOnTeam1TargetedToTeam1AndTeam2, action: run, allow: false}, // global observer cannot target multiple teams for a team observer_can_run query
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1475,6 +1496,7 @@ func TestAuthorizeQuery(t *testing.T) {
|
||||
{user: teamObserver, object: observerQueryOnTeam3TargetedToTeam2, action: run, allow: false},
|
||||
{user: teamObserver, object: observerQueryOnTeam3TargetedToTeam1, action: run, allow: false},
|
||||
{user: teamObserver, object: observerQueryOnTeam1TargetedToTeam1, action: run, allow: true},
|
||||
{user: teamObserver, object: observerQueryOnTeam1EmptyTeams, action: run, allow: true}, // team observer can run observer_can_run query with empty teams
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1638,7 +1660,10 @@ func TestAuthorizeQuery(t *testing.T) {
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam3TargetedToTeam2, action: run, allow: false},
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam3TargetedToTeam1, action: run, allow: false},
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam1TargetedToTeam1, action: run, allow: true},
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam1TargetedToTeam2, action: run, allow: true},
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam1TargetedToTeam2, action: run, allow: false}, // observer on team 2 cannot run query belonging to team 1
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam1TargetedToTeam1AndTeam2, action: run, allow: false}, // observer on team 2 cannot run query belonging to team 1 targeting both teams
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam1EmptyTeams, action: run, allow: true}, // admin on team 1 can run team 1 observer_can_run query with empty teams
|
||||
{user: twoTeamsAdminObs, object: observerQueryOnTeam2TargetedToTeam2, action: run, allow: true}, // observer on team 2 can run query belonging to team 2
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user