From f397b77c1a6933e6f65f66e63f05b8b6f9e5949a Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 30 Sep 2021 10:30:44 -0400 Subject: [PATCH] Schedule Page: Team maintainer can modify their team's schedule (#2243) * Change permissions to schedule UI to include team maintainers * Update e2e tests --- ...sue-2058-team-maintainer-can-schedule-team | 1 + cypress/integration/all/app/queryflow.spec.ts | 75 +--------- cypress/integration/free/maintainer.spec.ts | 2 +- cypress/integration/premium/admin.spec.ts | 137 +++++++++++++++++- .../premium/team_maintainer_observer.spec.ts | 17 ++- docs/01-Using-Fleet/09-Permissions.md | 9 +- .../side_panels/SiteTopNav/SiteTopNav.jsx | 48 +++--- .../side_panels/SiteTopNav/navItems.js | 22 ++- .../ManageSchedulePage/ManageSchedulePage.tsx | 85 ++++++++--- .../ScheduleListWrapper.tsx | 23 +-- frontend/router/index.tsx | 2 + 11 files changed, 276 insertions(+), 145 deletions(-) create mode 100644 changes/issue-2058-team-maintainer-can-schedule-team diff --git a/changes/issue-2058-team-maintainer-can-schedule-team b/changes/issue-2058-team-maintainer-can-schedule-team new file mode 100644 index 0000000000..ef694bf8b4 --- /dev/null +++ b/changes/issue-2058-team-maintainer-can-schedule-team @@ -0,0 +1 @@ +- Team maintainer now has permissions to modify their team's schedule \ No newline at end of file diff --git a/cypress/integration/all/app/queryflow.spec.ts b/cypress/integration/all/app/queryflow.spec.ts index 6ccb20127a..3677ec4126 100644 --- a/cypress/integration/all/app/queryflow.spec.ts +++ b/cypress/integration/all/app/queryflow.spec.ts @@ -38,6 +38,7 @@ describe( cy.findByText(/query all/i).click(); + cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting cy.findByText(/run query/i).should("exist"); cy.get(".ace_scroller") @@ -48,79 +49,7 @@ describe( cy.findByText(/query updated/i).should("be.visible"); - // // Start e2e test for schedules - // cy.visit("/schedule/manage"); - - // cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting - - // cy.findByRole("button", { name: /schedule a query/i }).click(); - - // cy.findByText(/select query/i).click(); - - // cy.findByText(/query all window crashes/i).click(); - - // cy.get( - // ".schedule-editor-modal__form-field--frequency > .dropdown__select" - // ).click(); - - // cy.findByText(/every week/i).click(); - - // cy.findByText(/show advanced options/i).click(); - - // cy.get( - // ".schedule-editor-modal__form-field--logging > .dropdown__select" - // ).click(); - - // cy.findByText(/ignore removals/i).click(); - - // cy.get(".schedule-editor-modal__form-field--shard > .input-field") - // .click() - // .type("50"); - - // cy.get(".schedule-editor-modal__btn-wrap") - // .contains("button", /schedule/i) - // .click(); - - // cy.visit("/schedule/manage"); - - // cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting - // cy.findByText(/query all window crashes/i).should("exist"); - - // cy.findByText(/actions/i).click(); - // cy.findByText(/edit/i).click(); - - // cy.get( - // ".schedule-editor-modal__form-field--frequency > .dropdown__select" - // ).click(); - - // cy.findByText(/every 6 hours/i).click(); - - // cy.findByText(/show advanced options/i).click(); - - // cy.findByText(/ignore removals/i).click(); - // cy.findByText(/snapshot/i).click(); - - // cy.get(".schedule-editor-modal__form-field--shard > .input-field") - // .click() - // .type("{selectall}{backspace}10"); - - // cy.get(".schedule-editor-modal__btn-wrap") - // .contains("button", /schedule/i) - // .click(); - - // cy.visit("/schedule/manage"); - - // cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting - // cy.findByText(/actions/i).click(); - // cy.findByText(/remove/i).click(); - - // cy.get(".remove-scheduled-query-modal__btn-wrap") - // .contains("button", /remove/i) - // .click(); - - // cy.findByText(/query all window crashes/i).should("not.exist"); - - // // End e2e test for schedules + // E2e Test for schedules moved to premium/admin cy.visit("/queries/manage"); diff --git a/cypress/integration/free/maintainer.spec.ts b/cypress/integration/free/maintainer.spec.ts index 5f50c2e835..4c24e4506f 100644 --- a/cypress/integration/free/maintainer.spec.ts +++ b/cypress/integration/free/maintainer.spec.ts @@ -22,7 +22,7 @@ describe( cy.visit("/"); // Ensure page is loaded - cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting + // cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting cy.contains("All hosts"); // Settings restrictions diff --git a/cypress/integration/premium/admin.spec.ts b/cypress/integration/premium/admin.spec.ts index 0e455f558b..f61c4262e6 100644 --- a/cypress/integration/premium/admin.spec.ts +++ b/cypress/integration/premium/admin.spec.ts @@ -78,12 +78,143 @@ describe( // cy.findByText(/teams/i).should("exist"); // }); + cy.visit("/queries/manage"); + + cy.findByRole("button", { name: /create new query/i }).click(); + + // Using class selector because third party element doesn't work with Cypress Testing Selector Library + cy.get(".ace_scroller") + .click({ force: true }) + .type("{selectall}SELECT * FROM windows_crashes;"); + + cy.findByRole("button", { name: /save/i }).click(); + + // save modal + cy.get(".query-form__query-save-modal-name") + .click() + .type("Query all window crashes"); + + cy.get(".query-form__query-save-modal-description") + .click() + .type("See all window crashes"); + + cy.findByRole("button", { name: /save query/i }).click(); + + cy.findByText(/query created/i).should("exist"); + cy.findByText(/back to queries/i).should("exist"); + cy.visit("/queries/manage"); + + cy.findByText(/query all/i).click(); + + cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.findByText(/run query/i).should("exist"); + + cy.get(".ace_scroller") + .click({ force: true }) + .type("{selectall}SELECT datetime, username FROM windows_crashes;"); + + cy.findByRole("button", { name: /^Save$/ }).click(); + + cy.findByText(/query updated/i).should("be.visible"); + + // Start e2e test for schedules + cy.visit("/schedule/manage"); + + cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting + + cy.findByRole("button", { name: /schedule a query/i }).click(); + + cy.findByText(/select query/i).click(); + + cy.findByText(/query all window crashes/i).click(); + + cy.get( + ".schedule-editor-modal__form-field--frequency > .dropdown__select" + ).click(); + + cy.findByText(/every week/i).click(); + + cy.findByText(/show advanced options/i).click(); + + cy.get( + ".schedule-editor-modal__form-field--logging > .dropdown__select" + ).click(); + + cy.findByText(/ignore removals/i).click(); + + cy.get(".schedule-editor-modal__form-field--shard > .input-field") + .click() + .type("50"); + + cy.get(".schedule-editor-modal__btn-wrap") + .contains("button", /schedule/i) + .click(); + + cy.visit("/schedule/manage"); + + cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.findByText(/query all window crashes/i).should("exist"); + + cy.findByText(/actions/i).click(); + cy.findByText(/edit/i).click(); + + cy.get( + ".schedule-editor-modal__form-field--frequency > .dropdown__select" + ).click(); + + cy.findByText(/every 6 hours/i).click(); + + cy.findByText(/show advanced options/i).click(); + + cy.findByText(/ignore removals/i).click(); + cy.findByText(/snapshot/i).click(); + + cy.get(".schedule-editor-modal__form-field--shard > .input-field") + .click() + .type("{selectall}{backspace}10"); + + cy.get(".schedule-editor-modal__btn-wrap") + .contains("button", /schedule/i) + .click(); + + cy.visit("/schedule/manage"); + + cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.findByText(/actions/i).click(); + cy.findByText(/remove/i).click(); + + cy.get(".remove-scheduled-query-modal__btn-wrap") + .contains("button", /remove/i) + .click(); + + cy.findByText(/query all window crashes/i).should("not.exist"); + + // End e2e test for schedules + + cy.visit("/queries/manage"); + + cy.findByText(/query all window crashes/i) + .parent() + .parent() + .within(() => { + cy.get(".fleet-checkbox__input").check({ force: true }); + }); + + cy.findByRole("button", { name: /delete/i }).click(); + + // Can't figure out how attach findByRole onto modal button + // Can't use findByText because delete button under modal + cy.get(".remove-query-modal") + .contains("button", /delete/i) + .click(); + + cy.findByText(/successfully removed query/i).should("be.visible"); + + cy.findByText(/query all/i).should("not.exist"); + // On the Packs pages (manage, new, and edit), they should… // ^^General admin functionality for packs page is being tested in app/packflow.spec.ts - // On the Schedule pages (manage, new, and edit), they should… - // ^^General admin functionality for packs page is being tested in app/queryflow.spec.ts - // On the Settings pages, they should… // See the “Teams” navigation item and access the Settings - Teams page cy.visit("/settings/organization"); diff --git a/cypress/integration/premium/team_maintainer_observer.spec.ts b/cypress/integration/premium/team_maintainer_observer.spec.ts index 725af24e4c..87298ae71f 100644 --- a/cypress/integration/premium/team_maintainer_observer.spec.ts +++ b/cypress/integration/premium/team_maintainer_observer.spec.ts @@ -34,13 +34,11 @@ describe( // Nav restrictions cy.findByText(/settings/i).should("not.exist"); - cy.findByText(/schedule/i).should("not.exist"); + cy.findByText(/schedule/i).should("exist"); cy.visit("/settings/organization"); cy.findByText(/you do not have permissions/i).should("exist"); cy.visit("/packs/manage"); cy.findByText(/you do not have permissions/i).should("exist"); - cy.visit("/schedule/manage"); - cy.findByText(/you do not have permissions/i).should("exist"); // NOT see and select "add label" cy.findByRole("button", { name: /new label/i }).should("not.exist"); @@ -120,7 +118,7 @@ describe( cy.get("nav").within(() => { cy.findByText(/hosts/i).should("exist"); cy.findByText(/queries/i).should("exist"); - cy.findByText(/schedule/i).should("not.exist"); + cy.findByText(/schedule/i).should("exist"); cy.findByText(/settings/i).should("not.exist"); }); @@ -140,7 +138,7 @@ describe( // ^^TODO hosts table is not rendering because we need new forEach script/command for admin to assign team after the host is added // See and select the “Add new host” button - cy.findByText(/add new host/i).click(); + cy.findByRole("button", { name: /add new host/i }).click(); // See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click(); @@ -191,6 +189,15 @@ describe( // }); // }); + // On the Schedule page, they should + // See Oranges (team they maintain) only, not able to reach packs, able to schedule a query + cy.visit("/schedule/manage"); + cy.findByText(/oranges/i).click(); + cy.findByText(/apples/i).should("not.exist"); + cy.findByText(/advanced/i).should("not.exist"); + cy.findByRole("button", { name: /schedule a query/i }).click(); + // TODO: Write e2e test for team maintainer to schedule a query + // On the Profile page, they should… // See 2 Teams in the Team section and Various in the Role section cy.visit("/profile"); diff --git a/docs/01-Using-Fleet/09-Permissions.md b/docs/01-Using-Fleet/09-Permissions.md index 2315c56a6a..aadc0c7075 100644 --- a/docs/01-Using-Fleet/09-Permissions.md +++ b/docs/01-Using-Fleet/09-Permissions.md @@ -75,12 +75,15 @@ The following table depicts various permissions levels in a team. | Filter hosts assigned to team using labels | ✅ | ✅ | | Target hosts assigned to team using labels | ✅ | ✅ | | Run saved queries as live queries on hosts assigned to team | ✅ | ✅ | -| Create new team policies | | ✅ | -| Delete team policies | | ✅ | | Run custom queries as live queries on hosts assigned to team | | ✅ | | Enroll hosts to member team | | ✅ | | Delete hosts belonging to member team | | ✅ | | Edit queries they authored | | ✅ | | Delete queries they authored | | ✅ | -| Browse global policies | | ✅ | +| Create new team schedules | | ✅ | +| Delete team schedules | | ✅ | | Browse global schedules | | ✅ | +| Create new team policies | | ✅ | +| Delete team policies | | ✅ | +| Browse global policies | | ✅ | + diff --git a/frontend/components/side_panels/SiteTopNav/SiteTopNav.jsx b/frontend/components/side_panels/SiteTopNav/SiteTopNav.jsx index 497a7867fb..9d3240001c 100644 --- a/frontend/components/side_panels/SiteTopNav/SiteTopNav.jsx +++ b/frontend/components/side_panels/SiteTopNav/SiteTopNav.jsx @@ -48,34 +48,6 @@ class SiteTopNav extends Component { const iconClasses = classnames([`${navItemBaseClass}__icon`]); - let icon = ( - {`${iconName} - ); - if (iconName === "queries") - icon = ( - {`${iconName} - ); - else if (iconName === "packs") - icon = ( - {`${iconName} - ); - else if (iconName === "policies") - icon = ( - {`${iconName} - ); - else if (iconName === "settings") - icon = ( - {`${iconName} - ); - if (iconName === "logo") { return (
  • @@ -88,6 +60,26 @@ class SiteTopNav extends Component {
  • ); } + + const iconImage = () => { + switch (iconName) { + case "hosts": + return HostsIcon; + case "queries": + return QueriesIcon; + case "packs": + return PacksIcon; + case "policies": + return PoliciesIcon; + default: + return AdminIcon; + } + }; + + const icon = ( + {`${iconName} + ); + return (
  • { }, ]; - const globalMaintainerNavItems = [ + const teamMaintainerNavItems = [ { icon: "packs", name: "Schedule", @@ -55,6 +55,9 @@ export default (currentUser) => { pathname: PATHS.MANAGE_SCHEDULE, }, }, + ]; + + const globalMaintainerNavItems = [ { icon: "policies", name: "Policies", @@ -67,11 +70,24 @@ export default (currentUser) => { ]; if (permissionUtils.isGlobalAdmin(currentUser)) { - return [...userNavItems, ...globalMaintainerNavItems, ...adminNavItems]; + return [ + ...userNavItems, + ...teamMaintainerNavItems, + ...globalMaintainerNavItems, + ...adminNavItems, + ]; } if (permissionUtils.isGlobalMaintainer(currentUser)) { - return [...userNavItems, ...globalMaintainerNavItems]; + return [ + ...userNavItems, + ...teamMaintainerNavItems, + ...globalMaintainerNavItems, + ]; + } + + if (permissionUtils.isAnyTeamMaintainer(currentUser)) { + return [...userNavItems, ...teamMaintainerNavItems]; } return userNavItems; diff --git a/frontend/pages/schedule/ManageSchedulePage/ManageSchedulePage.tsx b/frontend/pages/schedule/ManageSchedulePage/ManageSchedulePage.tsx index 84dc6cbb20..a888c4f64f 100644 --- a/frontend/pages/schedule/ManageSchedulePage/ManageSchedulePage.tsx +++ b/frontend/pages/schedule/ManageSchedulePage/ManageSchedulePage.tsx @@ -8,6 +8,7 @@ import { push } from "react-router-redux"; import deepDifference from "utilities/deep_difference"; import { IConfig } from "interfaces/config"; import { IQuery } from "interfaces/query"; +import { IUser } from "interfaces/user"; import { ITeam } from "interfaces/team"; import { IGlobalScheduledQuery } from "interfaces/global_scheduled_query"; import { ITeamScheduledQuery } from "interfaces/team_scheduled_query"; @@ -20,6 +21,7 @@ import queryActions from "redux/nodes/entities/queries/actions"; import teamActions from "redux/nodes/entities/teams/actions"; // @ts-ignore import { renderFlash } from "redux/nodes/notifications/actions"; +import permissionUtils from "utilities/permissions"; import paths from "router/paths"; import Button from "components/buttons/Button"; @@ -41,7 +43,8 @@ const renderTable = ( allScheduledQueriesList: IGlobalScheduledQuery[] | ITeamScheduledQuery[], allScheduledQueriesError: { name: string; reason: string }[], toggleScheduleEditorModal: () => void, - teamId: number + teamId: number, + isTeamMaintainer: boolean ): JSX.Element => { if (Object.keys(allScheduledQueriesError).length !== 0) { return ; @@ -54,12 +57,14 @@ const renderTable = ( allScheduledQueriesList={allScheduledQueriesList} toggleScheduleEditorModal={toggleScheduleEditorModal} teamId={teamId} + isTeamMaintainer={isTeamMaintainer} /> ); }; const renderAllTeamsTable = ( teamId: number, + isTeamMaintainer: boolean, allTeamsScheduledQueriesList: IGlobalScheduledQuery[], allTeamsScheduledQueriesError: { name: string; reason: string }[] ): JSX.Element => { @@ -73,6 +78,7 @@ const renderAllTeamsTable = ( inheritedQueries allScheduledQueriesList={allTeamsScheduledQueriesList} teamId={teamId} + isTeamMaintainer={isTeamMaintainer} /> ); @@ -87,6 +93,9 @@ interface IRootState { app: { config: IConfig; }; + auth: { + user: IUser; + }; entities: { global_scheduled_queries: { isLoading: boolean; @@ -149,6 +158,16 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => { return state.app.config.tier === "premium"; }); + const user = useSelector( + (state: IRootState): IUser => { + return state.auth.user; + } + ); + + const isTeamMaintainer = useSelector((state: IRootState): boolean => { + return permissionUtils.isAnyTeamMaintainer(state.auth.user); + }); + const allQueries = useSelector((state: IRootState) => state.entities.queries); const allQueriesList = Object.values(allQueries.data); @@ -180,21 +199,34 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => { const selectedTeam = isNaN(teamId) ? "global" : teamId; const generateTeamOptionsDropdownItems = (): ITeamOptions[] => { - const teamOptions: ITeamOptions[] = [ - { + const teamOptions: ITeamOptions[] = []; + + if (isTeamMaintainer) { + user.teams.forEach((team) => { + if (team.role === "maintainer") { + teamOptions.push({ + disabled: false, + label: team.name, + value: team.id, + }); + } + }); + } else { + teamOptions.push({ disabled: false, label: "All teams", value: "global", - }, - ]; - - allTeamsList.forEach((team) => { - teamOptions.push({ - disabled: false, - label: team.name, - value: team.id, }); - }); + + allTeamsList.forEach((team) => { + teamOptions.push({ + disabled: false, + label: team.name, + value: team.id, + }); + }); + } + return teamOptions; }; @@ -349,6 +381,15 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => { } }; + if (selectedTeam === "global" && isTeamMaintainer) { + const teamMaintainerTeams = generateTeamOptionsDropdownItems(); + dispatch( + push( + `${paths.MANAGE_TEAM_SCHEDULE(Number(teamMaintainerTeams[0].value))}` + ) + ); + } + return (
    @@ -398,13 +439,15 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => { {allScheduledQueriesList.length !== 0 && allScheduledQueriesError.length !== 0 && (
    - + {!isTeamMaintainer && ( + + )}
    {/* must use ternary for NaN */} @@ -455,6 +499,7 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => { {showInheritedQueries && renderAllTeamsTable( teamId, + isTeamMaintainer, allTeamsScheduledQueriesList, allTeamsScheduledQueriesError )} diff --git a/frontend/pages/schedule/ManageSchedulePage/components/ScheduleListWrapper/ScheduleListWrapper.tsx b/frontend/pages/schedule/ManageSchedulePage/components/ScheduleListWrapper/ScheduleListWrapper.tsx index cb13594834..364da30c52 100644 --- a/frontend/pages/schedule/ManageSchedulePage/components/ScheduleListWrapper/ScheduleListWrapper.tsx +++ b/frontend/pages/schedule/ManageSchedulePage/components/ScheduleListWrapper/ScheduleListWrapper.tsx @@ -33,6 +33,7 @@ interface IScheduleListWrapperProps { toggleScheduleEditorModal?: () => void; teamId: number; inheritedQueries?: boolean; + isTeamMaintainer: boolean; } interface IRootState { entities: { @@ -55,6 +56,7 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => { onEditScheduledQueryClick, teamId, inheritedQueries, + isTeamMaintainer, } = props; const dispatch = useDispatch(); const { MANAGE_PACKS } = paths; @@ -69,8 +71,9 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {

    You don't have any queries scheduled.

    - Schedule a query, or go to your osquery packs via the - ‘Advanced’ button. + {!isTeamMaintainer + ? "Schedule a query, or go to your osquery packs via the ‘Advanced’ button." + : "Schedule a query to run on hosts assigned to this team."}

    - + {!isTeamMaintainer && ( + + )}
    diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index d4080d05b3..46cdcbb449 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -129,6 +129,8 @@ const routes = ( + +