feat: create automatic install policies for fleet-maintained apps (#24298)

> Related issue: #22077

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Jahziel Villasana-Espinoza 2024-12-03 19:55:58 -05:00 committed by GitHub
commit f0e3a5758f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1155 additions and 158 deletions

View file

@ -0,0 +1 @@
- Adds functionality for creating an automatic install policy for Fleet-maintained apps

View file

@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -26,19 +27,19 @@ func (svc *Service) AddFleetMaintainedApp(
appID uint,
installScript, preInstallQuery, postInstallScript, uninstallScript string,
selfService bool,
) error {
) (titleID uint, err error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
return 0, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
return 0, fleet.ErrNoContext
}
app, err := svc.ds.GetMaintainedAppByID(ctx, appID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting maintained app by id")
return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id")
}
// Download installer from the URL
@ -50,13 +51,13 @@ func (svc *Service) AddFleetMaintainedApp(
client := fleethttp.NewClient(fleethttp.WithTimeout(timeout))
installerTFR, filename, err := maintainedapps.DownloadInstaller(ctx, app.InstallerURL, client)
if err != nil {
return ctxerr.Wrap(ctx, err, "downloading app installer")
return 0, ctxerr.Wrap(ctx, err, "downloading app installer")
}
defer installerTFR.Close()
extension, err := maintainedapps.ExtensionForBundleIdentifier(app.BundleIdentifier)
if err != nil {
return ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier)
return 0, ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier)
}
// Validate the bytes we got are what we expected, if homebrew supports
@ -68,11 +69,11 @@ func (svc *Service) AddFleetMaintainedApp(
gotHash := hex.EncodeToString(h.Sum(nil))
if gotHash != app.SHA256 {
return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
return 0, ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
}
if err := installerTFR.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "rewind installer reader")
return 0, ctxerr.Wrap(ctx, err, "rewind installer reader")
}
}
@ -120,12 +121,12 @@ func (svc *Service) AddFleetMaintainedApp(
// Create record in software installers table
_, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "setting downloaded installer")
return 0, ctxerr.Wrap(ctx, err, "setting downloaded installer")
}
// Save in S3
if err := svc.storeSoftware(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "upload maintained app installer to S3")
return 0, ctxerr.Wrap(ctx, err, "upload maintained app installer to S3")
}
// Create activity
@ -133,7 +134,7 @@ func (svc *Service) AddFleetMaintainedApp(
if payload.TeamID != nil && *payload.TeamID != 0 {
t, err := svc.ds.Team(ctx, *payload.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team")
return 0, ctxerr.Wrap(ctx, err, "getting team")
}
teamName = &t.Name
}
@ -145,10 +146,17 @@ func (svc *Service) AddFleetMaintainedApp(
TeamID: payload.TeamID,
SelfService: payload.SelfService,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for added software")
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
}
return nil
// Use the writer for this query; we need the software installer that might have just been
// created above
titleId, err := svc.ds.GetSoftwareTitleIDByMaintainedAppID(ctxdb.RequirePrimary(ctx, true), app.ID, payload.TeamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id")
}
return titleId, nil
}
func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {

View file

@ -212,6 +212,10 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
pending_uninstall: 1,
failed_uninstall: 1,
},
automatic_install_policies: [],
last_install: null,
last_uninstall: null,
package_url: "",
};
export const createMockSoftwarePackage = (

View file

@ -1,23 +1,71 @@
import React from "react";
import { InjectedRouter } from "react-router";
import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import { ISoftwarePackage } from "interfaces/software";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import LinkCell from "../LinkCell";
const baseClass = "software-name-cell";
type InstallType =
| "manual"
| "selfService"
| "automatic"
| "automaticSelfService";
interface installIconConfig {
iconName: IconNames;
tooltip: JSX.Element;
}
const installIconMap: Record<InstallType, installIconConfig> = {
manual: {
iconName: "install",
tooltip: <>Software can be installed on Host details page.</>,
},
selfService: {
iconName: "user",
tooltip: (
<>
End users can install from <b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
automatic: {
iconName: "refresh",
tooltip: <>Software will be automatically installed on each host.</>,
},
automaticSelfService: {
iconName: "automatic-self-service",
tooltip: (
<>
Software will be automatically installed on each host. End users can
reinstall from <b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
};
interface IInstallIconWithTooltipProps {
isSelfService: boolean;
installType?: "manual" | "automatic";
}
const InstallIconWithTooltip = ({
isSelfService,
}: {
isSelfService: ISoftwarePackage["self_service"];
}) => {
installType,
}: IInstallIconWithTooltipProps) => {
let iconType: InstallType = "manual";
if (installType === "automatic") {
iconType = isSelfService ? "automaticSelfService" : "automatic";
} else if (isSelfService) {
iconType = "selfService";
}
const tooltipId = uniqueId();
return (
<div className={`${baseClass}__install-icon-with-tooltip`}>
@ -27,8 +75,9 @@ const InstallIconWithTooltip = ({
data-for={tooltipId}
>
<Icon
name={isSelfService ? "install-self-service" : "install"}
name={installIconMap[iconType].iconName}
className={`${baseClass}__install-icon`}
color="ui-fleet-black-50"
/>
</div>
<ReactTooltip
@ -40,17 +89,7 @@ const InstallIconWithTooltip = ({
data-html
>
<span className={`${baseClass}__install-tooltip-text`}>
{isSelfService ? (
<>
End users can install from <b>Fleet Desktop {">"} Self-service</b>
.
</>
) : (
<>
Install manually on <b>Host details</b> page or automatically with
policy automations.
</>
)}
{installIconMap[iconType].tooltip}
</span>
</ReactTooltip>
</div>
@ -65,6 +104,7 @@ interface ISoftwareNameCellProps {
router?: InjectedRouter;
hasPackage?: boolean;
isSelfService?: boolean;
installType?: "manual" | "automatic";
iconUrl?: string;
}
@ -75,6 +115,7 @@ const SoftwareNameCell = ({
router,
hasPackage = false,
isSelfService = false,
installType,
iconUrl,
}: ISoftwareNameCellProps) => {
// NO path or router means it's not clickable. return
@ -104,7 +145,10 @@ const SoftwareNameCell = ({
<SoftwareIcon name={name} source={source} url={iconUrl} />
<span className="software-name">{name}</span>
{hasPackage && (
<InstallIconWithTooltip isSelfService={isSelfService} />
<InstallIconWithTooltip
isSelfService={isSelfService}
installType={installType}
/>
)}
</>
}

View file

@ -0,0 +1,41 @@
import React from "react";
import classnames from "classnames";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
const baseClass = "tag";
interface ITagProps {
icon: IconNames;
text: string;
className?: string;
onClick?: () => void;
}
const Tag = ({ icon, text, className, onClick }: ITagProps) => {
const classNames = classnames(
baseClass,
className,
onClick && `${baseClass}__clickable-tag`
);
const content = (
<>
<Icon name={icon} size="small" color="ui-fleet-black-75" />
<span className={`${baseClass}__text`}>{text}</span>
</>
);
return onClick ? (
// use a button element so that the tag can be focused and clicked
// with the keyboard
<button className={classNames} onClick={onClick}>
{content}
</button>
) : (
<div className={classNames}>{content}</div>
);
};
export default Tag;

View file

@ -0,0 +1,27 @@
.tag {
display: flex;
height: 18px;
padding: 3px 6px;
align-items: center;
gap: $pad-xsmall;
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
color: $ui-fleet-black-75;
font-size: $xx-small;
font-weight: $bold;
white-space: nowrap;
// styles to override the default <button> element styles for the tag
// when it is clickable
&__clickable-tag {
background: none;
cursor: pointer;
outline: inherit;
box-sizing: inherit;
&:focus {
// this is defined in the Button component styles
@include button-focus-outline;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./Tag";

View file

@ -1,7 +1,7 @@
$base-class: "button";
@mixin button-focus-outline($offset: 2px) {
outline-color: #d9d9fe;
outline-color: $core-focused-outline;
outline-offset: $offset;
outline-style: solid;
outline-width: 2px;

View file

@ -73,7 +73,7 @@ describe("Radio - component", () => {
// Also adds a disabled class to the componet
const radioComponent = screen.getByTestId("radio-input");
expect(radioComponent).toHaveClass("disabled");
expect(radioComponent).toHaveClass("radio__disabled");
});
it("render a tooltip from the tooltip prop", async () => {

View file

@ -16,6 +16,7 @@ export interface IRadioProps {
className?: string;
disabled?: boolean;
tooltip?: React.ReactNode;
helpText?: React.ReactNode;
testId?: string;
}
@ -28,35 +29,39 @@ const Radio = ({
disabled,
label,
tooltip,
helpText,
testId,
onChange,
}: IRadioProps): JSX.Element => {
const wrapperClasses = classnames(baseClass, className, {
[`disabled`]: disabled,
[`${baseClass}__disabled`]: disabled,
});
return (
<label htmlFor={id} className={wrapperClasses} data-testid={testId}>
<span className={`${baseClass}__input`}>
<input
type="radio"
id={id}
disabled={disabled}
name={name}
value={value}
checked={checked}
onChange={(event) => onChange(event.target.value)}
/>
<span className={`${baseClass}__control`} />
</span>
<span className={`${baseClass}__label`}>
{tooltip ? (
<TooltipWrapper tipContent={tooltip}>{label}</TooltipWrapper>
) : (
<>{label}</>
)}
</span>
</label>
<div className={wrapperClasses} data-testid={testId}>
<label htmlFor={id} className={`${baseClass}__radio-control`}>
<span className={`${baseClass}__input`}>
<input
type="radio"
id={id}
disabled={disabled}
name={name}
value={value}
checked={checked}
onChange={(event) => onChange(event.target.value)}
/>
<span className={`${baseClass}__control-button`} />
</span>
<span className={`${baseClass}__label`}>
{tooltip ? (
<TooltipWrapper tipContent={tooltip}>{label}</TooltipWrapper>
) : (
<>{label}</>
)}
</span>
</label>
{helpText && <div className={`${baseClass}__help-text`}>{helpText}</div>}
</div>
);
};

View file

@ -3,8 +3,12 @@
.radio {
font-size: $x-small;
display: flex;
align-items: center;
// this includes the control button and the radio label text
&__radio-control {
display: flex;
align-items: center;
}
&__input {
display: flex;
@ -15,7 +19,7 @@
height: 0;
position: absolute;
& + .radio__control::before {
& + .radio__control-button::before {
position: absolute;
content: "";
width: 10px;
@ -29,17 +33,17 @@
transform: scale(0);
}
&:checked + .radio__control::before {
&:checked + .radio__control-button::before {
transform: scale(1);
}
&:focus + .radio__control {
&:focus + .radio__control-button {
border-color: $core-vibrant-blue;
}
}
}
&__control {
&__control-button {
position: relative;
display: flex;
width: 16px;
@ -53,4 +57,20 @@
margin-left: $pad-small;
line-height: 1;
}
&__help-text {
color: $ui-fleet-black-75;
margin-top: $pad-xxsmall;
margin-left: calc(20px + #{$pad-small});
}
&__disabled {
.radio__label {
color: $ui-fleet-black-50;
}
.radio__help-text {
color: $ui-fleet-black-50;
}
}
}

View file

@ -0,0 +1,32 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IAutomaticSelfServiceProps {
size?: IconSizes;
color?: Colors;
}
const AutomaticSelfService = ({
size = "medium",
color = "ui-fleet-black-75",
}: IAutomaticSelfServiceProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.89811 2.40002C6.44011 2.40052 5.04192 2.97992 4.01092 4.01092C2.97948 5.04237 2.40002 6.4413 2.40002 7.89998C2.40002 8.45227 1.95231 8.89998 1.40002 8.89998C0.84774 8.89998 0.400024 8.45227 0.400024 7.89998C0.400024 5.91087 1.1902 4.00323 2.59671 2.59671C4.00323 1.1902 5.91087 0.400024 7.89998 0.400024H7.90374V0.400032C9.94996 0.407729 11.9151 1.19512 13.4 2.5999L13.4 1.4C13.4 0.847715 13.8478 0.400011 14.4001 0.400024C14.9523 0.400038 15.4 0.847765 15.4 1.40005L15.3999 5.01114C15.3999 5.56341 14.9522 6.01111 14.3999 6.01111L10.7889 6.01111C10.2367 6.01111 9.78894 5.5634 9.78894 5.01111C9.78894 4.45883 10.2367 4.01111 10.7889 4.01111H11.981C10.8713 2.98224 9.4144 2.4062 7.89811 2.40002ZM1.39997 9.78889L5.01106 9.7887C5.56334 9.78867 6.01108 10.2364 6.01111 10.7886C6.01114 11.3409 5.56345 11.7887 5.01117 11.7887L3.81877 11.7888C4.56096 12.4769 5.45849 12.9626 6.42532 13.2106C6.96028 13.3478 7.2827 13.8927 7.14546 14.4277C7.00821 14.9627 6.46329 15.2851 5.92833 15.1478C4.6159 14.8112 3.39922 14.1454 2.40002 13.2002V14.3998C2.40002 14.9521 1.95231 15.3998 1.40002 15.3998C0.84774 15.3998 0.400024 14.9521 0.400024 14.3998V10.7889C0.400024 10.2366 0.847706 9.78892 1.39997 9.78889ZM11.4072 10.3145C11.4072 9.92002 11.727 9.60022 12.1215 9.60022C12.516 9.60022 12.8358 9.92002 12.8358 10.3145C12.8358 10.709 12.516 11.0288 12.1215 11.0288C11.727 11.0288 11.4072 10.709 11.4072 10.3145ZM14.8358 10.3145C14.8358 10.8753 14.6657 11.3964 14.3743 11.829C14.4689 11.9058 14.5599 11.9879 14.6469 12.0748C15.3166 12.7446 15.6929 13.653 15.6929 14.6002C15.6929 15.1525 15.2452 15.6002 14.6929 15.6002C14.1406 15.6002 13.6929 15.1525 13.6929 14.6002C13.6929 14.1835 13.5273 13.7838 13.2326 13.4891C12.9379 13.1944 12.5382 13.0288 12.1215 13.0288C11.7047 13.0288 11.305 13.1944 11.0103 13.4891C10.7156 13.7838 10.55 14.1835 10.55 14.6002C10.55 15.1525 10.1023 15.6002 9.55005 15.6002C8.99776 15.6002 8.55005 15.1525 8.55005 14.6002C8.55005 13.653 8.92632 12.7446 9.5961 12.0748C9.68307 11.9879 9.77406 11.9058 9.86864 11.829C9.57726 11.3964 9.40719 10.8753 9.40719 10.3145C9.40719 8.81545 10.6224 7.60022 12.1215 7.60022C13.6205 7.60022 14.8358 8.81545 14.8358 10.3145Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default AutomaticSelfService;

View file

@ -0,0 +1,30 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IUserProps {
size?: IconSizes;
color?: Colors;
}
const User = ({ size = "medium", color = "ui-fleet-black-75" }: IUserProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.75358 8.66669C2.75358 5.401 5.40094 2.75364 8.66663 2.75364C11.9323 2.75364 14.5797 5.401 14.5797 8.66669C14.5797 9.967 14.1599 11.1693 13.4486 12.1454C13.1902 11.5533 12.8216 11.0087 12.356 10.543C12.0931 10.2802 11.8051 10.0483 11.4976 9.84964C12.1159 9.17002 12.4928 8.26688 12.4928 7.27572C12.4928 5.16263 10.7798 3.44964 8.66671 3.44964C6.55362 3.44964 4.84062 5.16263 4.84062 7.27572C4.84062 8.26688 5.21751 9.17002 5.83579 9.84964C5.5283 10.0483 5.2403 10.2802 4.97746 10.543C4.51174 11.0087 4.14317 11.5534 3.88476 12.1456C3.17335 11.1694 2.75358 9.96706 2.75358 8.66669ZM5.57994 13.7111C6.47843 14.2621 7.53543 14.5797 8.66663 14.5797C9.79788 14.5797 10.8549 14.2621 11.7535 13.711C11.6459 13.0742 11.343 12.4814 10.8803 12.0187C10.2932 11.4316 9.49695 11.1018 8.66671 11.1018C7.83647 11.1018 7.04023 11.4316 6.45316 12.0187C5.99039 12.4815 5.68747 13.0742 5.57994 13.7111ZM8.66663 0.666687C4.24835 0.666687 0.666626 4.24841 0.666626 8.66669C0.666626 13.085 4.24835 16.6667 8.66663 16.6667C13.0849 16.6667 16.6666 13.085 16.6666 8.66669C16.6666 4.24841 13.0849 0.666687 8.66663 0.666687ZM6.92758 7.27572C6.92758 6.31523 7.70621 5.53659 8.66671 5.53659C9.62721 5.53659 10.4058 6.31523 10.4058 7.27572C10.4058 8.23622 9.62721 9.01485 8.66671 9.01485C7.70621 9.01485 6.92758 8.23622 6.92758 7.27572Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default User;

View file

@ -64,6 +64,8 @@ import Refresh from "./Refresh";
import Install from "./Install";
import InstallSelfService from "./InstallSelfService";
import Settings from "./Settings";
import AutomaticSelfService from "./AutomaticSelfService";
import User from "./User";
// a mapping of the usable names of icons to the icon source.
export const ICON_MAP = {
@ -134,6 +136,8 @@ export const ICON_MAP = {
install: Install,
"install-self-service": InstallSelfService,
settings: Settings,
"automatic-self-service": AutomaticSelfService,
user: User,
};
export type IconNames = keyof typeof ICON_MAP;

View file

@ -105,7 +105,6 @@ export interface IPolicyFormData {
team_id?: number | null;
id?: number;
calendar_events_enabled?: boolean;
// undefined from GET/LIST when not set, null for PATCH to unset
software_title_id?: number | null;
// null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script
script_id?: number | null;

View file

@ -55,8 +55,16 @@ export interface ISoftwareTitleVersion {
hosts_count?: number;
}
export interface ISoftwarePackagePolicy {
id: number;
name: string;
}
export interface ISoftwarePackage {
name: string;
last_install: string | null;
last_uninstall: string | null;
package_url: string;
version: string;
uploaded_at: string;
install_script: string;
@ -71,6 +79,7 @@ export interface ISoftwarePackage {
pending_uninstall: number;
failed_uninstall: number;
};
automatic_install_policies?: ISoftwarePackagePolicy[];
install_during_setup?: boolean;
}

View file

@ -4,6 +4,7 @@ import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import RevealButton from "components/buttons/RevealButton";
import Button from "components/buttons/Button";
import Radio from "components/forms/fields/Radio";
import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields";
@ -17,6 +18,7 @@ export interface IFleetMaintainedAppFormData {
preInstallQuery?: string;
postInstallScript?: string;
uninstallScript?: string;
installType: string;
}
export interface IFormValidation {
@ -51,6 +53,7 @@ const FleetAppDetailsForm = ({
installScript: defaultInstallScript,
postInstallScript: defaultPostInstallScript,
uninstallScript: defaultUninstallScript,
installType: "manual",
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: true,
@ -87,6 +90,11 @@ const FleetAppDetailsForm = ({
setFormValidation(generateFormValidation(newData));
};
const onChangeInstallType = (value: string) => {
const newData = { ...formData, installType: value };
setFormData(newData);
};
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
onSubmit(formData);
@ -96,6 +104,38 @@ const FleetAppDetailsForm = ({
return (
<form className={baseClass} onSubmit={onSubmitForm}>
<fieldset>
<legend>Install</legend>
<div className={`${baseClass}__radio-inputs`}>
<Radio
checked={formData.installType === "manual"}
id="manual"
value="manual"
name="install-type"
label="Manual"
onChange={onChangeInstallType}
helpText="Manually install on Host details page for each host."
/>
<Radio
checked={formData.installType === "automatic"}
id="automatic"
value="automatic"
name="install-type"
label="Automatic"
onChange={onChangeInstallType}
helpText={
<>
Automatically install on each host that&apos;s{" "}
<TooltipWrapper tipContent="If the host already has any version of this software, it won't be installed.">
missing this software.
</TooltipWrapper>{" "}
Policy that triggers install can be customized after software is
added.
</>
}
/>
</div>
</fieldset>
<Checkbox
value={formData.selfService}
onChange={onToggleSelfServiceCheckbox}

View file

@ -16,4 +16,22 @@
flex-direction: row;
gap: $pad-large;
}
fieldset {
border: none;
padding: 0;
margin: 0;
legend {
margin-bottom: $pad-small;
font-size: $x-small;
font-weight: $bold;
}
}
&__radio-inputs {
display: flex;
flex-direction: column;
gap: $pad-small;
}
}

View file

@ -7,6 +7,7 @@ import PATHS from "router/paths";
import { buildQueryStringFromParams } from "utilities/url";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import { QueryContext } from "context/query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
@ -29,6 +30,12 @@ import FleetAppDetailsForm from "./FleetAppDetailsForm";
import { IFleetMaintainedAppFormData } from "./FleetAppDetailsForm/FleetAppDetailsForm";
import AddFleetAppSoftwareModal from "./AddFleetAppSoftwareModal";
import {
getFleetAppPolicyDescription,
getFleetAppPolicyName,
getFleetAppPolicyQuery,
} from "./helpers";
const baseClass = "fleet-maintained-app-details-page";
interface ISoftwareSummaryProps {
@ -103,7 +110,7 @@ const FleetMaintainedAppDetailsPage = ({
setShowAddFleetAppSoftwareModal,
] = useState(false);
const { data, isLoading, isError } = useQuery(
const { data: fleetApp, isLoading, isError } = useQuery(
["fleet-maintained-app", appId],
() => softwareAPI.getFleetMainainedApp(appId),
{
@ -131,25 +138,75 @@ const FleetMaintainedAppDetailsPage = ({
setShowAddFleetAppSoftwareModal(true);
const { installType } = formData;
let titleId: number | undefined;
try {
await softwareAPI.addFleetMaintainedApp(parseInt(teamId, 10), {
...formData,
appId,
});
const res = await softwareAPI.addFleetMaintainedApp(
parseInt(teamId, 10),
{
...formData,
appId,
}
);
titleId = res.software_title_id;
// for manual install we redirect only on a successful software add.
if (installType === "manual") {
router.push(
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
team_id: teamId,
available_for_install: true,
})}`
);
renderFlash(
"success",
<>
<b>{fleetApp?.name}</b> successfully added.
</>
);
}
} catch (error) {
// quick exit if there was an error adding the software. Skip the policy
// creation.
renderFlash("error", getErrorReason(error));
setShowAddFleetAppSoftwareModal(false);
return;
}
// If the install type is automatic we now need to create the new policy.
if (installType === "automatic" && fleetApp) {
try {
await teamPoliciesAPI.create({
name: getFleetAppPolicyName(fleetApp.name),
description: getFleetAppPolicyDescription(fleetApp.name),
query: getFleetAppPolicyQuery(fleetApp.name),
team_id: parseInt(teamId, 10),
software_title_id: titleId,
platform: "darwin",
});
renderFlash(
"success",
<>
<b>{fleetApp?.name}</b> successfully added.
</>
);
} catch (e) {
renderFlash(
"error",
"Couldn't add automatic install policy. Software is successfuly added. To try again delete software and add it again.",
{ persistOnPageChange: true }
);
}
// for automatic install we redirect on both a successful and error policy
// add because the software was already successfuly added.
router.push(
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
team_id: teamId,
available_for_install: true,
})}`
);
renderFlash(
"success",
<>
<b>{data?.name}</b> successfully added.
</>
);
} catch (error) {
renderFlash("error", getErrorReason(error)); // TODO: handle error messages
}
setShowAddFleetAppSoftwareModal(false);
@ -168,7 +225,7 @@ const FleetMaintainedAppDetailsPage = ({
return <DataError />;
}
if (data) {
if (fleetApp) {
return (
<>
<BackLink
@ -176,18 +233,18 @@ const FleetMaintainedAppDetailsPage = ({
path={backToAddSoftwareUrl}
className={`${baseClass}__back-to-add-software`}
/>
<h1>{data.name}</h1>
<h1>{fleetApp.name}</h1>
<div className={`${baseClass}__page-content`}>
<FleetAppSummary
name={data.name}
platform={data.platform}
version={data.version}
name={fleetApp.name}
platform={fleetApp.platform}
version={fleetApp.version}
/>
<FleetAppDetailsForm
showSchemaButton={!isSidePanelOpen}
defaultInstallScript={data.install_script}
defaultPostInstallScript={data.post_install_script}
defaultUninstallScript={data.uninstall_script}
defaultInstallScript={fleetApp.install_script}
defaultPostInstallScript={fleetApp.post_install_script}
defaultUninstallScript={fleetApp.uninstall_script}
onClickShowSchema={() => setSidePanelOpen(true)}
onCancel={onCancel}
onSubmit={onSubmit}
@ -205,7 +262,7 @@ const FleetMaintainedAppDetailsPage = ({
<MainContent className={baseClass}>
<>{renderContent()}</>
</MainContent>
{isPremiumTier && data && isSidePanelOpen && (
{isPremiumTier && fleetApp && isSidePanelOpen && (
<SidePanelContent className={`${baseClass}__side-panel`}>
<QuerySidePanel
key="query-side-panel"

View file

@ -0,0 +1,41 @@
import fleetAppData from "../../../../../../server/mdm/maintainedapps/apps.json";
const NameToIdentifierMap: Record<string, string> = {
"1Password": "1password",
"Adobe Acrobat Reader": "adobe-acrobat-reader",
"Box Drive": "box-drive",
Brave: "brave-browser",
"Cloudflare WARP": "cloudflare-warp",
"Docker Desktop": "docker",
Figma: "figma",
"Mozilla Firefox": "firefox",
"Google Chrome": "google-chrome",
"Microsoft Edge": "microsoft-edge",
"Microsoft Excel": "microsoft-excel",
"Microsoft Teams": "microsoft-teams",
"Microsoft Word": "microsoft-word",
Notion: "notion",
Postman: "postman",
Slack: "slack",
TeamViewer: "teamviewer",
"Microsoft Visual Studio Code": "visual-studio-code",
WhatsApp: "whatsapp",
Zoom: "zoom",
};
const getFleetAppData = (name: string) => {
const appId = NameToIdentifierMap[name]; // TODO: need a better matching mechanism here
return fleetAppData.find((app) => app.identifier === appId);
};
export const getFleetAppPolicyName = (appName: string) => {
return `[Install software] ${appName}`;
};
export const getFleetAppPolicyDescription = (appName: string) => {
return `Policy triggers automatic install of ${appName} on each host that's missing this software.`;
};
export const getFleetAppPolicyQuery = (name: string) => {
return getFleetAppData(name)?.automatic_policy_query;
};

View file

@ -0,0 +1,92 @@
import React from "react";
import { ISoftwarePackagePolicy } from "interfaces/software";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import { Link } from "react-router";
const baseClass = "automatic-install-modal";
interface IPoliciesListItemProps {
teamId: number;
policy: ISoftwarePackagePolicy;
}
const PoliciesListItem = ({ teamId, policy }: IPoliciesListItemProps) => {
return (
<li key={policy.id} className={`${baseClass}__list-item`}>
<Link to={`/policies/${policy.id}?team_id=${teamId}`}>{policy.name}</Link>
</li>
);
};
interface IPoliciesListProps {
teamId: number;
policies: ISoftwarePackagePolicy[];
}
const PoliciesList = ({ teamId, policies }: IPoliciesListProps) => {
return (
<ul className={`${baseClass}__list`}>
{policies.map((policy) => (
<PoliciesListItem key={policy.id} teamId={teamId} policy={policy} />
))}
</ul>
);
};
interface IAutomaticInstallModalProps {
teamId: number;
policies: ISoftwarePackagePolicy[];
onExit: () => void;
}
const AutomaticInstallModal = ({
teamId,
policies,
onExit,
}: IAutomaticInstallModalProps) => {
const description =
policies.length > 1 ? (
<>
Software will be installed when hosts fail any of these policies.{" "}
<CustomLink
newTab
text="Learn more"
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
/>
</>
) : (
<>
Software will be installed when hosts fail this policy.{" "}
<CustomLink
newTab
text="Learn more"
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
/>
</>
);
return (
<Modal
className={baseClass}
title="Automatic install"
onExit={onExit}
width="large"
>
<>
<p className={`${baseClass}__description`}>{description}</p>
<PoliciesList teamId={teamId} policies={policies} />
<div className="modal-cta-wrap">
<Button variant="brand" onClick={onExit}>
Done
</Button>
</div>
</>
</Modal>
);
};
export default AutomaticInstallModal;

View file

@ -0,0 +1,23 @@
.automatic-install-modal {
&__description {
margin: 0 0 $pad-large
}
&__list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-medium;
}
&__list-item {
border-bottom: 1px solid $ui-fleet-black-10;
padding: $pad-small $pad-large;
&:last-child {
border-bottom: 0;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./AutomaticInstallModal";

View file

@ -67,7 +67,7 @@ const DeleteSoftwareModal = ({
</p>
<p>
Installs or uninstalls currently running on a host will still
complete, but results wont appear in Fleet.
complete, but results won&apos;t appear in Fleet.
</p>
<p>You cannot undo this action.</p>
<div className="modal-cta-wrap">

View file

@ -22,6 +22,7 @@ import ActionsDropdown from "components/ActionsDropdown";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import Tag from "components/Tag";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import endpoints from "utilities/endpoints";
@ -34,6 +35,7 @@ import {
SOFTWARE_PACKAGE_DROPDOWN_OPTIONS,
downloadFile,
} from "./helpers";
import AutomaticInstallModal from "../AutomaticInstallModal";
const baseClass = "software-package-card";
@ -267,6 +269,9 @@ const SoftwarePackageCard = ({
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState(
false
);
const onEditSoftwareClick = () => {
setShowEditSoftwareModal(true);
@ -342,16 +347,22 @@ const SoftwarePackageCard = ({
</div>
</div>
<div className={`${baseClass}__actions-wrapper`}>
{isSelfService && (
<div className={`${baseClass}__self-service-badge`}>
<Icon
name="install-self-service"
size="small"
color="ui-fleet-black-75"
/>
Self-service
</div>
)}
{softwarePackage?.automatic_install_policies &&
softwarePackage?.automatic_install_policies.length > 0 && (
<TooltipWrapper
showArrow
position="top"
tipContent="Click to see policy that triggers automatic install."
underline={false}
>
<Tag
icon="refresh"
text="Automatic install"
onClick={() => setShowAutomaticInstallModal(true)}
/>
</TooltipWrapper>
)}
{isSelfService && <Tag icon="user" text="Self-service" />}
{showActions && (
<SoftwareActionsDropdown
isSoftwarePackage={!!softwarePackage}
@ -401,6 +412,15 @@ const SoftwarePackageCard = ({
onSuccess={onDeleteSuccess}
/>
)}
{showAutomaticInstallModal &&
softwarePackage?.automatic_install_policies &&
softwarePackage?.automatic_install_policies.length > 0 && (
<AutomaticInstallModal
teamId={teamId}
policies={softwarePackage.automatic_install_policies}
onExit={() => setShowAutomaticInstallModal(false)}
/>
)}
</Card>
);
};

View file

@ -87,21 +87,6 @@
align-items: center;
}
&__self-service-badge {
display: flex;
height: 18px;
padding: 3px 6px;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid $ui-fleet-black-10;
background: $ui-off-white;
color: $ui-fleet-black-75;
font-size: $xx-small;
font-weight: $bold;
white-space: nowrap;
}
&__actions {
@include button-dropdown;
color: $core-fleet-black;

View file

@ -7,11 +7,8 @@ import { RouteComponentProps } from "react-router";
import { AxiosError } from "axios";
import paths from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import {
ISoftwareTitleDetails,
formatSoftwareType,

View file

@ -22,6 +22,10 @@ describe("SoftwareTitleDetailsPage helpers", () => {
},
install_script: "echo foo",
icon_url: "https://example.com/icon.png",
automatic_install_policies: [],
last_install: null,
last_uninstall: null,
package_url: "",
},
app_store_app: null,
source: "apps",

View file

@ -70,10 +70,19 @@ const getSoftwareNameCellData = (
const { software_package, app_store_app } = softwareTitle;
let hasPackage = false;
let isSelfService = false;
let installType: "manual" | "automatic" | undefined;
let iconUrl: string | null = null;
if (software_package) {
hasPackage = true;
isSelfService = software_package.self_service;
if (
software_package.automatic_install_policies &&
software_package.automatic_install_policies.length > 0
) {
installType = "automatic";
} else {
installType = "manual";
}
} else if (app_store_app) {
hasPackage = true;
isSelfService = app_store_app.self_service;
@ -88,6 +97,7 @@ const getSoftwareNameCellData = (
path: softwareTitleDetailsPath,
hasPackage: hasPackage && !isAllTeams,
isSelfService,
installType,
iconUrl,
};
};
@ -117,6 +127,7 @@ const generateTableHeaders = (
router={router}
hasPackage={nameCellData.hasPackage}
isSelfService={nameCellData.isSelfService}
installType={nameCellData.installType}
iconUrl={nameCellData.iconUrl ?? undefined}
/>
);

View file

@ -64,6 +64,7 @@ export default {
resolution,
platform,
critical,
software_title_id,
// note absence of automations-related fields, which are only set by the UI via update
} = data;
const { TEAMS } = endpoints;
@ -76,6 +77,7 @@ export default {
resolution,
platform,
critical,
software_title_id,
});
},
update: (id: number, data: IPolicyFormData) => {

View file

@ -166,3 +166,30 @@ WHERE NOT EXISTS (
return avail, meta, nil
}
// GetSoftwareTitleIDByAppID returns the software title ID related to a given fleet library app ID.
func (ds *Datastore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) {
stmt := `
SELECT
st.id
FROM software_titles st
JOIN software_installers si ON si.title_id = st.id
JOIN fleet_library_apps fla ON fla.id = si.fleet_library_app_id
WHERE fla.id = ? AND si.global_or_team_id = ?`
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
var titleID uint
if err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, appID, globalOrTeamID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "no matching software installer found")
}
return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id")
}
return titleID, nil
}

View file

@ -3,6 +3,7 @@ package mysql
import (
"context"
"os"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -24,6 +25,7 @@ func TestMaintainedApps(t *testing.T) {
{"IngestWithBrew", testIngestWithBrew},
{"ListAvailableApps", testListAvailableApps},
{"GetMaintainedAppByID", testGetMaintainedAppByID},
{"GetSoftwareTitleIdByAppID", testGetSoftwareTitleIdByAppID},
}
for _, c := range cases {
@ -377,3 +379,85 @@ func testGetMaintainedAppByID(t *testing.T, ds *Datastore) {
require.Equal(t, expApp, gotApp)
}
func testGetSoftwareTitleIdByAppID(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Maintained app doesn't exist, should get not found error
_, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, 99, nil)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
app, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "foo",
Token: "token",
Version: "1.0.0",
Platform: "darwin",
InstallerURL: "https://example.com/foo.zip",
SHA256: "sha",
BundleIdentifier: "bundle",
InstallScript: "install",
UninstallScript: "uninstall",
})
require.NoError(t, err)
// Valid maintained app ID, but no installer yet so we should get not found error
_, err = ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
// create a software installer for team and for no team
installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
FleetLibraryAppID: &app.ID,
})
require.NoError(t, err)
_, err = ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: nil,
FleetLibraryAppID: &app.ID,
})
require.NoError(t, err)
// get the software installer metadata as we will need the associated software title id.
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerTm1ID)
require.NoError(t, err)
require.NotNil(t, installer1.TitleID)
stID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, &team1.ID)
require.NoError(t, err)
require.Equal(t, *installer1.TitleID, stID)
stNoTmID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil)
require.NoError(t, err)
require.Equal(t, *installer1.TitleID, stNoTmID)
require.NoError(t, err)
}

View file

@ -465,7 +465,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea
var args []interface{}
query := `
SELECT
SELECT
` + policyCols + `,
COALESCE(u.name, '<deleted>') AS author_name,
COALESCE(u.email, '') AS author_email,
@ -705,7 +705,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op
var args []interface{}
query := `
SELECT
SELECT
` + policyCols + `,
COALESCE(u.name, '<deleted>') AS author_name,
COALESCE(u.email, '') AS author_email,
@ -1473,15 +1473,15 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error {
p.id as policy_id,
t.id AS inherited_team_id,
(
SELECT COUNT(*)
FROM policy_membership pm
INNER JOIN hosts h ON pm.host_id = h.id
SELECT COUNT(*)
FROM policy_membership pm
INNER JOIN hosts h ON pm.host_id = h.id
WHERE pm.policy_id = p.id AND pm.passes = true AND h.team_id = t.id
) AS passing_host_count,
(
SELECT COUNT(*)
FROM policy_membership pm
INNER JOIN hosts h ON pm.host_id = h.id
SELECT COUNT(*)
FROM policy_membership pm
INNER JOIN hosts h ON pm.host_id = h.id
WHERE pm.policy_id = p.id AND pm.passes = false AND h.team_id = t.id
) AS failing_host_count
FROM policies p
@ -1555,12 +1555,12 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error {
SELECT
p.id,
NULL AS inherited_team_id, -- using NULL to represent global scope
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0),
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0),
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 0)), 0)
FROM policies p
LEFT JOIN policy_membership pm ON p.id = pm.policy_id
GROUP BY p.id
ON DUPLICATE KEY UPDATE
ON DUPLICATE KEY UPDATE
updated_at = NOW(),
passing_host_count = VALUES(passing_host_count),
failing_host_count = VALUES(failing_host_count);
@ -1622,7 +1622,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
hostID *uint,
) ([]fleet.HostPolicyMembershipData, error) {
query := `
SELECT
SELECT
COALESCE(sh.email, '') AS email,
COALESCE(pm.passing, 1) AS passing,
COALESCE(pm.failing_policy_ids, '') AS failing_policy_ids,
@ -1640,7 +1640,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
SELECT host_id, MIN(email) AS email
FROM host_emails
JOIN hosts ON host_emails.host_id=hosts.id
WHERE email LIKE CONCAT('%@', ?) AND team_id = ?
WHERE email LIKE CONCAT('%@', ?) AND team_id = ?
GROUP BY host_id
) sh ON h.id = sh.host_id
LEFT JOIN host_display_names hdn ON h.id = hdn.host_id
@ -1663,3 +1663,41 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
return hosts, nil
}
// GetPoliciesBySoftwareTitleID returns the policies that are associated with a set of software titles.
func (ds *Datastore) getPoliciesBySoftwareTitleIDs(
ctx context.Context,
softwareTitleIDs []uint,
teamID *uint,
) ([]fleet.AutomaticInstallPolicy, error) {
if len(softwareTitleIDs) == 0 {
return nil, nil
}
query := `
SELECT
p.id AS id,
p.name AS name,
st.id AS software_title_id
FROM policies p
JOIN software_installers si ON p.software_installer_id = si.id
JOIN software_titles st ON si.title_id = st.id
WHERE st.id IN (?) AND p.team_id = ?
`
var tmID uint
if teamID != nil {
tmID = *teamID
}
query, args, err := sqlx.In(query, softwareTitleIDs, tmID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build select get policies by software id query")
}
var policies []fleet.AutomaticInstallPolicy
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get policies by software installer id")
}
return policies, nil
}

View file

@ -69,6 +69,7 @@ func TestPolicies(t *testing.T) {
{"TestPoliciesNewGlobalPolicyWithScript", testNewGlobalPolicyWithScript},
{"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript},
{"TeamPoliciesNoTeam", testTeamPoliciesNoTeam},
{"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -4999,3 +5000,165 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) {
require.Equal(t, "SELECT 0;", host5PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)])
require.Equal(t, "SELECT 3;", host5PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)])
}
func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
ctx := context.Background()
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
policy1 := newTestPolicy(t, ds, user1, "policy 1", "darwin", &team1.ID)
policy2 := newTestPolicy(t, ds, user1, "policy 2", "darwin", &team2.ID)
// Get policies for an invalid title ID
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{999}, &team1.ID)
require.NoError(t, err)
require.Empty(t, policies)
installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
// Associate an installer to policy 1 on team 1.
installer1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
})
require.NoError(t, err)
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
err = ds.SavePolicy(context.Background(), policy1, false, false)
require.NoError(t, err)
// Associate an installer to policy 2 on team 2.
installer2ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage2",
Filename: "file2",
Title: "file2",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: &team2.ID,
})
require.NoError(t, err)
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
err = ds.SavePolicy(context.Background(), policy2, false, false)
require.NoError(t, err)
// get the software installer metadata as we will need the associated software title ids.
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
require.NoError(t, err)
require.NotNil(t, installer1.TitleID)
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
require.NoError(t, err)
require.NotNil(t, installer2.TitleID)
// software title 1 should have policy 1 when filtering by team 1
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team1.ID)
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, policy1.ID, policies[0].ID)
require.Equal(t, policy1.Name, policies[0].Name)
// software title 1 should not have any policies when filtering by team 2
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team2.ID)
require.NoError(t, err)
require.Len(t, policies, 0)
// software title 2 should have policy 2 when filtering by team 2
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team2.ID)
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, policy2.ID, policies[0].ID)
require.Equal(t, policy2.Name, policies[0].Name)
// software title 2 should not have any policies when filtering by team 1
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team1.ID)
require.NoError(t, err)
require.Len(t, policies, 0)
// software title 2 should not have any policies when filtering by no team
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, nil)
require.NoError(t, err)
require.Len(t, policies, 0)
// Associate a couple of installers to policy 3 on no team.
installer3ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello noteam",
PreInstallQuery: "SELECT 1 from noteam",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage3noteam",
Filename: "file3noteam",
Title: "file3noteam",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: nil,
})
require.NoError(t, err)
installer4ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello noteam",
PreInstallQuery: "SELECT 1 from noteam",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage4noteam",
Filename: "file4noteam",
Title: "file4noteam",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: nil,
})
require.NoError(t, err)
policy3 := newTestPolicy(t, ds, user1, "policy 3", "darwin", ptr.Uint(0))
policy3.SoftwareInstallerID = ptr.Uint(installer3ID)
err = ds.SavePolicy(context.Background(), policy3, false, false)
require.NoError(t, err)
policy4 := newTestPolicy(t, ds, user1, "policy 4", "darwin", ptr.Uint(0))
policy4.SoftwareInstallerID = ptr.Uint(installer4ID)
err = ds.SavePolicy(context.Background(), policy4, false, false)
require.NoError(t, err)
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
require.NoError(t, err)
require.NotNil(t, installer3.TitleID)
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
require.NoError(t, err)
require.NotNil(t, installer3.TitleID)
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, nil)
require.NoError(t, err)
require.Len(t, policies, 2)
expected := map[uint]fleet.AutomaticInstallPolicy{
policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID},
policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID},
}
for _, got := range policies {
require.Equal(t, expected[got.ID], got)
}
// "No team" titles should not have any policies when filtering by team 1
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, ptr.Uint(1))
require.NoError(t, err)
require.Len(t, policies, 0)
}

View file

@ -405,6 +405,12 @@ WHERE
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
}
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
}
dest.AutomaticInstallPolicies = policies
return &dest, nil
}

View file

@ -151,6 +151,7 @@ func (ds *Datastore) ListSoftwareTitles(
if title.PackageVersion != nil {
version = *title.PackageVersion
}
title.SoftwarePackage = &fleet.SoftwarePackageOrApp{
Name: *title.PackageName,
Version: version,
@ -179,6 +180,18 @@ func (ds *Datastore) ListSoftwareTitles(
titleIndex[title.ID] = i
}
// Grab the automatic install policies, if any exist
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, titleIDs, opt.TeamID)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "batch getting policies by software title IDs")
}
for _, p := range policies {
if i, ok := titleIndex[p.TitleID]; ok {
softwareList[i].SoftwarePackage.AutomaticInstallPolicies = append(softwareList[i].SoftwarePackage.AutomaticInstallPolicies, p)
}
}
// we grab matching versions separately and build the desired object in
// the application logic. This is because we need to support MySQL 5.7
// and there's no good way to do an aggregation that builds a structure

View file

@ -1865,6 +1865,9 @@ type Datastore interface {
// CleanUpMDMManagedCertificates removes all managed certificates that are not associated with any host+profile.
CleanUpMDMManagedCertificates(ctx context.Context) error
// GetSoftwareTitleIDByMaintainedAppID returns the software title ID for the given app ID.
GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error)
}
// MDMAppleStore wraps nanomdm's storage and adds methods to deal with

View file

@ -1162,7 +1162,7 @@ type Service interface {
// Fleet-maintained apps
// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error)
// ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team
ListFleetMaintainedApps(ctx context.Context, teamID uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
// GetFleetMaintainedApp returns a Fleet-maintained app by ID

View file

@ -124,6 +124,9 @@ type SoftwareInstaller struct {
URL string `json:"url" db:"url"`
// FleetLibraryAppID is the related Fleet-maintained app for this installer (if not nil).
FleetLibraryAppID *uint `json:"-" db:"fleet_library_app_id"`
// AutomaticInstallPolicies is the list of policies that trigger automatic
// installation of this software.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
}
// SoftwarePackageResponse is the response type used when applying software by batch.
@ -414,6 +417,12 @@ type HostSoftwareWithInstaller struct {
AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"`
}
type AutomaticInstallPolicy struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
TitleID uint `json:"-" db:"software_title_id"`
}
// SoftwarePackageOrApp provides information about a software installer
// package or a VPP app.
type SoftwarePackageOrApp struct {
@ -421,6 +430,9 @@ type SoftwarePackageOrApp struct {
AppStoreID string `json:"app_store_id,omitempty"`
// Name is only present for software installer packages.
Name string `json:"name,omitempty"`
// AutomaticInstallPolicies is only present for Fleet maintained apps
// installed automatically with a policy.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"`
Version string `json:"version"`
SelfService *bool `json:"self_service,omitempty"`

View file

@ -2,12 +2,14 @@
{
"identifier": "1password",
"bundle_identifier": "com.1password.1password",
"installer_format": "zip:app"
"installer_format": "zip:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.1password.1password';"
},
{
"identifier": "adobe-acrobat-reader",
"bundle_identifier": "com.adobe.Reader",
"installer_format": "dmg:pkg"
"installer_format": "dmg:pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.adobe.Reader';"
},
{
"identifier": "box-drive",
@ -20,92 +22,110 @@
"(cd /Users/$LOGGED_IN_USER; defaults delete com.box.desktop)",
"echo \"${LOGGED_IN_USER} ALL = (root) NOPASSWD: /Library/Application\\ Support/Box/uninstall_box_drive_r\" >> /etc/sudoers.d/box_uninstall"
],
"post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"]
"post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"],
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.box.desktop';"
},
{
"identifier": "brave-browser",
"bundle_identifier": "com.brave.Browser",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.brave.Browser';"
},
{
"identifier": "cloudflare-warp",
"bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos",
"installer_format": "pkg",
"post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"]
"post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"],
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.cloudflare.1dot1dot1dot1.macos';"
},
{
"identifier": "docker",
"bundle_identifier": "com.docker.docker",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.docker.docker';"
},
{
"identifier": "figma",
"bundle_identifier": "com.figma.Desktop",
"installer_format": "zip:app"
"installer_format": "zip:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.figma.Desktop';"
},
{
"identifier": "firefox",
"bundle_identifier": "org.mozilla.firefox",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefox';"
},
{
"identifier": "google-chrome",
"bundle_identifier": "com.google.Chrome",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.google.Chrome';"
},
{
"identifier": "microsoft-edge",
"bundle_identifier": "com.microsoft.edgemac",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.edgemac';"
},
{
"identifier": "microsoft-excel",
"bundle_identifier": "com.microsoft.Excel",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Excel';"
},
{
"identifier": "microsoft-teams",
"bundle_identifier": "com.microsoft.teams2",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.teams2';"
},
{
"identifier": "microsoft-word",
"bundle_identifier": "com.microsoft.Word",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Word';"
},
{
"identifier": "notion",
"bundle_identifier": "notion.id",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'notion.id';"
},
{
"identifier": "postman",
"bundle_identifier": "com.postmanlabs.mac",
"installer_format": "zip:app"
"installer_format": "zip:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.postmanlabs.mac';"
},
{
"identifier": "slack",
"bundle_identifier": "com.tinyspeck.slackmacgap",
"installer_format": "dmg:app"
"installer_format": "dmg:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.tinyspeck.slackmacgap';"
},
{
"identifier": "teamviewer",
"bundle_identifier": "com.teamviewer.TeamViewer",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.teamviewer.TeamViewer';"
},
{
"identifier": "visual-studio-code",
"bundle_identifier": "com.microsoft.VSCode",
"installer_format": "zip:app"
"installer_format": "zip:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.VSCode';"
},
{
"identifier": "whatsapp",
"bundle_identifier": "net.whatsapp.WhatsApp",
"installer_format": "zip:app"
"installer_format": "zip:app",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'net.whatsapp.WhatsApp';"
},
{
"identifier": "zoom-for-it-admins",
"bundle_identifier": "us.zoom.xos",
"installer_format": "pkg"
"installer_format": "pkg",
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'us.zoom.xos';"
}
]

View file

@ -1167,6 +1167,8 @@ type GetHostMDMCertificateProfileFunc func(ctx context.Context, hostUUID string,
type CleanUpMDMManagedCertificatesFunc func(ctx context.Context) error
type GetSoftwareTitleIDByMaintainedAppIDFunc func(ctx context.Context, appID uint, teamID *uint) (uint, error)
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@ -2887,6 +2889,9 @@ type DataStore struct {
CleanUpMDMManagedCertificatesFunc CleanUpMDMManagedCertificatesFunc
CleanUpMDMManagedCertificatesFuncInvoked bool
GetSoftwareTitleIDByMaintainedAppIDFunc GetSoftwareTitleIDByMaintainedAppIDFunc
GetSoftwareTitleIDByMaintainedAppIDFuncInvoked bool
mu sync.Mutex
}
@ -6900,3 +6905,10 @@ func (s *DataStore) CleanUpMDMManagedCertificates(ctx context.Context) error {
s.mu.Unlock()
return s.CleanUpMDMManagedCertificatesFunc(ctx)
}
func (s *DataStore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) {
s.mu.Lock()
s.GetSoftwareTitleIDByMaintainedAppIDFuncInvoked = true
s.mu.Unlock()
return s.GetSoftwareTitleIDByMaintainedAppIDFunc(ctx, appID, teamID)
}

View file

@ -15132,7 +15132,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
installerBytes := []byte("abc")
// Mock server to serve the "installers"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/badinstaller":
_, _ = w.Write([]byte("badinstaller"))
@ -15143,7 +15143,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
_, _ = w.Write(installerBytes)
}
}))
defer srv.Close()
defer installerServer.Close()
getSoftwareInstallerIDByMAppID := func(mappID uint) uint {
var id uint
@ -15166,11 +15166,11 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
_, err := h.Write(installerBytes)
require.NoError(t, err)
spoofedSHA := hex.EncodeToString(h.Sum(nil))
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, srv.URL+"/installer.zip")
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, installerServer.URL+"/installer.zip")
require.NoError(t, err)
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", srv.URL+"/badinstaller")
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", installerServer.URL+"/badinstaller")
require.NoError(t, err)
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", srv.URL+"/timeout")
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", installerServer.URL+"/timeout")
return err
})
@ -15352,6 +15352,103 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
postinstall, err = s.ds.GetAnyScriptContents(ctx, *i.PostInstallScriptContentID)
require.NoError(t, err)
require.Equal(t, req.PostInstallScript, string(postinstall))
// ===========================================================================================
// Adding an automatically installed FMA
// ===========================================================================================
// Add another FMA
req = &addFleetMaintainedAppRequest{
AppID: 5,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
}
addMAResp = addFleetMaintainedAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.NoError(t, addMAResp.Err)
require.NotEmpty(t, addMAResp.SoftwareTitleID)
// Add the automatic install policy
tpParams := teamPolicyRequest{
Name: "[Install software]",
Query: "select * from osquery;",
Description: "Some description",
Platform: "darwin",
SoftwareTitleID: &addMAResp.SoftwareTitleID,
}
tpResp := teamPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &tpResp)
require.NotNil(t, tpResp.Policy)
require.NotEmpty(t, tpResp.Policy.ID)
// List software titles; we should see the policy on the software title object
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "2",
"order_key", "id",
"order_direction", "desc",
"available_for_install", "true",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 2)
// most recently added FMA should have 1 automatic install policy
st := resp.SoftwareTitles[0] // sorted by ID above
require.NotNil(t, st.SoftwarePackage)
require.Len(t, st.SoftwarePackage.AutomaticInstallPolicies, 1)
gotPolicy := st.SoftwarePackage.AutomaticInstallPolicies[0]
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
// First FMA added doesn't have automatic install policies
st = resp.SoftwareTitles[1] // sorted by ID above
require.NotNil(t, st.SoftwarePackage)
require.Empty(t, st.SoftwarePackage.AutomaticInstallPolicies)
// Get the specific app that we set to be installed automatically
var titleResp getSoftwareTitleResponse
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
getSoftwareTitleRequest{},
http.StatusOK, &titleResp,
"team_id", "0",
)
require.NotNil(t, titleResp.SoftwareTitle)
swTitle := titleResp.SoftwareTitle
require.NotNil(t, swTitle.SoftwarePackage)
require.Len(t, swTitle.SoftwarePackage.AutomaticInstallPolicies, 1)
gotPolicy = swTitle.SoftwarePackage.AutomaticInstallPolicies[0]
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
// Policy should appear in the list of policies
var listPolResp listTeamPoliciesResponse
s.DoJSON(
"GET", "/api/latest/fleet/teams/0/policies",
listTeamPoliciesRequest{},
http.StatusOK, &listPolResp,
"page", "0",
)
require.Len(t, listPolResp.Policies, 1)
policies := listPolResp.Policies
require.Equal(t, tpResp.Policy.Name, policies[0].Name)
require.Equal(t, tpResp.Policy.ID, policies[0].ID)
require.Equal(t, tpResp.Policy.Description, policies[0].Description)
require.Equal(t, tpResp.Policy.Query, policies[0].Query)
require.Equal(t, "darwin", policies[0].Platform)
require.False(t, policies[0].Critical)
require.NotNil(t, policies[0].InstallSoftware)
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
}
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {

View file

@ -20,7 +20,8 @@ type addFleetMaintainedAppRequest struct {
}
type addFleetMaintainedAppResponse struct {
Err error `json:"error,omitempty"`
SoftwareTitleID uint `json:"software_title_id,omitempty"`
Err error `json:"error,omitempty"`
}
func (r addFleetMaintainedAppResponse) error() error { return r.Err }
@ -29,7 +30,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
req := request.(*addFleetMaintainedAppRequest)
ctx, cancel := context.WithTimeout(ctx, maintainedapps.InstallerTimeout)
defer cancel()
err := svc.AddFleetMaintainedApp(
titleId, err := svc.AddFleetMaintainedApp(
ctx,
req.TeamID,
req.AppID,
@ -46,15 +47,15 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
return &addFleetMaintainedAppResponse{Err: err}, nil
}
return &addFleetMaintainedAppResponse{}, nil
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
}
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error {
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
return 0, fleet.ErrMissingLicense
}
type listFleetMaintainedAppsRequest struct {

View file

@ -175,7 +175,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return nil, ctxerr.Wrap(ctx, err, "checked using a global admin")
}
return nil, fleet.NewPermissionError("Error: You dont have permission to view specified software. It is installed on hosts that belong to team you dont have permissions to view.")
return nil, fleet.NewPermissionError("Error: You don't have permission to view specified software. It is installed on hosts that belong to team you don't have permissions to view.")
}
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
}

View file

@ -159,6 +159,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
require.NoError(t, err)
}
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
return err
})
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)