From bac0d8376a347706c7b06c87e319e00e5a02afd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 08:44:21 -0300 Subject: [PATCH 01/21] Update versions of fleetd components in Fleet's TUF [automated] (#21701) Automated change from [GitHub action](https://github.com/fleetdm/fleet/actions/workflows/fleetd-tuf.yml). Co-authored-by: lucasmrod --- orbit/TUF.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orbit/TUF.md b/orbit/TUF.md index dbf97f184f..36edaecbd6 100644 --- a/orbit/TUF.md +++ b/orbit/TUF.md @@ -18,8 +18,8 @@ Following are the currently deployed versions of fleetd components on the `stabl | Component\OS | macOS | Linux | Windows | Linux (arm64) | |--------------|--------|--------|---------|---------------| -| orbit | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 | -| desktop | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 | +| orbit | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 | +| desktop | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 | | osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 | | nudge | - | - | - | - | | swiftDialog | - | - | - | - | From 6920eedfab7a6697f080ada40064bbbe93182bdf Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Fri, 30 Aug 2024 09:46:33 -0300 Subject: [PATCH 02/21] unreleased fixes for MABM (#21704) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Manual QA for all new/changed functionality --- cmd/fleet/serve.go | 2 ++ ee/server/service/mdm.go | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 5be28feedd..bcdaab9567 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -583,6 +583,8 @@ the way that the Fleet server works. // backfilled tok := &fleet.ABMToken{ EncryptedToken: appleBM.EncryptedToken, + // 2000-01-01 is our "zero value" for time + RenewAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), } _, err = ds.InsertABMToken(context.Background(), tok) if err != nil { diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index c4dd4bbadf..9aeaddd67b 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1271,10 +1271,13 @@ func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOS // validate the team IDs token.MacOSTeam = fleet.ABMTokenTeam{Name: fleet.TeamNameNoTeam} + token.MacOSDefaultTeamID = nil token.IOSTeam = fleet.ABMTokenTeam{Name: fleet.TeamNameNoTeam} + token.IOSDefaultTeamID = nil token.IPadOSTeam = fleet.ABMTokenTeam{Name: fleet.TeamNameNoTeam} + token.IPadOSDefaultTeamID = nil - if macOSTeamID != nil { + if macOSTeamID != nil && *macOSTeamID != 0 { macOSTeam, err := svc.ds.Team(ctx, *macOSTeamID) if err != nil { return nil, &fleet.BadRequestError{ @@ -1288,7 +1291,7 @@ func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOS token.MacOSDefaultTeamID = macOSTeamID } - if iOSTeamID != nil { + if iOSTeamID != nil && *iOSTeamID != 0 { iOSTeam, err := svc.ds.Team(ctx, *iOSTeamID) if err != nil { return nil, &fleet.BadRequestError{ @@ -1301,7 +1304,7 @@ func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOS token.IOSDefaultTeamID = iOSTeamID } - if iPadOSTeamID != nil { + if iPadOSTeamID != nil && *iPadOSTeamID != 0 { iPadOSTeam, err := svc.ds.Team(ctx, *iPadOSTeamID) if err != nil { return nil, &fleet.BadRequestError{ From 182823753c215d35b7406b46147458c09d00b029 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Fri, 30 Aug 2024 08:18:07 -0500 Subject: [PATCH 03/21] Revert "Fleet UI: Update profile activities to not include host platform" (#21678) Reverts fleetdm/fleet#20696 See discussion here: https://fleetdm.slack.com/archives/C01EZVBHFHU/p1724875922881429 --- .../ActivityItem/ActivityItem.tsx | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index dfb8e3b016..0e4bb125fa 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -41,16 +41,19 @@ const PREMIUM_ACTIVITIES = new Set([ const getProfileMessageSuffix = ( isPremiumTier: boolean, + platform: "apple" | "windows", teamName?: string | null ) => { - let messageSuffix = <>hosts; + const platformDisplayName = + platform === "apple" ? "macOS, iOS, and iPadOS" : "Windows"; + let messageSuffix = <>all {platformDisplayName} hosts; if (isPremiumTier) { messageSuffix = teamName ? ( <> - the {teamName} team + {platformDisplayName} hosts assigned to the {teamName} team ) : ( - <>hosts with no team + <>{platformDisplayName} hosts with no team ); } return messageSuffix; @@ -364,7 +367,12 @@ const TAGGED_TEMPLATES = { ) : ( <>a configuration profile )}{" "} - to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} + to{" "} + {getProfileMessageSuffix( + isPremiumTier, + "apple", + activity.details?.team_name + )} . ); @@ -383,7 +391,12 @@ const TAGGED_TEMPLATES = { <>a configuration profile )}{" "} from{" "} - {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. + {getProfileMessageSuffix( + isPremiumTier, + "apple", + activity.details?.team_name + )} + . ); }, @@ -394,6 +407,7 @@ const TAGGED_TEMPLATES = { edited configuration profiles for{" "} {getProfileMessageSuffix( isPremiumTier, + "apple", activity.details?.team_name )}{" "} via fleetctl. @@ -413,7 +427,12 @@ const TAGGED_TEMPLATES = { ) : ( <>a configuration profile )}{" "} - to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} + to{" "} + {getProfileMessageSuffix( + isPremiumTier, + "windows", + activity.details?.team_name + )} . ); @@ -432,7 +451,12 @@ const TAGGED_TEMPLATES = { <>a configuration profile )}{" "} from{" "} - {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. + {getProfileMessageSuffix( + isPremiumTier, + "windows", + activity.details?.team_name + )} + . ); }, @@ -443,6 +467,7 @@ const TAGGED_TEMPLATES = { edited configuration profiles for{" "} {getProfileMessageSuffix( isPremiumTier, + "windows", activity.details?.team_name )}{" "} via fleetctl. @@ -762,7 +787,12 @@ const TAGGED_TEMPLATES = { added declaration (DDM) profile {activity.details?.profile_name} {" "} - to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} + to{" "} + {getProfileMessageSuffix( + isPremiumTier, + "apple", + activity.details?.team_name + )} . ); @@ -773,7 +803,12 @@ const TAGGED_TEMPLATES = { {" "} removed declaration (DDM) profile{" "} {activity.details?.profile_name} from{" "} - {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. + {getProfileMessageSuffix( + isPremiumTier, + "apple", + activity.details?.team_name + )} + . ); }, @@ -783,7 +818,11 @@ const TAGGED_TEMPLATES = { {" "} edited declaration (DDM) profiles{" "} {activity.details?.profile_name} for{" "} - {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}{" "} + {getProfileMessageSuffix( + isPremiumTier, + "apple", + activity.details?.team_name + )}{" "} via fleetctl. ); From 45f4825c0aa19dfcd08cf4be6549767ab360d392 Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:59:28 -0500 Subject: [PATCH 04/21] Fix spacing on product-groups page (#21682) --- handbook/company/product-groups.md | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index 8a21d332ee..fa8bf1bf31 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -1,4 +1,5 @@ # 🛩️ Product groups + This page covers what all contributors (fleeties or not) need to know in order to contribute changes to [the core product](https://fleetdm.com/docs). When creating software, handoffs between teams or contributors are one of the most common sources of miscommunication and waste. Like [GitLab](https://docs.google.com/document/d/1RxqS2nR5K0vN6DbgaBw7SEgpPLi0Kr9jXNGzpORT-OY/edit#heading=h.7sfw1n9c1i2t), Fleet uses product groups to minimize handoffs and maximize iteration and efficiency in the way we build the product. @@ -6,10 +7,14 @@ When creating software, handoffs between teams or contributors are one of the mo > - Write down philosophies and show how the pieces of the development process fit together on this "🛩️ Product groups" page. > - Use the dedicated [departmental](https://fleetdm.com/handbook/company#org-chart) handbook pages for [🚀 Engineering](https://fleetdm.com/handbook/engineering) and [🦢 Product Design](https://fleetdm.com/handbook/product) to keep track of specific, rote responsibilities and recurring rituals designed to be read and used only by people within those departments. + ## Product roadmap + Fleet team members can read [Fleet's high-level product goals for the current quarter](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) (confidential Google Sheet). + ## What are product groups? + Fleet organizes product development efforts into separate, cross-functional product groups that include product designers, developers, and quality engineers. These product groups are organized by business goal, and designed to operate in parallel. Security, performance, stability, scalability, database migrations, release compatibility, usage documentation (such as REST API and configuration reference), contributor experience, and support escalation are the responsibility of every product group. @@ -18,6 +23,7 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) > Ideas expressed in wireframes, like code contributions, [are welcome from everyone](https://chat.osquery.io/c/fleet), inside or outside the company. + ## Current product groups | Product group | Goal _(value for customers and/or community)_ | Capacity\* | @@ -27,7 +33,9 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) \* The number of [estimated story points](https://fleetdm.com/handbook/company/communications#estimation-points) this group can take on per-sprint under ideal circumstances, used as a baseline number for planning and prioritizing user stories for drafting. In reality, capacity will vary as engineers are on-call, out-of-office, filling in for other product groups, etc. + ### Endpoint ops group + The goal of the endpoint ops group is to increase and exceed [Fleet's product maturity goals in the endpoint operations category](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing). | Responsibility | Human(s) | @@ -40,7 +48,9 @@ The goal of the endpoint ops group is to increase and exceed [Fleet's product ma > The [Slack channel](https://fleetdm.slack.com/archives/C01EZVBHFHU), [kanban release board](https://app.zenhub.com/workspaces/-g-endpoint-ops-current-sprint-63bd7e0bf75dba002a2343ac/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-endpoint-ops) for this product group is `#g-endpoint-ops`. + ### MDM group + The goal of the MDM group is to increase and exceed [Fleet's product maturity goals](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing) in the "MDM" product category. | Responsibility | Human(s) | @@ -53,7 +63,9 @@ The goal of the MDM group is to increase and exceed [Fleet's product maturity go > The [Slack channel](https://fleetdm.slack.com/archives/C03C41L5YEL), [kanban release board](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-mdm) for this product group is `#g-mdm`. + ## Making changes + Fleet's highest product ambition is to create experiences that users want. To deliver on this mission, we need a clear, repeatable process for turning an idea into a set of cohesively-designed changes in the product. We also need to allow [open source contributions](https://fleetdm.com/handbook/company#open-source) at any point in the process from the wider Fleet community - these won't necessarily follow this process. @@ -65,14 +77,18 @@ To make a change to Fleet: - Then, it will be [drafted](https://fleetdm.com/handbook/company/product-groups#drafting) (planned). - Next, it will be [implemented](https://fleetdm.com/handbook/company/product-groups#implementing) and [released](https://fleetdm.com/handbook/engineering#release-process). + ### Planned and unplanned changes + Most changes to Fleet are planned changes. They are [prioritized](https://fleetdm.com/handbook/product), defined, designed, revised, estimated, and scheduled into a release sprint _prior to starting implementation_. The process of going from a prioritized goal to an estimated, scheduled, committed user story with a target release is called "drafting", or "the drafting phase". Occasionally, changes are unplanned. Like a patch for an unexpected bug, or a hotfix for a security issue. Or if an open source contributor suggests an unplanned change in the form of a pull request. These unplanned changes are sometimes OK to merge as-is. But if they change the user interface, the CLI usage, or the REST API, then they need to go through drafting and reconsideration before merging. > But wait, [isn't this "waterfall"?](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) Waterfall is something else. Between 2015-2023, GitLab and The Sails Company independently developed and coevolved similar delivery processes. (What we call "drafting" and "implementation" at Fleet, is called "the validation phase" and "the build phase" at GitLab.) + ### Experimental features + When a new feature is introduced it may be labeled as experimental. Experimental features are undergoing a rapid [incremental improvement and iteration process](https://fleetdm.com/handbook/company/why-this-way#why-lean-software-development) where new learnings may requires breaking changes. When we introduce experimental features, it is important that any API endpoints or configuration surface that may change in the future be clearly labeled as experimental. 1. Apply the `~experimental` label to all associated user stories. @@ -81,7 +97,9 @@ When a new feature is introduced it may be labeled as experimental. Experimental > **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + ### Breaking changes + For product changes that cause breaking API or configuration changes or major impact for users (or even just the _impression_ of major impact!), the company plans migration thoughtfully. If the feature was released as stable (not experimental), the product group and E-group: 1. **Written:** Write a migration guide. @@ -92,12 +110,16 @@ For product changes that cause breaking API or configuration changes or major im All of the steps above happen prior to any breaking changes to stable features being prioritized for implementation. + #### API changes + To maintain consistency, ensure perspective, and provide a single pair of eyes in the design of Fleet's REST API and API documentation, there is a single Directly Responsible Individual (DRI). The API design DRI will review and approve any alterations at the pull request stage, instead of making it a prerequisite during drafting of the story. You may tag the DRI in a GitHub issue with draft API specs in place to receive a review and feedback prior to implementation. Receiving a pre-review from the DRI is encouraged if the API changes introduce new endpoints, or substantially change existing endpoints. No API changes are merged without accompanying API documentation and approval from the DRI. The DRI is responsible for ensuring that the API design remains consistent and adequately addresses both standard and edge-case scenarios. The DRI is also the code owner of the API documentation Markdown file. The DRI is committed to reviewing PRs within one business day. In instances where the DRI is unavailable, the Head of Product will act as the substitute code owner and reviewer. + #### Changes to tables' schema + Whenever a PR is proposed for making changes to our [tables' schema](https://fleetdm.com/tables/screenlock)(e.g. to schema/tables/screenlock.yml), it also has to be reflected in our osquery_fleet_schema.json file. The website team will [periodically](https://fleetdm.com/handbook/marketing/website-handbook#rituals) update the json file with the latest changes. If the changes should be deployed sooner, you can generate the new json file yourself by running these commands: @@ -110,14 +132,18 @@ cd website > If a table is added to our ChromeOS extension but it does not exist in osquery or if it is a table added by fleetd, add a note that mentions it, as in this [example](https://github.com/fleetdm/fleet/blob/e95e075e77b683167e86d50960e3dc17045e3c44/schema/tables/mdm.yml#L2). + ### Drafting + "Drafting" is the art of defining a change, designing and shepherding it through the drafting process until it is ready for implementation. The goal of drafting is to deliver software that works every time with less total effort and investment, without making contribution any less fun. By researching and iterating [prior to development](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), we design better product features, crystallize fewer bad, preemptive naming decisions, and achieve better throughput: getting more done in less time. > Fleet's drafting process is focused first and foremost on product development, but it can be used for any kind of change that benefits from planning or a "dry run". For example, imagine you work for a business who has decided to swap out one of your payroll or device management vendors. You will probably need to plan and execute changes to a number of complicated onboarding/offboarding processes. + #### Drafting process + The DRI for defining and drafting issues for a product group is the product manager, with close involvement from the designer and engineering manager. But drafting is a team effort, and all contributors participate. A user story is considered ready for implementation once: @@ -130,19 +156,25 @@ A user story is considered ready for implementation once: > All user stories intended for the next sprint are estimated by the last estimation session before the sprint begins. This makes sure contributors have adequate time to complete the current sprint and provide accurate estimates for the next sprint. + #### Writing a good user story + Good user stories are short, with clear, unambiguous language. - What screen are they looking at? (`As an observer on the host details page…`) - What do they want to do? (`As an observer on the host details page, I want to run a permitted query.`) - Don't get hung up on the "so that I can ________" clause. It is helpful, but optional. - Example: "As an admin I would like to be asked for confirmation before deleting a user so that I do not accidentally delete a user." + #### Is it actually a story? + User stories are small and independently valuable. - Is it small enough? Will this task be likely to fit in 1 sprint when estimated? - Is it valuable enough? Will this task drive business value when released, independent of other tasks? + #### Defining "done" + To successfully deliver a user story, the people working on it need to know what "done" means. Since the goal of a user story is to implement certain changes to the product, the "definition of done" is written and maintained by the product manager. But ultimately, this "definition of done" involves everyone in the product group. We all collectively rely on accuracy of estimations, astuteness of designs, and cohesiveness of changes envisioned in order to deliver on time and without fuss. @@ -163,7 +195,9 @@ Things to consider when writing the "definition of done" for a user story: - **QA:** Changes are tested by hand prior to submitting pull requests. In addition, quality assurance will do an extra QA check prior to considering this story "done". Any special QA notes? - **Follow-through:** Is there anything in particular that we should inform others (people who aren't in this product group) about after this user story is released? For example: communication to specific customers, tips on how best to highlight this in a release post, gotchas, etc. + #### Providing context + User story issues contain an optional section called "Context". This section is optional and hidden by default. It can be included or omitted, as time allows. As Fleet grows as an all-remote company with more asynchronous processes across timezones, we will rely on this section more and more. @@ -181,7 +215,9 @@ Here are some examples of questions that might be helpful to answer: These questions are helpful for the product team when considering what to prioritize. (The act of writing the answers is a lot of the value!) But these answers can also be helpful when users or contributors (including our future selves) have questions about how best to estimate, iterate, or refine. + #### Initiate an air guitar session + Anyone in the product group can initiate an air guitar session. 1. Initiate: Create a user story and add the `~air-guitar` label to indicate that it is going through the air guitar process. Air guitar issues are always intended to be designed right away. If they can't be, the requestor is notified via at-mention in the issue (that person is either the CSM or AE). @@ -205,9 +241,12 @@ Anyone in the product group can initiate an air guitar session. Air guitar sessions are timeboxed to ensure they are fast and focused. Documentation from this process may inform future user stories and can be invaluable when revisiting the idea at a later stage. While the air guitar process is exploratory in nature, it should be thorough enough to provide meaningful insights and data for future decision-making. + ### Implementing + #### Developing from wireframes + Please read carefully and [pay special attention](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach) to UI wireframes. Designs have usually gone through multiple rounds of revisions, but they could easily still be overlooking complexities or edge cases! When you think you've discovered a blocker, here's how to proceed: @@ -226,7 +265,9 @@ At Fleet, we prioritize [iteration](https://fleetdm.com/handbook/company#results After these considerations, if you still think you've found a blocker, alert the [appropriate PM](https://fleetdm.com/handbook/company/product-groups#current-product-groups) so that the user story can be brought back for [expedited drafting](https://fleetdm.com/handbook/product#expedited-drafting). Otherwise, make a [feature request](https://fleetdm.com/handbook/product#intake). + #### Sub-tasks + The simplest way to manage work is to use a single user story issue, then pass it around between contributors/asignees as seldom as possible. But on a case-by-case basis, for particular user stories and teams, it can sometimes be worthwhile to invest additional overhead in creating separate **unestimated sub-task** issues ("sub-tasks"). A user story is estimated to fit within 1 sprint and drives business value when released, independent of other stories. Sub-tasks are not. @@ -241,20 +282,28 @@ Sub-tasks: - are NOT the best place to post GitHub comments (instead, concentrate conversation in the top-level "user story" issue) - will NOT be looked at or QA'd by quality assurance + ## Outages + At Fleet, we consider an outage to be a situation where new features or previously stable features are broken or unusable. - Occurences of outages are tracked in the [Outages](https://docs.google.com/spreadsheets/d/1a8rUk0pGlCPpPHAV60kCEUBLvavHHXbk_L3BI0ybME4/edit#gid=0) spreadsheet. - Fleet encourages embracing the inevitability of mistakes and discourages blame games. - Fleet stresses the critical importance of avoiding outages because they make customers' lives worse instead of better. + ## Scaling Fleet + Fleet, as a Go server, scales horizontally very well. It’s not very CPU or memory intensive. However, there are some specific gotchas to be aware of when implementing new features. Visit our [scaling Fleet page](https://fleetdm.com/handbook/engineering/scaling-fleet) for tips on scaling Fleet as efficiently and effectively as possible. + ## Load testing + The [load testing page](https://fleetdm.com/handbook/engineering/load-testing) outlines the process we use to load test Fleet, and contains the results of our semi-annual load test. + ## Version support + To provide the most accurate and efficient support, Fleet will only target fixes based on the latest released version. In the current version fixes, Fleet will not backport to older releases. Community version supported for bug fixes: **Latest version only** @@ -265,7 +314,9 @@ Premium version supported for bug fixes: **Latest version only** Premium support for support/troubleshooting: **All versions** + ## Release testing + When a release is in testing, QA should use the Slack channel #help-qa to keep everyone aware of issues found. All bugs found should be reported in the channel after creating the bug first. When a critical bug is found, the Fleetie who labels the bug as critical is responsible for following the [critical bug notification process](https://fleetdm.com/handbook/engineering#notify-community-members-about-a-critical-bug) below. @@ -280,7 +331,9 @@ All unreleased bugs are addressed before publishing a release. Released bugs tha - Causes irreversible damage, such as data loss - Introduces a security vulnerability + ### Notify the community about a critical bug + We inform customers and the community about critical bugs immediately so they don’t trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the `#g-mdm` or `#g-endpoint-ops` channel with the filed bug. If the bug finder is not a Fleetie (e.g., a member of the community), then whoever sees the critical bug should raise the alarm. Note that the bug finder here is NOT necessarily the **first** person who sees the bug. If you come across a bug you think is critical, but it has not been escalated, raise the alarm! @@ -295,7 +348,9 @@ When a critical bug is identified, we will then follow the patch release process > After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. + ## Feature fest + To stay in-sync with our customers' needs, Fleet accepts feature requests from customers and community members on a sprint-by-sprint basis in the regular 🎁🗣 Feature Fest meeting. Anyone in the company is invited to submit requests or simply listen in on the 🎁🗣 Feature Fest meeting. Folks from the wider community can also [request an invite](https://fleetdm.com/contact). ### Making a request @@ -303,7 +358,9 @@ To make a feature request or advocate for a feature request from a customer or c Requests are weighed from top to bottom while prioritizing attendee requests. This means that if the individual that added a feature request is not in attendance, the feature request will be discussed towards the end of the call if there's time. + ### How feature requests are evaluated + Digestion of these new product ideas (requests) happens at the **🎁🗣 Feature Fest** meeting. Before the **🎁🗣 Feature Fest** meeting, the [Customer renewals DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) goes through the "Inbox" column and removes customer requests that are not a high priority for the business. Stakeholders will be notified by the Customer renewals DRI. @@ -326,7 +383,9 @@ Requests are weighed by: - How well the request fits within Fleet's product vision and roadmap - Whether the feature seems like it can be designed, estimated, and developed in 6 weeks, given its individual complexity and when combined with other work already accepted + ### After the feature is accepted + After the 🎁🗣 Feature Fest meeting, the Feature prioritization DRI will clear the Feature Fest board as follows: **Prioritized features:** Remove `feature fest` label, add `:product` label, and move the issue to the "Ready" column in the drafting board. The request will then be assigned to a [Product Designer](https://fleetdm.com/handbook/company/product-groups#current-product-groups) during the "Design sprint kick-off" ritual. **Put to the side features:** Remove `feature fest` label and notify the requestor. @@ -363,14 +422,18 @@ You can read our guide to diagnosing issues in Fleet on the [debugging page](htt - [In engineering](https://fleetdm.com/handbook/company/product-groups#in-engineering) - [Awaiting QA](https://fleetdm.com/handbook/company/product-groups#awaiting-qa) + ### All bugs + - [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+is%3Aopen+label%3Abug). - **Bugs opened this week:** This filter returns all "bug" issues opened after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+label%3Abug+created%3A%3E%3DREPLACE_ME_YYYY-MM-DD). - **Bugs closed this week:** This filter returns all "bug" issues closed after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on Github](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+is%3Aclosed+label%3Abug+closed%3A%3E%3DREPLACE_ME_YYYY-MM-DD). + #### Inbox + Quickly reproducing bug reports is a [priority for Fleet](https://fleetdm.com/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks). When a new bug is created using the [bug report form](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title=), it is in the "inbox" state. At this state, the bug review DRI (QA) is responsible for going through the inbox and documenting reproduction steps, asking for more reproduction details from the reporter, or asking the product team for more guidance. QA has **1 business day** to move the bug to the next step (reproduced). @@ -379,7 +442,9 @@ For community-reported bugs, this may require QA to gather more information from Once reproduced, QA documents the reproduction steps in the description and moves it to the reproduced state. If QA or the engineering manager feels the bug report may be expected behavior, or if clarity is required on the intended behavior, it is assigned to the group's product manager. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+label%3Abug+label%3A%3Areproduce+sort%3Acreated-asc+). + #### Reproduced + QA has reproduced the issue successfully. It should now be transferred to engineering. Remove the “reproduce” label, add the following labels: @@ -393,10 +458,14 @@ Once the bug is properly labeled, assign it to the [relevant engineering manager > **Fast track for Fleeties:** Fleeties do not have to wait for QA to reproduce the bug. If you're confident it's reproducible, it's a bug, and the reproduction steps are well-documented, it can be moved directly to the reproduced state. + #### In product drafting (as needed) + If a bug requires input from product the `:product` label is added, the `:release` label is removed, and the PM is assigned to the issue. It will stay in this state until product closes the bug, or removes the `:product` label and assigns to an EM. + #### In engineering + A bug is in engineering after it has been reproduced and assigned to an EM. If a bug meets the criteria for a [critical bug](https://fleetdm.com/handbook/engineering#critical-bugs), the `~critical bug` label is added, and the EM follows the [critical bug notification process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#critical-bug-notification-process). During daily standup, the EM will filter the board to only `:incoming` bugs and review with the team. The EM will remove the `:incoming` label, prioritize the bug in the "Ready" coulmn, unassign themselves, and assign an engineer or leave it unassigned for the first available engineer. @@ -415,13 +484,19 @@ For Endpoint ops support on MDM bugs: Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements). + #### Awaiting QA + Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" column of the relevant product group's sprint board. If the bug is verified as fixed, it is moved to the "Ready for release" column of the sprint board. Otherwise, the remaining issues are noted in a comment, and it is moved back to the "In progress" column of the sprint board. + ## How to reach the developer on-call + Oncall engineers do not need to actively monitor Slack channels, except when called in by the Community or Customer teams. Members of those teams are instructed to `@oncall` in `#help-engineering` to get the attention of the on-call engineer to continue discussing any issues that come up. In some cases, the Community or Customer representative will continue to communicate with the requestor. In others, the on-call engineer will communicate directly (team members should use their judgment and discuss on a case-by-case basis how to best communicate with community members and customers). + ### The developer on-call rotation + See [the internal Google Doc](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) for the engineers in the rotation. Fleet team members can also subscribe to the [shared calendar](https://calendar.google.com/calendar/u/0?cid=Y181MzVkYThiNzMxMGQwN2QzOWEwMzU0MWRkYzc5ZmVhYjk4MmU0NzQ1ZTFjNzkzNmIwMTAxOTllOWRmOTUxZWJhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) for calendar events. @@ -430,7 +505,9 @@ New developers are added to the on-call rotation by their manager after they hav > The on-call rotation may be adjusted with approval from the EMs of any product groups affected. Any changes should be made before the start of the sprint so that capacity can be planned accordingly. + ### Developer on-call responsibilities + - **Second-line response** The on-call developer is a second-line responder to questions raised by customers and community members. @@ -459,7 +536,9 @@ Fleet's documentation for contributors can be found in the [Fleet GitHub repo](h The on-call developer is asked to read, understand, test, correct, and improve at least one doc page per week. Our goal is to 1, ensure accuracy and verify that our deployment guides and tutorials are up to date and work as expected. And 2, improve the readability, consistency, and simplicity of our documentation – with empathy towards first-time users. See [Writing documentation](https://fleetdm.com/handbook/marketing#writing-documentation) for writing guidelines, and don't hesitate to reach out to [#g-digital-experience](https://fleetdm.slack.com/archives/C01GQUZ91TN) on Slack for writing support. A backlog of documentation improvement needs is kept [here](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%22%3Aimprove+documentation%22). + ### Escalations + When the on-call developer is unsure of the answer, they should follow this process for escalation. To achieve quick "first-response" times, you are encouraged to say something like "I don't know the answer and I'm taking it back to the team," or "I think X, but I'm confirming that with the team (or by looking in the code)." @@ -470,7 +549,9 @@ How to escalate: 2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@lukeheath` and provide the information turned up in your research. Please include possibly relevant links (even if you didn't find what you were looking for there). Luke will work with you to craft an appropriate answer or find another team member who can help. + ### Changing of the guard + The on-call developer changes each week on Wednesday. A Slack reminder should notify the on-call of the handoff. Please do the following: @@ -487,7 +568,9 @@ In the Slack reminder thread, the on-call developer includes their retrospective 3. How did you spend the rest of your on-call week? This is a chance to demo or share what you learned. + ## Wireframes + - Showing these principles and ideas, to help remember the pros and cons and conceptualize the above visually. - Figma: [⚗️ Fleet product project](https://www.figma.com/files/project/17318630/%E2%9A%97%EF%B8%8F-Fleet-product?fuid=1234929285759903870) @@ -560,9 +643,12 @@ OPTIONS --host Host specified by hostname, uuid, osquery_host_id or node_key that you want to target. ``` + ## Meetings + ### User story discovery + User story discovery meetings are scheduled as needed to align on large or complicated user stories. Before a discovery meeting is scheduled, the user story must be prioritized for product drafting and go through the design and specification process. When the user story is ready to be estimated, a user story discovery meeting may be scheduled to provide more dedicated, synchronous time for the team to discuss the user story than is available during weekly estimation sessions. All participants are expected to review the user story and associated designs and specifications before the discovery meeting. @@ -582,7 +668,9 @@ All participants are expected to review the user story and associated designs an - Software Engineers: Clarifying questions and implementation details - Product Quality Specialist: Testing plan + ### Design consultation + Design consultations are scheduled as needed with the relevant participants, typically product designers and frontend engineers. It is an opportunity to collaborate and discuss design, implementation, and story requirements. The meeting is scheduled as needed by the product designer or frontend engineer when a user story is in the "Prioritized" column on the [drafting board](https://app.zenhub.com/workspaces/-drafting-ships-in-6-weeks-6192dd66ea2562000faea25c/board). **Participants:** @@ -595,7 +683,9 @@ Design consultations are scheduled as needed with the relevant participants, typ - Discuss design input - Discuss implementation details + ### Design reviews + Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and contributors proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API. Product designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. The Head of Product Design and other participants review the changes quickly and give feedback, and then the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business. @@ -610,12 +700,16 @@ Here are some tips for making this meeting effective: > To allow for asynchronous participation, instead of attending, contributors can alternatively choose to add an agenda item to the "Product design review" meeting with a GitHub link. Then, the Head of Product Design will review during the meeting and provide feedback. Every "Product design review" is recorded and automatically transcribed to a Google Doc so that it is searchable by every Fleet team member. + ### Weekly bug review + QA has weekly check-in with product to go over the inbox items. QA is responsible for proposing “not a bug”, closing due to lack of response (with a nice message), or raising other relevant questions. All requires product agreement QA may also propose that a reported bug is not actually a bug. A bug is defined as “behavior that is not according to spec or implied by spec.” If agreed that it is not a bug, then it's assigned to the relevant product manager to determine its priority. + ### Group weeklies + A chance for deeper, synchronous discussion on topics relevant across product groups like “Frontend weekly”, “Backend weekly”, etc. **Participants:** Anyone who wishes to participate. @@ -625,7 +719,9 @@ A chance for deeper, synchronous discussion on topics relevant across product gr - Review difficult frontend bugs - Write engineering-initiated stories + ### Eng Together + This meeting is to disseminate engineering-wide announcements, promote cohesion across groups within the engineering team, and connect with engineers (and the "engineering-curious") in other departments. Held monthly for one hour. **Participants:** Everyone at the company is welcome to attend. All engineers are asked to attend. The subject matter is focused on engineering. @@ -639,14 +735,18 @@ This meeting is to disseminate engineering-wide announcements, promote cohesion - Social - Structured and/or unstructured social activities + ## Development best practices + - Remember the user. What would you do if you saw that error message? [🔴](https://fleetdm.com/handbook/company#empathy) - Communicate any blockers ASAP in your group Slack channel or standup. [🟠](https://fleetdm.com/handbook/company#ownership) - Think fast and iterate. [🟢](https://fleetdm.com/handbook/company#results) - If it probably works, assume it's still broken. Assume it's your fault. [🔵](https://fleetdm.com/handbook/company#objectivity) - Speak up and have short toes. Write things down to make them complete. [🟣](https://fleetdm.com/handbook/company#openness) + ## Product design conventions + Behind every [wireframe at Fleet](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), there are 3 foundational design principles: - **Use-case first.** Taking advantage of top-level features vs. per-platform options allows us to take advantage of similarities and avoid having two different ways to configure the same thing. @@ -657,13 +757,17 @@ Start off cross-platform for every option, setting, and feature. If we **prove** - **Control the noise.** Bring the needs surface level, tuck away things you don't need by default (when possible, given time). For example, hide Windows controls if there are no Windows devices (based on number of Windows hosts). + ## Scrum at Fleet + Fleet product groups employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence. New tickets are estimated, specified, and prioritized on the roadmap: - [Roadmap](https://app.zenhub.com/workspaces/-roadmap-ships-in-6-weeks-6192dd66ea2562000faea25c/board) + ### Scrum items + Our scrum boards are exclusively composed of four types of scrum items: 1. **User stories**: These are simple and concise descriptions of features or requirements from the user's perspective, marked with the `story` label. They keep our focus on delivering value to our customers. Occasionally, due to ZenHub's ticket sub-task structure, the term "epic" may be seen. However, we treat these as regular user stories. @@ -676,17 +780,23 @@ Our scrum boards are exclusively composed of four types of scrum items: > Our sprint boards do not accommodate any other type of ticket. By strictly adhering to these four types of scrum items, we maintain an organized and focused workflow that consistently adds value for our users. + ## Sprints + Sprints align with Fleet's [3-week release cycle](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence). On the first day of each release, all estimated issues are moved into the relevant section of the new "Release" board, which has a kanban view per group. Sprints are managed in [Zenhub](https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible). To plan capacity for a sprint, [create a "Sprint" issue](https://github.com/fleetdm/confidential/issues/new/choose), replace the fake constants with real numbers, and attach the appropriate labels for your product group. + ### Sprint numbering + Sprints are numbered according to the release version. For example, for the sprint ending on June 30th, 2023, on which date we expect to release Fleet v4.34, the sprint is called the 4.34 sprint. + ### Sprint ceremonies + Each sprint is marked by five essential ceremonies: 1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint. @@ -695,7 +805,9 @@ Each sprint is marked by five essential ceremonies: 4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review the next release. Engineers are allotted 3-10 minutes to showcase features, improvements, and bug fixes they have contributed to the upcoming release. We focus on changes that can be demoed live and avoid overly technical details so the presentation is accessible to everyone. Features should show what is capable and bugs should identify how this might have impacted existing customers and how this resolution fixed that. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).) 5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint. + ## Outside contributions + [Anyone can contribute](https://fleetdm.com/handbook/company#openness) at Fleet, from inside or outside the company. Since contributors from the wider community don't receive a paycheck from Fleet, they work on whatever they want. Many open source contributions that start as a small, seemingly innocuous pull request come with lots of additional [unplanned work](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) down the road: unforseen side effects, documentation, testing, potential breaking changes, database migrations, [and more](https://fleetdm.com/handbook/company/development-groups#defining-done). From 1d7d84022c0d8735fca1ee45b99b8fdfca15cd04 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Fri, 30 Aug 2024 17:02:49 +0100 Subject: [PATCH 05/21] fix selected team names overflowing in vpp edit teams modal dropdown (#21710) relates to #21700 This fixes an issue where the selected team names were overflowing in the dropdown in vpp edit teams modal. This makes it so they now wrap. **Before** ![image](https://github.com/user-attachments/assets/c69f1855-95ef-4eb0-9ade-8a67c2b44e70) **After** ![image](https://github.com/user-attachments/assets/c922a587-35fe-41c5-ba8f-976084c0103c) > NOTE: I'm not sure why the top padding in the dropdown is removed when the text wraps and still need to figure that part out but I wanted to get this in to unblock the release. - [x] Manual QA for all new/changed functionality --- .../components/EditTeamsVppModal/EditTeamsVppModal.tsx | 5 +++-- .../VppPage/components/EditTeamsVppModal/_styles.scss | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx index 8635499a0c..029380e60e 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx @@ -209,7 +209,7 @@ const EditTeamsVppModal = ({ showArrow tipContent={
- You can’t choose teams because you already have a VPP token + You can't choose teams because you already have a VPP token assigned to all teams. First, edit teams for that VPP token to choose teams here.
@@ -223,6 +223,7 @@ const EditTeamsVppModal = ({ placeholder="Search teams" value={selectedValue} label="Teams" + className={`${baseClass}__vpp-dropdown`} wrapperClassName={`${baseClass}__form-field--vpp-teams ${ isDropdownDisabled ? `${baseClass}__form-field--disabled` : "" }`} @@ -230,7 +231,7 @@ const EditTeamsVppModal = ({ isDropdownDisabled ? undefined : ( <> Each team can have only one VPP token. Teams that already - have a VPP token won’t show up here. + have a VPP token won't show up here. ) } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss index 9b6f508dc1..72ed9e4a10 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss @@ -2,6 +2,13 @@ .component__tooltip-wrapper__element { width: 100%; // default component style was causing the select box not to be full width } + + // this is needed to wrap the selected team names in that are displayed + // in the dropdown select box. + .dropdown__select { + text-wrap: wrap; + } + // styles needed to make select look like figma design when disabled, // default styles in the Dropdown component were not enough &__form-field--disabled { From fcdda20664c75c9db61725174dc4f05469020504 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 30 Aug 2024 14:13:25 -0300 Subject: [PATCH 06/21] Backend for policy automation to install software (#21650) #21428 - [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] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [X] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality --- .../21428-policy-automatic-install-software | 1 + cmd/fleet/serve.go | 19 + ee/server/service/software_installers.go | 15 + server/datastore/mysql/activities.go | 7 +- server/datastore/mysql/activities_test.go | 2 + server/datastore/mysql/hosts_test.go | 1 + ...29170024_PolicyAutomaticInstallSoftware.go | 37 ++ server/datastore/mysql/policies.go | 38 +- server/datastore/mysql/policies_test.go | 133 +++- server/datastore/mysql/schema.sql | 14 +- server/datastore/mysql/scripts_test.go | 4 + server/datastore/mysql/software_installers.go | 83 ++- .../mysql/software_installers_test.go | 218 +++++++ server/datastore/mysql/software_test.go | 113 ++-- server/datastore/mysql/software_titles.go | 10 +- .../datastore/mysql/software_titles_test.go | 21 + server/datastore/mysql/teams.go | 9 +- server/fleet/datastore.go | 7 +- server/fleet/policies.go | 76 ++- server/fleet/service.go | 2 +- server/fleet/software.go | 1 + server/fleet/software_installer.go | 17 + server/mock/datastore_mock.go | 30 +- server/service/activities.go | 7 +- server/service/global_policies.go | 11 +- server/service/integration_core_test.go | 4 + server/service/integration_enterprise_test.go | 613 +++++++++++++++++- server/service/orbit.go | 28 +- server/service/osquery.go | 135 ++++ server/service/team_policies.go | 131 +++- server/service/team_policies_test.go | 7 +- .../testdata/software-installers/README.md | 3 + .../software-installers/fleet-osquery.msi | Bin 0 -> 252416 bytes .../software-installers/no_version.pkg | Bin 0 -> 846 bytes server/service/testing_client.go | 27 +- server/test/new_objects.go | 6 + 36 files changed, 1736 insertions(+), 94 deletions(-) create mode 100644 changes/21428-policy-automatic-install-software create mode 100644 server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go create mode 100644 server/service/testdata/software-installers/README.md create mode 100644 server/service/testdata/software-installers/fleet-osquery.msi create mode 100644 server/service/testdata/software-installers/no_version.pkg diff --git a/changes/21428-policy-automatic-install-software b/changes/21428-policy-automatic-install-software new file mode 100644 index 0000000000..e61dc2a9ea --- /dev/null +++ b/changes/21428-policy-automatic-install-software @@ -0,0 +1 @@ +* Added automatic installation of software packages using policy automations. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index bcdaab9567..348f20ed42 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -126,6 +126,10 @@ the way that the Fleet server works. logger := initLogger(config) + if dev { + createTestBucketForInstallers(&config, logger) + } + // Init tracing if config.Logging.TracingEnabled { ctx := context.Background() @@ -1406,3 +1410,18 @@ var _ push.Pusher = nopPusher{} func (n nopPusher) Push(context.Context, []string) (map[string]*push.Response, error) { return nil, nil } + +func createTestBucketForInstallers(config *configpkg.FleetConfig, logger log.Logger) { + store, err := s3.NewSoftwareInstallerStore(config.S3) + if err != nil { + initFatal(err, "initializing S3 software installer store") + } + if err := store.CreateTestBucket(config.S3.SoftwareInstallersBucket); err != nil { + // Don't panic, allow devs to run Fleet without minio/S3 dependency. + level.Info(logger).Log( + "err", err, + "msg", "failed to create test bucket", + "name", config.S3.SoftwareInstallersBucket, + ) + } +} diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 153b0e76ba..eca4166129 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -37,6 +37,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. if !ok { return fleet.ErrNoContext } + payload.UserID = vc.UserID() // make sure all scripts use unix-style newlines to prevent errors when // running them, browsers use windows-style newlines, which breaks the @@ -629,6 +630,14 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f } return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer") } + + if meta.Version == "" { + return "", &fleet.BadRequestError{ + Message: fmt.Sprintf("Couldn't add. Fleet couldn't read the version from %s.", payload.Filename), + InternalErr: ctxerr.New(ctx, "extracting version from installer metadata"), + } + } + payload.Title = meta.Name if payload.Title == "" { // use the filename if no title from metadata @@ -686,6 +695,11 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin return ctxerr.Wrap(ctx, err, "validating authorization") } + vc, ok := viewer.FromContext(ctx) + if !ok { + return fleet.ErrNoContext + } + g, workerCtx := errgroup.WithContext(ctx) g.SetLimit(3) // critical to avoid data race, the slice is pre-allocated and each @@ -762,6 +776,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin PostInstallScript: p.PostInstallScript, InstallerFile: bytes.NewReader(bodyBytes), SelfService: p.SelfService, + UserID: vc.UserID(), } // set the filename before adding metadata, as it is used as fallback diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index d4cf76f3cc..7ea67b5504 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -31,7 +31,12 @@ func (ds *Datastore) NewActivity( var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index be87524c05..4c5e4077d2 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -401,6 +401,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: u.ID, }) require.NoError(t, err) sw2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -411,6 +412,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "bar", Source: "apps", Version: "0.0.2", + UserID: u.ID, }) require.NoError(t, err) sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index a1008853e3..2edc5ab393 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6751,6 +6751,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { InstallScript: "", PreInstallQuery: "", Title: "ChocolateRain", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false) diff --git a/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go b/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go new file mode 100644 index 0000000000..3d4beb6fbe --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170024, Down_20240829170024) +} + +func Up_20240829170024(tx *sql.Tx) error { + if _, err := tx.Exec(` + ALTER TABLE policies + ADD COLUMN software_installer_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY fk_policies_software_installer_id (software_installer_id) REFERENCES software_installers (id); + `); err != nil { + return fmt.Errorf("failed to add software_installer_id to policies: %w", err) + } + + // We store `user_name` and `user_email` in case the user is deleted from Fleet (`user_id` set to NULL). + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL, + ADD COLUMN user_name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD COLUMN user_email VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD CONSTRAINT fk_software_installers_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL; + `); err != nil { + return fmt.Errorf("failed to add user_id to software_installers: %w", err) + } + + return nil +} + +func Down_20240829170024(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 4df1f9324a..1ee3c2f2c9 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -22,12 +22,18 @@ import ( const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, - p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled + p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, + p.calendar_events_enabled, p.software_installer_id ` +var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be set on team policies only") + var policySearchColumns = []string{"p.name"} func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { + if args.SoftwareInstallerID != nil { + return nil, ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy") + } if args.QueryID != nil { q, err := ds.Query(ctx, *args.QueryID) if err != nil { @@ -129,15 +135,18 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite // // Currently, SavePolicy does not allow updating the team of an existing policy. func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error { + if p.TeamID == nil && p.SoftwareInstallerID != nil { + return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "save policy") + } // We must normalize the name for full Unicode support (Unicode equivalence). p.Name = norm.NFC.String(p.Name) sql := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := ds.writer(ctx).ExecContext( - ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID, + ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -484,7 +493,8 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl COALESCE(u.email, '') AS author_email, ps.updated_at as host_count_updated_at, COALESCE(ps.passing_host_count, 0) as passing_host_count, - COALESCE(ps.failing_host_count, 0) as failing_host_count + COALESCE(ps.failing_host_count, 0) as failing_host_count, + p.software_installer_id FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id @@ -601,11 +611,11 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u nameUnicode := norm.NFC.String(args.Name) res, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, - args.CalendarEventsEnabled, + args.CalendarEventsEnabled, args.SoftwareInstallerID, ) switch { case err == nil: @@ -1429,6 +1439,22 @@ func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fl return policies, nil } +func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + if len(policyIDs) == 0 { + return nil, nil + } + query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);` + query, args, err := sqlx.In(query, teamID, policyIDs) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer") + } + var policies []fleet.PolicySoftwareInstallerData + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies with associated installer") + } + return policies, nil +} + func (ds *Datastore) GetTeamHostsPolicyMemberships( ctx context.Context, domain string, diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index d3b9bdb93a..32db698265 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -1,6 +1,7 @@ package mysql import ( + "bytes" "context" "crypto/md5" //nolint:gosec // (only used for tests) "encoding/hex" @@ -63,6 +64,8 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNameSort", testPoliciesNameSort}, {"TestGetCalendarPolicies", testGetCalendarPolicies}, {"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships}, + {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, + {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1219,9 +1222,29 @@ func testPolicyQueriesForHost(t *testing.T, ds *Datastore) { func testPoliciesByID(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) policy1 := newTestPolicy(t, ds, user1, "policy1", "darwin", nil) - _ = newTestPolicy(t, ds, user1, "policy2", "darwin", nil) + team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + policy2 := newTestPolicy(t, ds, user1, "policy2", "darwin", &team1.ID) host1 := newTestHostWithPlatform(t, ds, "host1", "darwin", nil) + // Associate an installer to policy2 + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), host1, map[uint]*bool{policy1.ID: ptr.Bool(true)}, time.Now(), false)) require.NoError(t, ds.UpdateHostPolicyCounts(context.Background())) @@ -1230,9 +1253,12 @@ func testPoliciesByID(t *testing.T, ds *Datastore) { assert.Equal(t, len(policiesByID), 2) assert.Equal(t, policiesByID[1].ID, policy1.ID) assert.Equal(t, policiesByID[1].Name, policy1.Name) + assert.Nil(t, policiesByID[1].SoftwareInstallerID) + assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) assert.Equal(t, policiesByID[2].ID, uint(2)) assert.Equal(t, policiesByID[2].Name, "policy2") - assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) + assert.NotNil(t, policiesByID[2].SoftwareInstallerID) + assert.Equal(t, uint(1), *policiesByID[2].SoftwareInstallerID) _, err = ds.PoliciesByID(context.Background(), []uint{1, 2, 3}) require.Error(t, err) @@ -3875,3 +3901,106 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) } + +func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) { + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{ + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(1), + }) + require.Error(t, err) + require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) +} + +func testTeamPoliciesWithInstaller(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"}) // team2 has no policies + require.NoError(t, err) + + // Policy p1 has no associated installer. + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: nil, + }) + require.NoError(t, err) + // Create and associate an installer to p2. + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + require.Nil(t, p1.SoftwareInstallerID) + p2, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p2", + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + // Create p3 as global policy. + _, err = ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "p3", + Query: "SELECT 1;", + }) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + // p1 has no associated installers. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 1) + require.Equal(t, p2.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + + // p2 has associated installer but belongs to team1. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + p1.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(ctx, p1, false, false) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 2) + require.Equal(t, p1.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + require.Equal(t, p2.ID, policiesWithInstallers[1].ID) + require.Equal(t, installerID, policiesWithInstallers[1].InstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 0055f0de1d..1a5d7cb3fa 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1030,9 +1030,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=307 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=308 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170024,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1380,11 +1380,14 @@ CREATE TABLE `policies` ( `critical` tinyint(1) NOT NULL DEFAULT '0', `checksum` binary(16) NOT NULL, `calendar_events_enabled` tinyint unsigned NOT NULL DEFAULT '0', + `software_installer_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), KEY `idx_policies_team_id` (`team_id`), + KEY `fk_policies_software_installer_id` (`software_installer_id`), CONSTRAINT `policies_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`), CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -1667,6 +1670,9 @@ CREATE TABLE `software_installers` ( `storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `self_service` tinyint(1) NOT NULL DEFAULT '0', + `user_id` int unsigned DEFAULT NULL, + `user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), @@ -1674,10 +1680,12 @@ CREATE TABLE `software_installers` ( KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`), KEY `fk_software_installers_team_id` (`team_id`), KEY `idx_software_installers_platform_title_id` (`platform`,`title_id`), + KEY `fk_software_installers_user_id` (`user_id`), CONSTRAINT `fk_software_installers_install_script_content_id` FOREIGN KEY (`install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_post_install_script_content_id` FOREIGN KEY (`post_install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_team_id` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE + CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_software_installers_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index b3ccd430a4..5543a7661d 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -1138,6 +1138,8 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { s, err := ds.NewScript(ctx, s) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Bob", "bob@example.com", true) + // create a sync script execution res, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ScriptContents: "echo something_else", SyncRequest: true}) require.NoError(t, err) @@ -1153,6 +1155,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -1207,6 +1210,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 31b6ecb60b..a43c3367ad 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -117,8 +117,11 @@ INSERT INTO software_installers ( pre_install_query, post_install_script_content_id, platform, - self_service -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + self_service, + user_id, + user_name, + user_email +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))` args := []interface{}{ tid, @@ -132,6 +135,9 @@ INSERT INTO software_installers ( postInstallScriptID, payload.Platform, payload.SelfService, + payload.UserID, + payload.UserID, + payload.UserID, } res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) @@ -210,7 +216,8 @@ SELECT si.pre_install_query, si.post_install_script_content_id, si.uploaded_at, - COALESCE(st.name, '') AS software_title + COALESCE(st.name, '') AS software_title, + si.platform FROM software_installers si LEFT OUTER JOIN software_titles st ON st.id = si.title_id @@ -277,9 +284,21 @@ WHERE return &dest, nil } +var errDeleteInstallerWithAssociatedPolicy = errors.New("Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.") + func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id) if err != nil { + if isMySQLForeignKey(err) { + // Check if the software installer is referenced by a policy automation. + var count int + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM policies WHERE software_installer_id = ?`, id); err != nil { + return ctxerr.Wrapf(ctx, err, "getting reference from policies") + } + if count > 0 { + return ctxerr.Wrap(ctx, errDeleteInstallerWithAssociatedPolicy, "delete software installer") + } + } return ctxerr.Wrap(ctx, err, "delete software installer") } @@ -345,8 +364,11 @@ SELECT hsi.user_id AS user_id, hsi.post_install_script_exit_code, hsi.install_script_exit_code, - hsi.self_service, - hsi.host_deleted_at + hsi.self_service, + hsi.host_deleted_at, + si.user_id AS software_installer_user_id, + si.user_name AS software_installer_user_name, + si.user_email AS software_installer_user_email FROM host_software_installs hsi JOIN software_installers si ON si.id = hsi.software_installer_id @@ -485,6 +507,41 @@ WHERE }) } +func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { + stmt := fmt.Sprintf(` + SELECT execution_id, %s AS status + FROM host_software_installs hsi + WHERE hsi.id = ( + SELECT + MAX(id) + FROM host_software_installs + WHERE + software_installer_id = :installer_id AND host_id = :host_id + GROUP BY + host_id, software_installer_id) +`, softwareInstallerHostStatusNamedQuery("hsi", "")) + + stmt, args, err := sqlx.Named(stmt, map[string]interface{}{ + "host_id": hostID, + "installer_id": installerID, + "software_status_installed": fleet.SoftwareInstallerInstalled, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_pending": fleet.SoftwareInstallerPending, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build named query to get host last install data") + } + + var hostLastInstall fleet.HostLastInstallData + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get host last install data") + } + return &hostLastInstall, nil +} + func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error { if softwareInstallStore == nil { // no-op in this case, possible if not running with a Premium license @@ -547,10 +604,14 @@ INSERT INTO software_installers ( post_install_script_content_id, platform, self_service, - title_id + title_id, + user_id, + user_name, + user_email ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '') + (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), + ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?) ) ON DUPLICATE KEY UPDATE install_script_content_id = VALUES(install_script_content_id), @@ -560,7 +621,10 @@ ON DUPLICATE KEY UPDATE version = VALUES(version), pre_install_query = VALUES(pre_install_query), platform = VALUES(platform), - self_service = VALUES(self_service) + self_service = VALUES(self_service), + user_id = VALUES(user_id), + user_name = VALUES(user_name), + user_email = VALUES(user_email) ` // use a team id of 0 if no-team @@ -634,6 +698,9 @@ ON DUPLICATE KEY UPDATE installer.SelfService, installer.Title, installer.Source, + installer.UserID, + installer.UserID, + installer.UserID, } if _, err := tx.ExecContext(ctx, insertNewOrEditedInstaller, args...); err != nil { diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 2b589708f3..0e1a65e0c3 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -31,6 +31,8 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, + {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy}, + {"GetHostLastInstallData", testGetHostLastInstallData}, } for _, c := range cases { @@ -46,6 +48,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello", @@ -57,6 +60,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -70,6 +74,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Title: "file2", Version: "2.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -84,6 +89,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Version: "3.0", Source: "apps", SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) @@ -169,6 +175,8 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + cases := map[string]*uint{ "no team": nil, "team": &team.ID, @@ -188,6 +196,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { InstallScript: "echo", TeamID: teamID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -249,6 +258,8 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { require.NoError(t, err) teamID := team.ID + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + for _, tc := range []struct { name string expectedStatus fleet.SoftwareInstallerStatus @@ -295,6 +306,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { InstallScript: "echo " + tc.name, TeamID: &teamID, Filename: swFilename, + UserID: user1.ID, }) require.NoError(t, err) host, err := ds.NewHost(ctx, &fleet.Host{ @@ -342,6 +354,8 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { store, err := filesystem.NewSoftwareInstallerStore(dir) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + assertExisting := func(want []string) { dirEnts, err := os.ReadDir(filepath.Join(dir, "software-installers")) require.NoError(t, err) @@ -373,6 +387,7 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "installer0", Title: "ins0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -403,6 +418,8 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // TODO(roberto): perform better assertions, we should have evertything // to check that the actual values of everything match. assertSoftware := func(wantTitles []fleet.SoftwareTitle) { @@ -442,6 +459,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "foo", + UserID: user1.ID, }}) require.NoError(t, err) assertSoftware([]fleet.SoftwareTitle{ @@ -461,6 +479,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, }, { InstallScript: "install", @@ -472,6 +491,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, }, }) require.NoError(t, err) @@ -492,6 +512,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, }, }) require.NoError(t, err) @@ -509,6 +530,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor ctx := context.Background() team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foo", @@ -518,10 +540,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor PreInstallQuery: "SELECT 1", TeamID: &team.ID, Filename: "foo.pkg", + Platform: "darwin", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) require.NoError(t, err) + require.Equal(t, "darwin", installerMeta.Platform) metaByTeamAndTitle, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, *installerMeta.TitleID, true) require.NoError(t, err) @@ -536,6 +561,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor InstallScript: "echo install", TeamID: &team.ID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -554,6 +580,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) const platform = "linux" // No installers @@ -573,6 +600,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo.pkg", Platform: platform, SelfService: false, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -591,6 +619,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo2.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -629,6 +658,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo global.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil) @@ -649,3 +679,191 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, hasSelfService) } + +func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) { + ctx := context.Background() + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + softwareInstallerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins0", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: &softwareInstallerID, + }) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.NoError(t, err) +} + +func testGetHostLastInstallData(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithTeamID(team1.ID)) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithTeamID(team1.ID)) + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + softwareInstallerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins1", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + softwareInstallerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install2", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer2.pkg", + Title: "ins2", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + // No installations on host1 yet. + host1LastInstall, err := ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Install installer.pkg on host1. + installUUID1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID1) + + // Last installation should be pending. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Set result of last installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID1, + + InstallScriptExitCode: ptr.Int(0), + }) + require.NoError(t, err) + + // Last installation should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status) + + // Install installer2.pkg on host1. + installUUID2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID2, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID2) + + // Last installation for installer1.pkg should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status) + // Last installation for installer2.pkg should be "pending". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID2) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID2, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Perform another installation of installer1.pkg. + installUUID3, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID3) + + // Last installation for installer1.pkg should be "pending" again. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Set result of last installer1.pkg installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID3, + + InstallScriptExitCode: ptr.Int(1), + }) + require.NoError(t, err) + + // Last installation for installer1.pkg should be "failed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerFailed, *host1LastInstall.Status) + + // No installations on host2. + host2LastInstall, err := ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host2LastInstall) + host2LastInstall, err = ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID2) + require.NoError(t, err) + require.Nil(t, host2LastInstall) +} diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index e5ff9403ea..bcb5eb41e7 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3715,26 +3715,36 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and two for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) // create vpp3 app that allows self-service va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) vpp2, vpp3 := va2.AdamID, va3.AdamID @@ -3927,8 +3937,10 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { ctx := context.Background() host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) nanoEnroll(t, ds, host, false) - opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", - TestSecondaryOrderKey: "source"}} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{ + PerPage: 10, IncludeMetadata: true, OrderKey: "name", + TestSecondaryOrderKey: "source", + }} user, err := ds.NewUser(ctx, &fleet.User{ Password: []byte("p4ssw0rd.123"), @@ -4012,24 +4024,31 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { require.NoError(t, err) expected := map[string]fleet.HostSoftwareWithInstaller{ - byNSV[a1].Name + byNSV[a1].Source: {Name: byNSV[a1].Name, Source: byNSV[a1].Source, + byNSV[a1].Name + byNSV[a1].Source: { + Name: byNSV[a1].Name, Source: byNSV[a1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}}, - }}, - byNSV[b].Name + byNSV[b].Source: {Name: byNSV[b].Name, Source: byNSV[b].Source, + }, + }, + byNSV[b].Name + byNSV[b].Source: { + Name: byNSV[b].Name, Source: byNSV[b].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}}, - }}, + }, + }, // c1 and c2 are the same software title because they have the same name and source - byNSV[c1].Name + byNSV[c1].Source: {Name: byNSV[c1].Name, Source: byNSV[c1].Source, + byNSV[c1].Name + byNSV[c1].Source: { + Name: byNSV[c1].Name, Source: byNSV[c1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[c1].Version}, {Version: byNSV[c2].Version}, - }}, + }, + }, } compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, - expectOmitted ...string) { + expectOmitted ...string, + ) { require.Len(t, got, len(expected)-len(expectOmitted)) prev := "" for _, g := range got { @@ -4116,33 +4135,47 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and three for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) va4, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4", - BundleIdentifier: "com.app.vpp4"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4", + BundleIdentifier: "com.app.vpp4", + }, nil) require.NoError(t, err) vpp2, vpp3, vpp4 := va2.AdamID, va3.AdamID, va4.AdamID @@ -4384,6 +4417,7 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", TeamID: &team1.ID, + UserID: user.ID, }) require.NoError(t, err) @@ -4399,8 +4433,10 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { // add a VPP app for team 1 vppTm1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &team1.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &team1.ID) require.NoError(t, err) // fail to install it on the host @@ -4489,6 +4525,7 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore Version: "1.0", Source: "apps", TeamID: &team1.ID, + UserID: user.ID, }) require.NoError(t, err) @@ -4504,8 +4541,10 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore // add a VPP app for team 1 vppTm1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0"}, &team1.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0", + }, &team1.ID) require.NoError(t, err) // install it on the host diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index f7d27a0200..594ee84247 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -268,7 +268,7 @@ SELECT vap.icon_url as vpp_app_icon_url FROM software_titles st LEFT JOIN software_installers si ON si.title_id = st.id AND %s -LEFT JOIN vpp_apps vap ON vap.title_id = st.id +LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND %s LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND (%s) -- placeholder for JOIN on software/software_cve @@ -286,6 +286,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel countsJoin := "TRUE" softwareInstallersJoinCond := "TRUE" + vppAppsJoinCond := "TRUE" vppAppsTeamsJoinCond := "TRUE" includeVPPAppsAndSoftwareInstallers := "TRUE" switch { @@ -304,6 +305,11 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) } + if opt.PackagesOnly { + vppAppsJoinCond = "FALSE" + vppAppsTeamsJoinCond = "FALSE" + } + additionalWhere := "TRUE" match := opt.ListOptions.MatchQuery softwareJoin := "" @@ -363,7 +369,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel defaultFilter += ` AND ( si.self_service = 1 OR vat.self_service = 1 ) ` } - stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) + stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) return stmt, args } diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index d0ee221968..efdb25da54 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -271,6 +271,8 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + software1 := []fleet.Software{ {Name: "foo", Version: "0.0.1", Source: "chrome_extensions", Browser: "chrome"}, {Name: "foo", Version: "0.0.3", Source: "chrome_extensions", Browser: "chrome"}, @@ -303,6 +305,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -317,6 +320,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) @@ -594,6 +598,8 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{host1.ID})) host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) @@ -627,6 +633,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { Filename: "installer1.pkg", BundleIdentifier: "foo.bar", TeamID: &team1.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -642,6 +649,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer2.pkg", TeamID: &team2.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -856,12 +864,15 @@ func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // create a couple software installers not installed on any host installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -870,6 +881,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -955,6 +967,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) // create 2 software installers installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -962,6 +975,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -970,6 +984,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -1140,6 +1155,8 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // Create a macOS software foobar installer on "No team". macOSInstallerNoTeam, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foobar", @@ -1148,6 +1165,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "foobar.pkg", TeamID: nil, + UserID: user1.ID, }) require.NoError(t, err) @@ -1322,6 +1340,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team Foo"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", @@ -1329,6 +1348,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer1.pkg", BundleIdentifier: "com.foo.installer1", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -1339,6 +1359,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { Filename: "installer2.pkg", TeamID: &tm.ID, BundleIdentifier: "com.foo.installer2", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index c25b4a791c..c0d1b8b61b 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -106,7 +106,14 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team) func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) + // Delete team policies first, because policies can have associated installers which may be deleted on cascade + // before deleting the policies (which are also deleted on cascade). + _, err := tx.ExecContext(ctx, `DELETE FROM policies WHERE team_id = ?`, tid) + if err != nil { + return ctxerr.Wrapf(ctx, err, "deleting policies for team %d", tid) + } + + _, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) if err != nil { return ctxerr.Wrapf(ctx, err, "delete team %d", tid) } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 1d721390b0..764c6d2d6a 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -531,7 +531,7 @@ type Datastore interface { // InsertSoftwareInstallRequest tracks a new request to install the provided // software installer in the host. It returns the auto-generated installation // uuid. - InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) + InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) /////////////////////////////////////////////////////////////////////////////// // SoftwareStore @@ -679,6 +679,8 @@ type Datastore interface { // and have a calendar event scheduled. GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]HostPolicyMembershipData, error) + // GetPoliciesWithAssociatedInstaller returns team policies that have an associated installer. + GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicySoftwareInstallerData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // Methods used for async processing of host policy query results. @@ -1621,6 +1623,9 @@ type Datastore interface { // installer execution IDs that have not yet been run for a given host ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) + // GetHostLastInstallData returns the data for the last installation of a package on a host. + GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*HostLastInstallData, error) + // MatchOrCreateSoftwareInstaller matches or creates a new software installer. MatchOrCreateSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (uint, error) diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 6ce5e38097..daf0505f03 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -30,8 +30,44 @@ type PolicyPayload struct { // // Empty string targets all platforms. Platform string - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool + // SoftwareInstallerID is the ID of the software installer that will be installed if the policy fails. + // + // Only applies to team policies. + SoftwareInstallerID *uint +} + +// NewTeamPolicyPayload holds data for team policy creation. +// +// If QueryID is not nil, then Name, Query and Description are ignored +// (such fields are fetched from the queries table). +type NewTeamPolicyPayload struct { + // QueryID allows creating a policy from an existing query. + // + // Using QueryID is the old way of creating policies. + // Use Query, Name and Description instead. + QueryID *uint + // Name is the name of the policy (ignored if QueryID != nil). + Name string + // Query is the policy query (ignored if QueryID != nil). + Query string + // Critical marks the policy as high impact. + Critical bool + // Description is the policy description text (ignored if QueryID != nil). + Description string + // Resolution indicates the steps needed to solve a failing policy. + Resolution string + // Platform is a comma-separated string to indicate the target platforms. + // + // Empty string targets all platforms. + Platform string + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + CalendarEventsEnabled bool + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + SoftwareTitleID *uint } var ( @@ -109,8 +145,15 @@ type ModifyPolicyPayload struct { Platform *string `json:"platform"` // Critical marks the policy as high impact. Critical *bool `json:"critical" premium:"true"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled *bool `json:"calendar_events_enabled" premium:"true"` + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + // Value 0 will unset the current installer from the policy. + // + // Only applies to team policies. + SoftwareTitleID *uint `json:"software_title_id" premium:"true"` } // Verify verifies the policy payload is valid. @@ -163,7 +206,8 @@ type PolicyData struct { // Empty string targets all platforms. Platform string `json:"platform" db:"platforms"` - CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + SoftwareInstallerID *uint `json:"-" db:"software_installer_id"` UpdateCreateTimestamps } @@ -177,6 +221,14 @@ type Policy struct { // FailingHostCount is the number of hosts this policy fails on. FailingHostCount uint `json:"failing_host_count" db:"failing_host_count"` HostCountUpdatedAt *time.Time `json:"host_count_updated_at" db:"host_count_updated_at"` + + // InstallSoftware is used to trigger installation of a software title + // when this policy fails. + // + // Only applies to team policies. + // + // This field is populated from PolicyData.SoftwareInstallerID. + InstallSoftware *PolicySoftwareTitle `json:"install_software,omitempty"` } type PolicyCalendarData struct { @@ -184,6 +236,11 @@ type PolicyCalendarData struct { Name string `db:"name" json:"name"` } +type PolicySoftwareInstallerData struct { + ID uint `db:"id"` + InstallerID uint `db:"software_installer_id"` +} + // PolicyLite is a stripped down version of the policy. type PolicyLite struct { ID uint `db:"id"` @@ -232,10 +289,21 @@ type PolicySpec struct { // // Empty string targets all platforms. Platform string `json:"platform,omitempty"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool `json:"calendar_events_enabled"` } +// PolicySoftwareTitle contains software title data for policies. +type PolicySoftwareTitle struct { + // SoftwareTitleID is the ID of the title associated to the policy. + SoftwareTitleID uint `json:"software_title_id"` + // Name is the associated installer title name + // (not the package name, but the installed software title). + Name string `json:"name"` +} + // Verify verifies the policy data is valid. func (p PolicySpec) Verify() error { if err := verifyPolicyName(p.Name); err != nil { diff --git a/server/fleet/service.go b/server/fleet/service.go index b81366fcc1..b975691da3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -672,7 +672,7 @@ type Service interface { // ///////////////////////////////////////////////////////////////////////////// // Team Policies - NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error) + NewTeamPolicy(ctx context.Context, teamID uint, p NewTeamPolicyPayload) (*Policy, error) ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*Policy, err error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error) diff --git a/server/fleet/software.go b/server/fleet/software.go index d7492a2d98..9045d7e375 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -227,6 +227,7 @@ type SoftwareTitleListOptions struct { KnownExploit bool `query:"exploit,optional"` MinimumCVSS float64 `query:"min_cvss_score,optional"` MaximumCVSS float64 `query:"max_cvss_score,optional"` + PackagesOnly bool `query:"packages_only,optional"` } type HostSoftwareTitleListOptions struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index dbc3ea586e..b8d254eca9 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -79,6 +79,8 @@ type SoftwareInstaller struct { Name string `json:"name" db:"filename"` // Version is the version of the software package. Version string `json:"version" db:"version"` + // Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs). + Platform string `json:"platform" db:"platform"` // UploadedAt is the time the software package was uploaded. UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` // InstallerID is the unique identifier for the software package metadata in Fleet. @@ -140,6 +142,14 @@ func (s SoftwareInstallerStatus) IsValid() bool { } } +// HostLastInstallData contains data for the last installation of a package on a host. +type HostLastInstallData struct { + // ExecutionID is the installation ID of the package on the host. + ExecutionID string `db:"execution_id"` + // Status is the status of the installation on the host. + Status *SoftwareInstallerStatus `db:"status"` +} + // HostSoftwareInstaller represents a software installer package that has been installed on a host. type HostSoftwareInstallerResult struct { // ID is the unique numerical ID of the result assigned by the datastore. @@ -183,6 +193,12 @@ type HostSoftwareInstallerResult struct { // HostDeletedAt indicates if the data is associated with a // deleted host HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"` + // SoftwareInstallerUserID is the ID of the user that uploaded the software installer. + SoftwareInstallerUserID *uint `json:"-" db:"software_installer_user_id"` + // SoftwareInstallerUserID is the name of the user that uploaded the software installer. + SoftwareInstallerUserName string `json:"-" db:"software_installer_user_name"` + // SoftwareInstallerUserEmail is the email of the user that uploaded the software installer. + SoftwareInstallerUserEmail string `json:"-" db:"software_installer_user_email"` } const ( @@ -262,6 +278,7 @@ type UploadSoftwareInstallerPayload struct { Platform string BundleIdentifier string SelfService bool + UserID uint } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 128371de50..7f72c1e738 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -396,7 +396,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) -type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) +type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) type ListSoftwareForVulnDetectionFunc func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) @@ -494,6 +494,8 @@ type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[s type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) +type GetPoliciesWithAssociatedInstallerFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) + type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error @@ -1024,6 +1026,8 @@ type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) +type GetHostLastInstallDataFunc func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) + type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) @@ -1776,6 +1780,9 @@ type DataStore struct { GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFuncInvoked bool + GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFunc + GetPoliciesWithAssociatedInstallerFuncInvoked bool + GetCalendarPoliciesFunc GetCalendarPoliciesFunc GetCalendarPoliciesFuncInvoked bool @@ -2571,6 +2578,9 @@ type DataStore struct { ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFuncInvoked bool + GetHostLastInstallDataFunc GetHostLastInstallDataFunc + GetHostLastInstallDataFuncInvoked bool + MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFuncInvoked bool @@ -3950,11 +3960,11 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter) } -func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) { +func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) { s.mu.Lock() s.InsertSoftwareInstallRequestFuncInvoked = true s.mu.Unlock() - return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService) + return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareInstallerID, selfService) } func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) { @@ -4293,6 +4303,13 @@ func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain st return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID) } +func (s *DataStore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + s.mu.Lock() + s.GetPoliciesWithAssociatedInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetPoliciesWithAssociatedInstallerFunc(ctx, teamID, policyIDs) +} + func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { s.mu.Lock() s.GetCalendarPoliciesFuncInvoked = true @@ -6148,6 +6165,13 @@ func (s *DataStore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint return s.ListPendingSoftwareInstallsFunc(ctx, hostID) } +func (s *DataStore) GetHostLastInstallData(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { + s.mu.Lock() + s.GetHostLastInstallDataFuncInvoked = true + s.mu.Unlock() + return s.GetHostLastInstallDataFunc(ctx, hostID, installerID) +} + func (s *DataStore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { s.mu.Lock() s.MatchOrCreateSoftwareInstallerFuncInvoked = true diff --git a/server/service/activities.go b/server/service/activities.go index cdab1837f1..861c873095 100644 --- a/server/service/activities.go +++ b/server/service/activities.go @@ -85,7 +85,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } diff --git a/server/service/global_policies.go b/server/service/global_policies.go index c7d03e9695..ed0ef22013 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -6,12 +6,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/fleetdm/fleet/v4/pkg/fleethttp" "io" "net/http" "strings" "time" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -155,6 +155,9 @@ func (svc Service) GetPolicyByIDQueries(ctx context.Context, policyID uint) (*fl if err != nil { return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } return policy, nil } @@ -583,8 +586,10 @@ func autofillPoliciesEndpoint(ctx context.Context, request interface{}, svc flee } // Exposing external URL and timeout for testing purposes -var getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" -var getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +var ( + getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" + getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +) type AutofillError struct { Message string diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ef40dd7fb6..8b1f848205 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11595,6 +11595,9 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { t := s.T() ctx := context.Background() + adminUser, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + // there is already a datastore-layer test that verifies that correct values // are returned for users, saved scripts, etc. so this is more focused on // verifying that the service layer passes the proper options and the @@ -11639,6 +11642,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: adminUser.ID, }) require.NoError(t, err) s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index cc1a781640..43e7d2edbc 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10009,8 +10009,10 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { // Add new software to host -- installed on host, but not by Fleet installedVersion := "1.0.1" - softwareAlreadyInstalled := fleet.Software{Name: "DummyApp.app", Version: installedVersion, Source: "apps", - BundleIdentifier: "com.example.dummy"} + softwareAlreadyInstalled := fleet.Software{ + Name: "DummyApp.app", Version: installedVersion, Source: "apps", + BundleIdentifier: "com.example.dummy", + } software = append(software, softwareAlreadyInstalled) _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -10034,7 +10036,6 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { assert.Equal(t, installedVersion, getHostSw.Software[0].InstalledVersions[0].Version) assert.NotNil(t, getHostSw.Software[0].SoftwarePackage) assert.Nil(t, getHostSw.Software[0].Status) - } func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() { @@ -11343,7 +11344,11 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { require.EqualValues(t, 0, *scriptRes.ExitCode) } -func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) { +func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller( + payload *fleet.UploadSoftwareInstallerPayload, + expectedStatus int, + expectedError string, +) { t := s.T() t.Helper() openFile := func(name string) *os.File { @@ -11388,6 +11393,8 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. } r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) + defer r.Body.Close() + if expectedError != "" { errMsg := extractServerErrorText(r.Body) require.Contains(t, errMsg, expectedError) @@ -11711,6 +11718,21 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { ) } +func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some installer script", + Filename: "no_version.pkg", + TeamID: &team.ID, + } + s.uploadSoftwareInstaller(payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, matches existing software title @@ -12700,3 +12722,586 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.") } + +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() { + t := s.T() + ctx := context.Background() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) + require.NoError(t, err) + + newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: platform, + TeamID: teamID, + }) + require.NoError(t, err) + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey + return h + } + + host0NoTeam := newFleetdHost("host1NoTeam", nil, "darwin") + host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu") + host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows") + + // Upload dummy_installer.pkg to team1. + pkgPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some pkg install script", + Filename: "dummy_installer.pkg", + TeamID: &team1.ID, + } + s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "DummyApp.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + dummyInstallerPkgTitleID := resp.SoftwareTitles[0].ID + var dummyInstallerPkg struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &dummyInstallerPkg, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "dummy_installer.pkg", + ) + }) + dummyInstallerPkgInstallerID := dummyInstallerPkg.ID + require.NotZero(t, dummyInstallerPkgInstallerID) + require.NotNil(t, dummyInstallerPkg.UserID) + globalAdmin, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + require.Equal(t, globalAdmin.ID, *dummyInstallerPkg.UserID) + require.Equal(t, "Test Name admin1@example.com", dummyInstallerPkg.UserName) + require.Equal(t, "admin1@example.com", dummyInstallerPkg.UserEmail) + + // Upload ruby.deb to team1 by a user who will be deleted. + u2 := &fleet.User{ + Name: "admin team1", + Email: "admin_team1@example.com", + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team1, + Role: fleet.RoleAdmin, + }, + }, + } + require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10)) + adminTeam1, err := s.ds.NewUser(context.Background(), u2) + require.NoError(t, err) + rubyPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: &team1.ID, + } + sessionKey := uuid.New().String() + adminTeam1Session, err := s.ds.NewSession(ctx, adminTeam1.ID, sessionKey) + require.NoError(t, err) + adminToken := s.token + t.Cleanup(func() { + s.token = adminToken + }) + s.token = adminTeam1Session.Key + s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "") + s.token = adminToken + err = s.ds.DeleteUser(ctx, adminTeam1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + var rubyDeb struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &rubyDeb, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "ruby.deb", + ) + }) + rubyDebInstallerID := rubyDeb.ID + require.NotZero(t, rubyDebInstallerID) + require.Nil(t, rubyDeb.UserID) + require.Equal(t, "admin team1", rubyDeb.UserName) + require.Equal(t, "admin_team1@example.com", rubyDeb.UserEmail) + + // Upload fleet-osquery.msi to team2. + fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some msi install script", + Filename: "fleet-osquery.msi", + TeamID: &team2.ID, + // Set as Self-service to check that the generated host_software_installs + // is generated with self_service=false and the activity has the correct + // author (the admin that uploaded the installer). + SelfService: true, + } + s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Fleet osquery", + "team_id", fmt.Sprintf("%d", team2.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + fleetOsqueryMSITitleID := resp.SoftwareTitles[0].ID + var fleetOsqueryMSIInstallerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &fleetOsqueryMSIInstallerID, + `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team2.ID, "fleet-osquery.msi", + ) + }) + require.NotZero(t, fleetOsqueryMSIInstallerID) + + // Create a VPP app to test that policies cannot be assigned to them. + _, err = s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "App123 " + t.Name(), + BundleIdentifier: "bid_" + t.Name(), + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "adam_" + t.Name(), + Platform: fleet.MacOSPlatform, + }, + }, + }, &team1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded VPP app. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "App123", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].AppStoreApp) + vppAppTitleID := resp.SoftwareTitles[0].ID + + // Populate software for host1Team1 (to have a software title + // that doesn't have an associated installer) + software := []fleet.Software{ + {Name: "Foobar.app", Version: "0.0.1", Source: "apps"}, + } + _, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software) + require.NoError(t, err) + require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now())) + // Get software title ID of the software. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Foobar.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.Nil(t, resp.SoftwareTitles[0].SoftwarePackage) + foobarAppTitleID := resp.SoftwareTitles[0].ID + + // policy0AllTeams is a global policy that runs on all devices. + policy0AllTeams, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "policy0AllTeams", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy1Team1 runs on macOS devices. + policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy2Team1 runs on macOS and Linux devices. + policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy2Team1", + Query: "SELECT 2;", + Platform: "linux,darwin", + }) + require.NoError(t, err) + // policy3Team1 runs on all devices in team1 (will have no associated installers). + policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy3Team1", + Query: "SELECT 3;", + }) + require.NoError(t, err) + // policy4Team2 runs on Windows devices. + policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + Platform: "windows", + }) + require.NoError(t, err) + + // Attempt to associate to an unknown software title. + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(999_999), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate to a software title without associated installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(foobarAppTitleID), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate vppApp to policy1Team1 which should fail because we only allow associating software installers. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &vppAppTitleID, + }, + }, http.StatusBadRequest, &mtplr) + // Associate dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + // Change name only (to test not setting a software_title_id). + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), + json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr, + ) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + require.Equal(t, "policy1Team1_Renamed", *&policy1Team1.Name) + // Explicit set to 0 to disable. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.SoftwareInstallerID) + // Back to associating dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + + // Associate ruby.deb to policy2Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the + // current user's "Authorization: Bearer " header. + + // host1Team1 fails all policies on the first report. + // Failing policy1Team1 means an install request must be generated. + // Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts). + // Failing policy3Team1 should do nothing because it doesn't have any installers associated to it. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.NotEmpty(t, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + prevExecutionID := host1LastInstall.ExecutionID + + // Submit same results as before, which should not trigger a installation because the policy is already failing. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Submit same results but policy1Team1 now passes, + // and then submit again but policy1Team1 fails. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(true), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Another installation should not be triggered because the last installation is pending. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // host2Team1 is failing policy2Team1 and policy3Team1 policies. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host2Team1, + map[uint]*bool{ + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host2LastInstall, err := s.ds.GetHostLastInstallData(ctx, host2Team1.ID, rubyDebInstallerID) + require.NoError(t, err) + require.NotNil(t, host2LastInstall) + require.NotEmpty(t, host2LastInstall.ExecutionID) + require.NotNil(t, host2LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host2LastInstall.Status) + + // Associate fleet-osquery.msi to policy4Team2. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &fleetOsqueryMSITitleID, + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2, which should trigger an installation. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host3LastInstall, err := s.ds.GetHostLastInstallData(ctx, host3Team2.ID, fleetOsqueryMSIInstallerID) + require.NoError(t, err) + require.NotNil(t, host3LastInstall) + require.NotEmpty(t, host3LastInstall.ExecutionID) + require.NotNil(t, host3LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host3LastInstall.Status) + host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID) + require.NoError(t, err) + // Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so + // host3LastInstallDetails.SelfService should be false. + require.False(t, host3LastInstallDetails.SelfService) + + // + // The following increase coverage of policies result processing in distributed/write. + // + + // host3Team2 reports a passing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(true), + }, + ), http.StatusOK, &distributedResp) + + // host0NoTeam reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host0NoTeam, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // host3Team2 reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Unassociate policy4Team2 from installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // + // Finally have orbit install the packages and check activities. + // + + // host1Team1 posts the installation result for dummy_installer.pkg. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent) + s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "installed" + }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp.app", "dummy_installer.pkg", host1LastInstall.ExecutionID), 0) + + // host2Team1 posts the installation result for ruby.deb. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host2Team1.OrbitNodeKey, host2LastInstall.ExecutionID)), http.StatusNoContent) + activityID := s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "failed" + }`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID), 0) + + // Check that the activity item generated for ruby.deb installation has a null user, + // but has name and email set. + var actor struct { + UserID *uint `db:"user_id"` + UserName *string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.Nil(t, actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "admin team1", *actor.UserName) + require.Equal(t, "admin_team1@example.com", actor.UserEmail) + + // host3Team2 posts the installation result for fleet-osquery.msi. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host3Team2.OrbitNodeKey, host3LastInstall.ExecutionID)), http.StatusNoContent) + activityID = s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "failed" + }`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID), 0) + + // Check that the activity item generated for fleet-osquery.msi installation has the admin user set as author. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.NotNil(t, actor.UserID) + require.Equal(t, globalAdmin.ID, *actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "Test Name admin1@example.com", *actor.UserName) + require.Equal(t, "admin1@example.com", actor.UserEmail) +} diff --git a/server/service/orbit.go b/server/service/orbit.go index 394230c602..e894f8a157 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -998,11 +998,31 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "get host software installation result information") } + // Self-Service packages will have a nil author for the activity. var user *fleet.User - if hsi.UserID != nil && !hsi.SelfService { - user, err = svc.ds.UserByID(ctx, *hsi.UserID) - if err != nil { - return ctxerr.Wrap(ctx, err, "get host software installation user") + if !hsi.SelfService { + if hsi.UserID != nil { + user, err = svc.ds.UserByID(ctx, *hsi.UserID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host software installation user") + } + } else { + // hsi.UserID can be nil if the user was deleted and/or if the installation was + // triggered by Fleet (policy automation). Thus we set the author of the installation + // to be the user that uploaded the package (by design). + var userID uint + if hsi.SoftwareInstallerUserID != nil { + userID = *hsi.SoftwareInstallerUserID + } + // If there's no name or email then this may be a package uploaded + // before we added authorship to uploaded packages. + if hsi.SoftwareInstallerUserName != "" && hsi.SoftwareInstallerUserEmail != "" { + user = &fleet.User{ + ID: userID, + Name: hsi.SoftwareInstallerUserName, + Email: hsi.SoftwareInstallerUserEmail, + } + } } } diff --git a/server/service/osquery.go b/server/service/osquery.go index c4112f7d0d..a38d26407e 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1008,6 +1008,10 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, policyResults); err != nil { + logging.WithErr(ctx, err) + } + // filter policy results for webhooks var policyIDs []uint if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) { @@ -1038,6 +1042,7 @@ func (svc *Service) SubmitDistributedQueryResults( }() } } + // NOTE(mna): currently, failing policies webhook wouldn't see the new // flipped policies on the next run if async processing is enabled and the // collection has not been done yet (not persisted in mysql). Should @@ -1606,6 +1611,136 @@ func (svc *Service) registerFlippedPolicies(ctx context.Context, hostID uint, ho return nil } +func (svc *Service) processSoftwareForNewlyFailingPolicies( + ctx context.Context, + hostID uint, + hostTeamID *uint, + hostPlatform string, + incomingPolicyResults map[uint]*bool, +) error { + if hostTeamID == nil { + // TODO(lucas): Support hosts in "No team". + return nil + } + + // Filter out results that are not failures (we are only interested on failing policies, + // we don't care about passing policies or policies that failed to execute). + incomingFailingPolicies := make(map[uint]*bool) + var incomingFailingPoliciesIDs []uint + for policyID, policyResult := range incomingPolicyResults { + if policyResult != nil && !*policyResult { + incomingFailingPolicies[policyID] = policyResult + incomingFailingPoliciesIDs = append(incomingFailingPoliciesIDs, policyID) + } + } + if len(incomingFailingPolicies) == 0 { + return nil + } + + // Get policies with associated installers for the team. + policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, *hostTeamID, incomingFailingPoliciesIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get policies with installer") + } + if len(policiesWithInstaller) == 0 { + return nil + } + + // Filter out results of policies that are not associated to installers. + policiesWithInstallersMap := make(map[uint]fleet.PolicySoftwareInstallerData) + for _, policyWithInstaller := range policiesWithInstaller { + policiesWithInstallersMap[policyWithInstaller.ID] = policyWithInstaller + } + policyResultsOfPoliciesWithInstallers := make(map[uint]*bool) + for policyID, passes := range incomingFailingPolicies { + if _, ok := policiesWithInstallersMap[policyID]; !ok { + continue + } + policyResultsOfPoliciesWithInstallers[policyID] = passes + } + if len(policyResultsOfPoliciesWithInstallers) == 0 { + return nil + } + + // Get the policies associated with installers that are flipping from passing to failing on this host. + policyIDsOfNewlyFailingPoliciesWithInstallers, _, err := svc.ds.FlippingPoliciesForHost( + ctx, hostID, policyResultsOfPoliciesWithInstallers, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get flipping policies for host") + } + if len(policyIDsOfNewlyFailingPoliciesWithInstallers) == 0 { + return nil + } + policyIDsOfNewlyFailingPoliciesWithInstallersSet := make(map[uint]struct{}) + for _, policyID := range policyIDsOfNewlyFailingPoliciesWithInstallers { + policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyID] = struct{}{} + } + + // Finally filter out policies with installers that are not newly failing. + var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData + for _, policyWithInstaller := range policiesWithInstaller { + if _, ok := policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyWithInstaller.ID]; ok { + failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller) + } + } + + for _, failingPolicyWithInstaller := range failingPoliciesWithInstaller { + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, failingPolicyWithInstaller.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + logger := log.With(svc.logger, + "host_id", hostID, + "host_platform", hostPlatform, + "policy_id", failingPolicyWithInstaller.ID, + "software_installer_id", failingPolicyWithInstaller.InstallerID, + "software_title_id", installerMetadata.TitleID, + "software_installer_platform", installerMetadata.Platform, + ) + if fleet.PlatformFromHost(hostPlatform) != installerMetadata.Platform { + level.Debug(logger).Log("msg", "installer platform does not match host platform") + continue + } + hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host last install data") + } + // hostLastInstall.Status == nil can happen when a software is installed by Fleet and later removed. + if hostLastInstall != nil && hostLastInstall.Status != nil && + *hostLastInstall.Status == fleet.SoftwareInstallerPending { + // There's a pending install for this host and installer, + // thus we do not queue another install request. + level.Debug(svc.logger).Log( + "msg", "found pending install request for this host and installer", + "pending_execution_id", hostLastInstall.ExecutionID, + ) + continue + } + // NOTE(lucas): The user_id set in this software install will be NULL + // so this means that when generating the activity for this action + // (in SaveHostSoftwareInstallResult) + // the author will be set to the user that uploaded the software (we want this + // by design). + installUUID, err := svc.ds.InsertSoftwareInstallRequest( + ctx, hostID, + installerMetadata.InstallerID, + false, // Set Self-service as false because this is triggered by Fleet. + ) + if err != nil { + return ctxerr.Wrapf(ctx, err, + "insert software install request: host_id=%d, software_installer_id=%d", + hostID, installerMetadata.InstallerID, + ) + } + level.Debug(logger).Log( + "msg", "install request sent", + "install_uuid", installUUID, + ) + } + return nil +} + func (svc *Service) maybeDebugHost( ctx context.Context, host *fleet.Host, diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 81ebee7d40..8f68ecddf1 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -29,6 +29,7 @@ type teamPolicyRequest struct { Platform string `json:"platform"` Critical bool `json:"critical" premium:"true"` CalendarEventsEnabled bool `json:"calendar_events_enabled"` + SoftwareTitleID *uint `json:"software_title_id"` } type teamPolicyResponse struct { @@ -40,7 +41,7 @@ func (r teamPolicyResponse) error() error { return r.Err } func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*teamPolicyRequest) - resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.PolicyPayload{ + resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{ QueryID: req.QueryID, Name: req.Name, Query: req.Query, @@ -49,6 +50,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv Platform: req.Platform, Critical: req.Critical, CalendarEventsEnabled: req.CalendarEventsEnabled, + SoftwareTitleID: req.SoftwareTitleID, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -56,7 +58,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv return teamPolicyResponse{Policy: resp}, nil } -func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.PolicyPayload) (*fleet.Policy, error) { +func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewTeamPolicyPayload) (*fleet.Policy, error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -70,6 +72,11 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, errors.New("user must be authenticated to create team policies") } + p, err := svc.newTeamPolicyPayloadToPolicyPayload(ctx, teamID, tp) + if err != nil { + return nil, err + } + if err := p.Verify(); err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: fmt.Sprintf("policy payload verification: %s", err), @@ -80,6 +87,10 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, ctxerr.Wrap(ctx, err, "creating policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -95,6 +106,39 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return policy, nil } +func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet.Policy) error { + if p.SoftwareInstallerID == nil { + return nil + } + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, *p.SoftwareInstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + p.InstallSoftware = &fleet.PolicySoftwareTitle{ + SoftwareTitleID: *installerMetadata.TitleID, + Name: installerMetadata.SoftwareTitle, + } + return nil +} + +func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (fleet.PolicyPayload, error) { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, &teamID, p.SoftwareTitleID) + if err != nil { + return fleet.PolicyPayload{}, err + } + return fleet.PolicyPayload{ + QueryID: p.QueryID, + Name: p.Name, + Query: p.Query, + Critical: p.Critical, + Description: p.Description, + Resolution: p.Resolution, + Platform: p.Platform, + CalendarEventsEnabled: p.CalendarEventsEnabled, + SoftwareInstallerID: softwareInstallerID, + }, nil +} + ///////////////////////////////////////////////////////////////////////////////// // List ///////////////////////////////////////////////////////////////////////////////// @@ -148,11 +192,27 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee } if mergeInherited { - p, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) - return p, nil, err + policies, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) + for i := range policies { + if err := svc.populatePolicyInstallSoftware(ctx, policies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", policies[i].ID) + } + } + return policies, nil, err } - return svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + teamPolicies, inheritedPolicies, err = svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + if err != nil { + return nil, nil, err + } + + for i := range teamPolicies { + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", teamPolicies[i].ID) + } + } + + return teamPolicies, inheritedPolicies, nil } ///////////////////////////////////////////////////////////////////////////////// @@ -240,6 +300,10 @@ func (svc Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, po return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + return teamPolicy, nil } @@ -418,6 +482,14 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f policy.FailingHostCount = 0 policy.PassingHostCount = 0 } + if p.SoftwareTitleID != nil { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, teamID, p.SoftwareTitleID) + if err != nil { + return nil, err + } + policy.SoftwareInstallerID = softwareInstallerID + } + logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) err = svc.ds.SavePolicy(ctx, policy, removeAllMemberships, removeStats) @@ -425,6 +497,10 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return nil, ctxerr.Wrap(ctx, err, "saving policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -440,3 +516,48 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return policy, nil } + +func (svc *Service) deduceSoftwareInstallerIDFromTitleID(ctx context.Context, teamID *uint, softwareTitleID *uint) (*uint, error) { + if softwareTitleID == nil { + return nil, nil + } + + // If *p.SoftwareTitleID with value 0 is used to unset the current installer from the policy. + if *softwareTitleID == 0 { + return nil, nil + } + + if teamID == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "software_title_id cannot be set on global policies", + }) + } + + softwareTitle, err := svc.SoftwareTitleByID(ctx, *softwareTitleID, teamID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d not found", *softwareTitleID, *teamID), + }) + } + return nil, ctxerr.Wrap(ctx, err, "software title by id") + } + if softwareTitle.AppStoreApp != nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d is assocated to a VPP app, only software installers are supported", *softwareTitleID, *teamID), + }) + } + if softwareTitle.SoftwarePackage == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d does not have associated package", *softwareTitleID, *teamID), + }) + } + + // + // TODO(lucas): Support "No team" (softwareTitle.SoftwarePackage.TeamID == nil). + // + + // At this point we assume *softwareTitle.SoftwarePackage.TeamID == *teamID, + // because SoftwareTitleByID above receives the teamID. + return ptr.Uint(softwareTitle.SoftwarePackage.InstallerID), nil +} diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index 5c6c25ff8c..551e6e567c 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -32,7 +32,7 @@ func TestTeamPoliciesAuth(t *testing.T) { return nil, nil } ds.TeamPolicyFunc = func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) { - return nil, nil + return &fleet.Policy{}, nil } ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) { if id == 1 { @@ -68,6 +68,9 @@ func TestTeamPoliciesAuth(t *testing.T) { ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { return &fleet.Team{ID: 1}, nil } + ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{}, nil + } testCases := []struct { name string @@ -149,7 +152,7 @@ func TestTeamPoliciesAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - _, err := svc.NewTeamPolicy(ctx, 1, fleet.PolicyPayload{ + _, err := svc.NewTeamPolicy(ctx, 1, fleet.NewTeamPolicyPayload{ Name: "query1", Query: "select 1;", }) diff --git a/server/service/testdata/software-installers/README.md b/server/service/testdata/software-installers/README.md new file mode 100644 index 0000000000..b5a59d9daf --- /dev/null +++ b/server/service/testdata/software-installers/README.md @@ -0,0 +1,3 @@ +# testdata + +- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`. \ No newline at end of file diff --git a/server/service/testdata/software-installers/fleet-osquery.msi b/server/service/testdata/software-installers/fleet-osquery.msi new file mode 100644 index 0000000000000000000000000000000000000000..cc52ad5d4e0cedd7c8e01c2c41d248d4fc290b0b GIT binary patch literal 252416 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 E0eGFL2LJ#7 literal 0 HcmV?d00001 diff --git a/server/service/testdata/software-installers/no_version.pkg b/server/service/testdata/software-installers/no_version.pkg new file mode 100644 index 0000000000000000000000000000000000000000..c649ebf17bdcdc1f7e6ba42fd0e871b15e5c3bec GIT binary patch literal 846 zcmV-U1F`&gVR9h=8~_0T0000000JNY0000000q4O0004a+RRkRZrd;rz56Q!-4!m{ zXn|G^E%F8Kr8kNi*@U%0q75fsUy_od8g`PR=%IiB&GKf3oHrc)d~Is)0>h}fcIT5P z?0eA4u2k(|=l|Hh1k8WVH~cDweB*K5N%Ql#96)Fj5opywPBD!F%mSM1Db36mMI7Bi zT+k8ZX*@UHs9$T?`QuR#-+DaWRn-VO#~jU)vySS`Cg;crHU(O8IIU7Oc&gm_bWT$W zEu7@>8rp+C<}@}(ndQkb7ofMb=3;Tg)@sJMv|>53==)kpcQ14e4sWXW(E`2dK}Zc{ z@c5ntk|dRq0Ko)s6%vS9K|+d)BrU_L!Z-@IRTXn|pJ^^326IEv73iw3{07a$+^6nC6Yy97So@G_BFcs1lFuD@c}F}FtNf8LwD}; zQH#p7Wl|=)D&2_KzcZXi81njj;>byp%{XIBjA=nNT?si(?0ua$O(k5Egtp5`lBOx8 zCg9!V0IIdv)OeGWnE{-1=hCk|jiqm;Ne8uSs`;^la_TO^Kh&k{da@uymyo#Ut)8PTDCceGD`v=y6=NG4fqkHiWRe66fhk7P7XHRWWD zFV%(Io@~VudK^@?2{yjdlbyQ)e;C2Hk%I3k6@6y 0 { @@ -279,6 +279,21 @@ func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStat } } +func (ts *withServer) DoJSONWithoutAuth(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) { + t := ts.s.T() + rawBytes, err := json.Marshal(params) + require.NoError(t, err) + resp := ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{}, queryParams...) + t.Cleanup(func() { + resp.Body.Close() + }) + err = json.NewDecoder(resp.Body).Decode(v) + require.NoError(ts.s.T(), err) + if e, ok := v.(errorer); ok { + require.NoError(ts.s.T(), e.error()) + } +} + func (ts *withServer) getTestAdminToken() string { testUser := testUsers["admin1"] diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 099b7a0e57..f8a2de578c 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -210,6 +210,12 @@ func WithPlatform(s string) NewHostOption { } } +func WithTeamID(teamID uint) NewHostOption { + return func(h *fleet.Host) { + h.TeamID = &teamID + } +} + func NewHost(tb testing.TB, ds fleet.Datastore, name, ip, key, uuid string, now time.Time, options ...NewHostOption) *fleet.Host { osqueryHostID, _ := server.GenerateRandomText(10) h := &fleet.Host{ From 390c6888d796b5086b43718c9723567873427d4a Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 30 Aug 2024 13:18:47 -0500 Subject: [PATCH 07/21] Website: link to personalized view of endpoint ops page without changing primaryBuyingSituation (#21680) Closes: #21237 Changes: - Updated view-endpoint-ops to set a `pagePersonalization` value using the users primaryBuyingSituation (if it is set) or a `pageMode` query parameter. - Updated the endpoint ops page to be personalized based off the pagePersonalization value set in the view action - Updated the links to the endpoint ops page on the unpersonalized homepage to use a pageMode query parameter (e.g., pageMode=it) instead of a utm_content query parameter. - Updated the links to the endpoint ops page on the personalized homepage to not include a utm_content query parameter. --- website/api/controllers/view-endpoint-ops.js | 15 +++++++++-- website/views/pages/endpoint-ops.ejs | 28 ++++++++++---------- website/views/pages/homepage.ejs | 8 +++--- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index c5e2ec892b..df2fb2680d 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -22,13 +22,23 @@ module.exports = { } // Get testimonials for the component. let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); + // Default the pagePersonalization to the user's primaryBuyingSituation. + let pagePersonalization = this.req.session.primaryBuyingSituation; + // If a pageMode query parameter is set, update the pagePersonalization value. + // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session. + // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation. + if(this.req.param('pageMode') === 'it'){ + pagePersonalization = 'eo-it'; + } else if(this.req.param('pageMode') === 'security'){ + pagePersonalization = 'eo-security'; + } // Specify an order for the testimonials on this page using the last names of quote authors let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Harrison Ravazzolo','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; - if(['eo-it', 'mdm'].includes(this.req.session.primaryBuyingSituation)){ + if(['eo-it', 'mdm'].includes(pagePersonalization)){ testimonialOrderForThisPage = [ 'Harrison Ravazzolo', 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; - } else if(['eo-security', 'vm'].includes(this.req.session.primaryBuyingSituation)){ + } else if(['eo-security', 'vm'].includes(pagePersonalization)){ testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar','Alvaro Gutierrez', 'Joe Pistone']; } // Filter the testimonials by product category and the filtered list we built above. @@ -48,6 +58,7 @@ module.exports = { // Respond with view. return { testimonialsForScrollableTweets, + pagePersonalization, }; } diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index 18043c869f..22168e47a3 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -3,22 +3,22 @@
-

Endpoint operations <%= ['eo-security', 'vm'].includes(primaryBuyingSituation) ? 'for security' : ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'for IT' : '' %>

-

<%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

+

Endpoint operations <%= ['eo-security', 'vm'].includes(pagePersonalization) ? 'for security' : ['eo-it', 'mdm'].includes(pagePersonalization) ? 'for IT' : '' %>

+

<%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

A device verifying compliance for every endpoint
- <% if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) { %> + <% if(['eo-it', 'mdm'].includes(pagePersonalization)) { %> Automate anything

Remotely run scripts and prompts to complete tasks on every kind of computer, including Linux.

Pulse check anything

Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.

Ship data to any platform

Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.

- <% } else if(['eo-security', 'vm'].includes(primaryBuyingSituation)) { %> + <% } else if(['eo-security', 'vm'].includes(pagePersonalization)) { %> Osquery on easy mode

Build the agent in "read-only" mode or enable remote scripting to automatically mitigate misconfigurations and vulnerabilities.

Pulse check anything @@ -41,7 +41,7 @@
- <% if (['eo-security'].includes(primaryBuyingSituation)) { %> + <% if (['eo-security'].includes(pagePersonalization)) { %>
an opening quotation mark @@ -57,7 +57,7 @@
- <% } else if (['vm'].includes(primaryBuyingSituation)) { %> + <% } else if (['vm'].includes(pagePersonalization)) { %>
an opening quotation mark @@ -91,15 +91,15 @@
<% } %>
- <%if(['eo-security'].includes(primaryBuyingSituation)) {%> + <%if(['eo-security'].includes(pagePersonalization)) {%>
PlayPlay video
- <%} else if(['vm'].includes(primaryBuyingSituation)){%> + <%} else if(['vm'].includes(pagePersonalization)){%>
PlayPlay video
- <%} else if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) {%> + <%} else if(['eo-it', 'mdm'].includes(pagePersonalization)) {%>
PlayPlay video
@@ -116,7 +116,7 @@ - <% if(!primaryBuyingSituation || ['mdm', 'eo-it'].includes(primaryBuyingSituation)){%> + <% if(!pagePersonalization || ['mdm', 'eo-it'].includes(pagePersonalization)){%>

Automate anything

@@ -225,7 +225,7 @@

Osquery on easy mode

-

Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(primaryBuyingSituation) ? 'endpoints' : 'devices' %>.

+

Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(pagePersonalization) ? 'endpoints' : 'devices' %>.

Remotely disable/enable agent features, choose plugins, and keep osquery up to date.

Import community queries from other security teams at top brands like Palantir and Fastly.

@@ -235,7 +235,7 @@
- <% if(!primaryBuyingSituation || ['vm', 'eo-security'].includes(primaryBuyingSituation)) {%> + <% if(!pagePersonalization || ['vm', 'eo-security'].includes(pagePersonalization)) {%>

Open security tooling

Consolidate your security tooling on top of open data standards like YAML, SQL, and JSON.

@@ -289,7 +289,7 @@

Who else uses Fleet?

-

Empowering <%= ['mdm'].includes(primaryBuyingSituation) ? 'IT and corporate engineering' : ['eo-it'].includes(primaryBuyingSituation) ? 'IT and client platform' : ['eo-security'].includes(primaryBuyingSituation) ? 'security and platform' : ['vm'].includes(primaryBuyingSituation) ? 'security and IT' : 'IT and security' %> teams, globally

+

Empowering <%= ['mdm'].includes(pagePersonalization) ? 'IT and corporate engineering' : ['eo-it'].includes(pagePersonalization) ? 'IT and client platform' : ['eo-security'].includes(pagePersonalization) ? 'security and platform' : ['vm'].includes(pagePersonalization) ? 'security and IT' : 'IT and security' %> teams, globally

@@ -298,7 +298,7 @@

Endpoint operations

-

<%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

+

<%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

Start now Talk to us diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index b95a9d4930..d21fd43c39 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -62,7 +62,7 @@ Osquery on easy mode

Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.

@@ -80,7 +80,7 @@ Ship data to any platform

Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.

@@ -108,7 +108,7 @@

<%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

A <%= primaryBuyingSituation==='eo-security'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='eo-security'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.

- Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%> + Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%>
@@ -175,7 +175,7 @@

<%= primaryBuyingSituation==='vm'? 'Instrument your endpoints' : 'Understand your computers'%>

A <%= primaryBuyingSituation==='vm'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='vm'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.

- Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%> + Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%>
From f30017f354ad22ac5f88351eed21da3155f5d031 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 30 Aug 2024 17:00:29 -0300 Subject: [PATCH 08/21] Fix upcoming activities for automatic installers (#21714) Small fix for #21428. This is to show the activity the right way (Because installations triggered by Fleet will have `host_software_installs` with `NULL` `user_id`.). --- server/datastore/mysql/activities.go | 12 +++++--- server/datastore/mysql/activities_test.go | 29 +++++++++++++++---- server/service/integration_enterprise_test.go | 10 +++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 7ea67b5504..09a71a6f22 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -316,10 +316,12 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint // list pending software installs fmt.Sprintf(`SELECT hsi.execution_id as uuid, - u.name as name, - u.id as user_id, - u.gravatar_url as gravatar_url, - u.email as user_email, + -- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0), + -- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer. + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email, :installed_software_type as activity_type, hsi.created_at as created_at, JSON_OBJECT( @@ -339,6 +341,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint software_titles st ON st.id = si.title_id LEFT OUTER JOIN users u ON u.id = hsi.user_id + LEFT OUTER JOIN + users u2 ON u2.id = si.user_id LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hsi.host_id WHERE diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 4c5e4077d2..2bf04c06c7 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -494,7 +494,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { InstallScriptExitCode: ptr.Int(0), }) require.NoError(t, err) - h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one + + // No user for this one and not Self-service, means it was installed by Fleet thus the author was decided to be the admin + // that uploaded the installer. + h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) require.NoError(t, err) // create a single pending request for h2, as well as a non-pending one @@ -509,6 +512,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { // add a pending software install request for h2 h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false) require.NoError(t, err) + // No user for this one and Self-service, means it was installed by the end user, so the user_id should be null/nil. + h2Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, true) + require.NoError(t, err) // nothing for h3 @@ -517,6 +523,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooFailed, h1Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h1C, h1D, h1E) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooInstalled, h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Foo) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F) SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2) @@ -529,7 +537,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h1E: false, h2A: true, h2F: true, - h1Foo: false, + h1Foo: true, + h2Foo: false, h1Bar: true, h2Bar: true, vppCommand1: true, @@ -544,6 +553,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h1Foo: "foo", h1Bar: "bar", h2Bar: "bar", + h2Foo: "foo", + } + execIDsWithUserAdminID := map[string]struct{}{ + h1Foo: {}, } cases := []struct { @@ -595,10 +608,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{PerPage: 3}, + opts: fleet.ListOptions{PerPage: 4}, hostID: h2.ID, - wantExecs: []string{h2Bar, h2A, vppCommand2}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 3}, + wantExecs: []string{h2Foo, h2Bar, h2A, vppCommand2}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 4}, }, { opts: fleet.ListOptions{}, @@ -639,7 +652,11 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { case fleet.ActivityTypeInstalledSoftware{}.ActivityName(): require.Equal(t, wantExec, details["install_uuid"], "result %d", i) require.Equal(t, execIDsSoftwareTitle[wantExec], details["software_title"], "result %d", i) - wantUser = u2 + if _, ok := execIDsWithUserAdminID[details["install_uuid"].(string)]; ok { + wantUser = u + } else { + wantUser = u2 + } case fleet.ActivityInstalledAppStoreApp{}.ActivityName(): require.Equal(t, wantExec, details["command_uuid"], "result %d", i) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 43e7d2edbc..99904d6a04 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -13214,6 +13214,16 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers }, ), http.StatusOK, &distributedResp) + // Upcoming activities for host1Team1 should show the automatic installation of dummy_installer.pkg. + // Check the author should be the admin that uploaded the installer. + var listUpcomingAct listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1Team1.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 1) + require.NotNil(t, listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.ID, *listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.Name, *listUpcomingAct.Activities[0].ActorFullName) + require.Equal(t, globalAdmin.Email, *listUpcomingAct.Activities[0].ActorEmail) + // // Finally have orbit install the packages and check activities. // From 23f9065522ed3122beec0ac8fcaf775f8cff4f1c Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Fri, 30 Aug 2024 16:00:35 -0500 Subject: [PATCH 09/21] Profiles batch activity (#21604) #20757 API endpoint `/api/v1/fleet/mdm/profiles/batch` will now not log an activity for profile types that did not change in the database (Apple configuration profiles, Windows configuration profiles, or Apple declarations). Demo video: https://www.loom.com/share/8b75cbd8e7394c12ac6b56746b72c244 # 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/Committing-Changes.md#changes-files) for more information. - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - [x] Manual QA for all new/changed functionality --- changes/20757-profiles-batch-activity | 1 + cmd/fleetctl/apply_test.go | 27 +- cmd/fleetctl/get_test.go | 11 +- cmd/fleetctl/gitops_test.go | 58 +-- cmd/fleetctl/hosts_test.go | 20 +- ee/server/service/mdm.go | 4 +- ee/server/service/mdm_external_test.go | 5 +- ee/server/service/teams.go | 2 +- server/datastore/mysql/apple_mdm.go | 366 ++++++++++++++---- server/datastore/mysql/apple_mdm_test.go | 29 +- server/datastore/mysql/calendar_events.go | 2 +- server/datastore/mysql/mdm.go | 133 +++++-- server/datastore/mysql/mdm_test.go | 356 +++++++++++++---- server/datastore/mysql/microsoft_mdm.go | 135 +++++-- server/datastore/mysql/microsoft_mdm_test.go | 40 +- ...132940_AddAutoIncrementColumnToProfiles.go | 40 ++ server/datastore/mysql/mysql.go | 24 +- .../mysql/operating_system_vulnerabilities.go | 2 +- .../operating_system_vulnerabilities_test.go | 12 +- server/datastore/mysql/policies.go | 2 +- server/datastore/mysql/schema.sql | 14 +- server/datastore/mysql/software.go | 2 +- server/datastore/mysql/software_test.go | 18 +- server/fleet/apple_mdm.go | 34 ++ server/fleet/apple_mdm_test.go | 202 +++++++++- server/fleet/datastore.go | 7 +- server/fleet/windows_mdm.go | 12 + server/mock/datastore_mock.go | 8 +- server/service/apple_mdm.go | 10 +- server/service/apple_mdm_test.go | 20 +- server/service/client.go | 10 +- server/service/hosts.go | 4 +- server/service/hosts_test.go | 20 +- .../service/integration_mdm_profiles_test.go | 12 +- server/service/integration_mdm_test.go | 4 +- server/service/mdm.go | 63 +-- server/service/mdm_test.go | 24 +- server/service/teams_test.go | 5 +- server/service/testing_client.go | 21 + 39 files changed, 1348 insertions(+), 411 deletions(-) create mode 100644 changes/20757-profiles-batch-activity create mode 100644 server/datastore/mysql/migrations/tables/20240827132940_AddAutoIncrementColumnToProfiles.go diff --git a/changes/20757-profiles-batch-activity b/changes/20757-profiles-batch-activity new file mode 100644 index 0000000000..6b110b87c7 --- /dev/null +++ b/changes/20757-profiles-batch-activity @@ -0,0 +1 @@ +API endpoint `/api/v1/fleet/mdm/profiles/batch` will now not log an activity for profile types that did not change in the database (Apple configuration profiles, Windows configuration profiles, or Apple declarations). diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 04c6fe3b5b..3980504e48 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -167,12 +167,15 @@ func TestApplyTeamSpecs(t *testing.T) { return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( @@ -627,8 +630,9 @@ func TestApplyAppConfig(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { @@ -1250,11 +1254,14 @@ func TestApplyAsGitOps(t *testing.T) { teamEnrollSecrets = secrets return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { return nil, ¬FoundError{} diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 752d3a65d5..cc44450b11 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2271,11 +2271,14 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { } return nil, fmt.Errorf("team not found: %s", name) } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 4caaebd00e..fc9acc58a5 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -49,13 +49,13 @@ func TestBasicGlobalFreeGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -166,13 +166,13 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -277,13 +277,13 @@ func TestBasicTeamGitOps(t *testing.T) { ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, @@ -450,13 +450,14 @@ func TestFullGlobalGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -625,13 +626,14 @@ func TestFullTeamGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -927,10 +929,10 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, macProfiles) assert.Empty(t, winProfiles) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { assert.Empty(t, scripts) @@ -938,9 +940,9 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, profileUUIDs) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -1666,14 +1668,14 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil diff --git a/cmd/fleetctl/hosts_test.go b/cmd/fleetctl/hosts_test.go index a24fc79cbe..6220d6d75c 100644 --- a/cmd/fleetctl/hosts_test.go +++ b/cmd/fleetctl/hosts_test.go @@ -43,8 +43,9 @@ func TestHostsTransferByHosts(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -114,8 +115,9 @@ func TestHostsTransferByLabel(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -184,8 +186,9 @@ func TestHostsTransferByStatus(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -243,8 +246,9 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 9aeaddd67b..9d94428790 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1072,7 +1072,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui // This only sets profiles that haven't been queued by the cron to 'pending' (both removes and installs, which includes // the OS updates we just deleted). It doesn't have a functional difference because if you don't call this function // the cron will catch up, but it's important for the UX to mark them as pending immediately so it's reflected in the UI. - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{globalOrTeamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{globalOrTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } return nil @@ -1105,7 +1105,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui return err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } return nil diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 3d9ecf9924..c6324f84fe 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -200,8 +200,9 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() require.NoError(t, err) diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index f7dcbec15d..bb8e01342b 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -612,7 +612,7 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } if len(hostIDs) > 0 { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 387b591521..64237376ed 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -27,6 +27,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -88,7 +89,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations") } @@ -1135,7 +1136,7 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, server } var mdmID int64 - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { mdmID, _ = result.LastInsertId() } else { stmt := `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?` @@ -1444,7 +1445,8 @@ func (ds *Datastore) GetNanoMDMEnrollment(ctx context.Context, id string) (*flee func (ds *Datastore) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + _, err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + return err }) } @@ -1454,7 +1456,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMAppleConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT identifier, @@ -1516,13 +1518,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1543,31 +1545,37 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepIdents or delivered by Fleet) + var result sql.Result stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, append(keepIdents, fleetIdents...)) if err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, + p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1583,19 +1591,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Identifier] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1611,13 +1619,15 @@ ON DUPLICATE KEY UPDATE } // insert label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting apple profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting apple profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) BulkDeleteMDMAppleHostsConfigProfiles(ctx context.Context, profs []*fleet.MDMAppleProfilePayload) error { @@ -1682,9 +1692,9 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } appleMDMProfilesDesiredStateQuery := generateDesiredStateQuery("profile") @@ -1752,13 +1762,14 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( stmt, args, err := sqlx.In(toInstallStmt, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) if err != nil { - return ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, selectProfilesTotalBatches) + return false, ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, + selectProfilesTotalBatches) } var partialResult []*fleet.MDMAppleProfilePayload err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) if err != nil { - return ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches) + return false, ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches) } wantedProfiles = append(wantedProfiles, partialResult...) @@ -1810,19 +1821,19 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( stmt, args, err := sqlx.In(toRemoveStmt, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) if err != nil { - return ctxerr.Wrap(ctx, err, "building profiles to remove statement") + return false, ctxerr.Wrap(ctx, err, "building profiles to remove statement") } var partialResult []*fleet.MDMAppleProfilePayload err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) if err != nil { - return ctxerr.Wrap(ctx, err, "fetching profiles to remove") + return false, ctxerr.Wrap(ctx, err, "fetching profiles to remove") } currentProfiles = append(currentProfiles, partialResult...) } if len(wantedProfiles) == 0 && len(currentProfiles) == 0 { - return nil + return false, nil } // delete all host profiles to start from a clean slate, new entries will be added next @@ -1831,8 +1842,11 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( // // TODO part II(roberto): we found this call to be a major bottleneck during load testing // https://github.com/fleetdm/fleet/issues/21338 - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete all profiles") + if len(wantedProfiles) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete all profiles") + } + updatedDB = true } // profileIntersection tracks profilesToAdd ∩ profilesToRemove, this is used to avoid: @@ -1853,11 +1867,57 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( hostProfilesToClean = append(hostProfilesToClean, p) } } - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + if len(hostProfilesToClean) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + } + updatedDB = true } + profilesToInsert := make(map[string]*fleet.MDMAppleProfilePayload) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + profile_uuid, + profile_identifier, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + command_uuid, + profile_name, + checksum, + profile_uuid + FROM host_mdm_apple_profiles WHERE (host_uuid, profile_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.ProfileUUID) + } + var existingProfiles []fleet.MDMAppleProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.ProfileUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_apple_profiles ( profile_uuid, @@ -1897,6 +1957,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( resetBatch := func() { batchCount = 0 + clear(profilesToInsert) pargs = pargs[:0] psb.Reset() } @@ -1904,6 +1965,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( for _, p := range wantedProfiles { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) { + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + Status: pp.Status, + OperationType: pp.OperationType, + Detail: pp.Detail, + CommandUUID: pp.CommandUUID, + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, pp.OperationType, pp.Status, pp.CommandUUID, pp.Detail) psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1911,7 +1984,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1919,6 +1992,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( } } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeInstall, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeInstall, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1926,7 +2011,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1943,6 +2028,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if p.FailedToInstallOnHost() { continue } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeRemove, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeRemove, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1950,7 +2047,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1958,10 +2055,10 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } // mdmEntityTypeToDynamicNames tracks what names should be used in the @@ -3405,7 +3502,7 @@ func (ds *Datastore) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst // because the updated_at update condition is too complex?), so at the moment // this clears the profile uuids at all times, even if the profile did not // change. - if insertOnDuplicateDidUpdate(res) { + if insertOnDuplicateDidInsertOrUpdate(res) { // profile was updated, need to clear the profile uuids if err := ds.SetMDMAppleSetupAssistantProfileUUID(ctx, asst.TeamID, "", ""); err != nil { return nil, ctxerr.Wrap(ctx, err, "clear mdm apple setup assistant profiles") @@ -3954,7 +4051,9 @@ WHERE h.uuid = ? return nil } -func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, incomingDeclarations []*fleet.MDMAppleDeclaration) ([]*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, + incomingDeclarations []*fleet.MDMAppleDeclaration, +) (declarations []*fleet.MDMAppleDeclaration, updatedDB bool, err error) { const insertStmt = ` INSERT INTO mdm_apple_declarations ( declaration_uuid, @@ -4021,13 +4120,13 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "build query to load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load existing declarations") } if err := sqlx.SelectContext(ctx, tx, &existingDecls, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load existing declarations") } } @@ -4050,23 +4149,29 @@ WHERE // delete the obsolete declarations (all those that are not in keepNames) stmt, args, err := sqlx.In(fmt.Sprintf(fmtDeleteStmt, andNameNotInList), declTeamID, keepNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") + return nil, false, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") } delStmt = stmt delArgs = args } - if _, err := tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + var result sql.Result + if result, err = tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + return nil, false, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } for _, d := range incomingDeclarations { checksum := md5ChecksumScriptContent(string(d.RawJSON)) declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString() - if _, err := tx.ExecContext(ctx, insertStmt, + if result, err = tx.ExecContext(ctx, insertStmt, declUUID, d.Identifier, d.Name, @@ -4076,8 +4181,9 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) + return nil, false, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } incomingLabels := []fleet.ConfigurationProfileLabel{} @@ -4092,16 +4198,16 @@ WHERE // optimization for a later iteration. stmt, args, err := sqlx.In(loadExistingDecls, declTeamID, incomingNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedDecls, stmt, args...); err != nil { - return nil, ctxerr.Wrap(ctx, err, "load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load newly inserted declarations") } for _, newlyInsertedDecl := range newlyInsertedDecls { incomingDecl, ok := incomingDecls[newlyInsertedDecl.Name] if !ok { - return nil, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) + return nil, false, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) } for _, label := range incomingDecl.LabelsIncludeAll { @@ -4116,14 +4222,16 @@ WHERE } } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetDeclarationLabelAssociationsDB(ctx, tx, + incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") + return nil, false, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") } - return incomingDeclarations, nil + return incomingDeclarations, updatedDB || updatedLabels, nil } func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { @@ -4220,7 +4328,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO declaration.LabelsExcludeAny[i].Exclude = true labels = append(labels, declaration.LabelsExcludeAny[i]) } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { + if _, err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations") } @@ -4234,9 +4342,10 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO return declaration, nil } -func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, declarationLabels []fleet.ConfigurationProfileLabel) error { +func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, + declarationLabels []fleet.ConfigurationProfileLabel) (updatedDB bool, err error) { if len(declarationLabels) == 0 { - return nil + return false, nil } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -4258,38 +4367,72 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont exclude = VALUES(exclude) ` + selectStmt := ` + SELECT apple_declaration_uuid as profile_uuid, label_name, label_id, exclude FROM mdm_declaration_labels + WHERE (apple_declaration_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) + labelsToInsert = make(map[string]*fleet.ConfigurationProfileLabel, len(declarationLabels)) ) for i, pl := range declarationLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &declarationLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) - } - - return ctxerr.Wrap(ctx, err, "setting label associations for declarations") + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") } - deleteStmt = fmt.Sprintf(deleteStmt, deleteBuilder.String()) + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) + } + + return false, ctxerr.Wrap(ctx, err, "setting label associations for declarations") + } + updatedDB = true + } + + deleteStmt = fmt.Sprintf(deleteStmt, selectOrDeleteBuilder.String()) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -4299,13 +4442,21 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for declarations") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for declarations") + } + if result != nil { + rows, err := result.RowsAffected() + if err != nil { + return false, ctxerr.Wrap(ctx, err, "count rows affected by insert") + } + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) { @@ -4392,23 +4543,24 @@ func (ds *Datastore) MDMAppleBatchSetHostDeclarationState(ctx context.Context) ( err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error - uuids, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) + uuids, _, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) return err }) return uuids, ctxerr.Wrap(ctx, err, "upserting host declaration state") } -func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, status *fleet.MDMDeliveryStatus) ([]string, error) { +func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, + status *fleet.MDMDeliveryStatus) ([]string, bool, error) { // once all the declarations are in place, compute the desired state // and find which hosts need a DDM sync. changedDeclarations, err := mdmAppleGetHostsWithChangedDeclarationsDB(ctx, tx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") + return nil, false, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") } if len(changedDeclarations) == 0 { - return []string{}, nil + return []string{}, false, nil } // a host might have more than one declaration to sync, we do this to @@ -4430,11 +4582,12 @@ func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtCont // - support the DDM endpoints, which use data from the // `host_mdm_apple_declarations` table to compute which declarations to // serve - if err := mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { - return nil, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") + var updatedDB bool + if updatedDB, err = mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") } - return uuids, nil + return uuids, updatedDB, nil } // mdmAppleBatchSetPendingHostDeclarationsDB tracks the current status of all @@ -4445,7 +4598,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( batchSize int, changedDeclarations []*fleet.MDMAppleHostDeclaration, status *fleet.MDMDeliveryStatus, -) error { +) (updatedDB bool, err error) { baseStmt := ` INSERT INTO host_mdm_apple_declarations (host_uuid, status, operation_type, checksum, declaration_uuid, declaration_identifier, declaration_name) @@ -4457,7 +4610,50 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( checksum = VALUES(checksum) ` + profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + declaration_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + checksum, + declaration_uuid, + declaration_identifier, + declaration_name + FROM host_mdm_apple_declarations WHERE (host_uuid, declaration_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.DeclarationUUID) + } + var existingProfiles []fleet.MDMAppleHostDeclaration + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending declarations select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.DeclarationUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + clear(profilesToInsert) + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true _, err := tx.ExecContext( ctx, fmt.Sprintf(baseStmt, strings.TrimSuffix(valuePart, ",")), @@ -4467,13 +4663,23 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( } generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) { + profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{ + HostUUID: d.HostUUID, + DeclarationUUID: d.DeclarationUUID, + Name: d.Name, + Identifier: d.Identifier, + Status: status, + OperationType: d.OperationType, + Detail: d.Detail, + Checksum: d.Checksum, + } valuePart := "(?, ?, ?, ?, ?, ?, ?)," args := []any{d.HostUUID, status, d.OperationType, d.Checksum, d.DeclarationUUID, d.Identifier, d.Name} return valuePart, args } - err := batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) - return ctxerr.Wrap(ctx, err, "inserting changed host declaration state") + err = batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) + return updatedDB, ctxerr.Wrap(ctx, err, "inserting changed host declaration state") } // mdmAppleGetHostsWithChangedDeclarationsDB returns a diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 389d76e5cb..aba31a3c8e 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -1058,7 +1058,9 @@ func expectAppleDeclarations( var got []*fleet.MDMAppleDeclaration ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_apple_declarations WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT declaration_uuid, team_id, identifier, name, raw_json, checksum, created_at, uploaded_at FROM mdm_apple_declarations WHERE team_id = ?`, + tmID) }) // create map of expected declarations keyed by identifier @@ -4862,8 +4864,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-1", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, "not-exists") require.NoError(t, err) @@ -4880,8 +4885,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { }) require.NoError(t, err) nanoEnroll(t, ds, host1, true) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4894,8 +4902,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-2", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4906,8 +4917,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { err = ds.DeleteMDMAppleConfigProfile(ctx, decl.DeclarationUUID) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -6085,8 +6099,11 @@ func testMDMAppleProfilesOnIOSIPadOS(t *testing.T, ds *Datastore) { someProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("a", "a", 0)) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) profiles, err := ds.GetHostMDMAppleProfiles(ctx, "iOS0_UUID") require.NoError(t, err) diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go index af8200b2b0..21004cf83a 100644 --- a/server/datastore/mysql/calendar_events.go +++ b/server/datastore/mysql/calendar_events.go @@ -64,7 +64,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent( return ctxerr.Wrap(ctx, err, "insert calendar event") } - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { id, _ = result.LastInsertId() } else { stmt := `SELECT id FROM calendar_events WHERE email = ?` diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index eefce6091d..d3bbba82de 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -13,6 +13,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/jmoiron/sqlx" ) @@ -121,22 +122,26 @@ func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, c return &cmd, nil } -func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - if err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { +func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, + err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + if updates.WindowsConfigProfile, err = ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set windows profiles") } - if err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { + if updates.AppleConfigProfile, err = ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple profiles") } - if _, err := ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { + if _, updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple declarations") } return nil }) + return updates, err } func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { @@ -335,10 +340,12 @@ func (ds *Datastore) BulkSetPendingMDMHostProfiles( ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) +) (updates fleet.MDMProfilesUpdates, err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + updates, err = ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) + return err }) + return updates, err } // Note that team ID 0 is used for profiles that apply to hosts in no team @@ -349,7 +356,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( tx sqlx.ExtContext, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { +) (updates fleet.MDMProfilesUpdates, err error) { var ( countArgs int macProfUUIDs []string @@ -384,10 +391,10 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countArgs++ } if countArgs > 1 { - return errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") + return updates, errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") } if countArgs == 0 { - return nil + return updates, nil } var countProfUUIDs int @@ -401,7 +408,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countProfUUIDs++ } if countProfUUIDs > 1 { - return errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") + return updates, errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") } var ( @@ -471,10 +478,10 @@ WHERE if len(hosts) == 0 && !hasAppleDecls { uuidStmt, args, err := sqlx.In(uuidStmt, args...) if err != nil { - return ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") } if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") } } @@ -495,12 +502,14 @@ WHERE } } - if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") + updates.AppleConfigProfile, err = ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } - if err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") + updates.WindowsConfigProfile, err = ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } const defaultBatchSize = 1000 @@ -513,11 +522,12 @@ WHERE // (and my hunch is that we could even do the same for // profiles) but this could be optimized to use only a provided // set of host uuids. - if _, err := mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") + _, updates.AppleDeclaration, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") } - return nil + return updates, nil } func (ds *Datastore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify, toFail, toRetry []string) error { @@ -984,9 +994,9 @@ func batchSetProfileLabelAssociationsDB( tx sqlx.ExtContext, profileLabels []fleet.ConfigurationProfileLabel, platform string, -) error { +) (updatedDB bool, err error) { if len(profileLabels) == 0 { - return nil + return false, nil } var platformPrefix string @@ -1001,7 +1011,7 @@ func batchSetProfileLabelAssociationsDB( case "windows": platformPrefix = "windows" default: - return fmt.Errorf("unsupported platform %s", platform) + return false, fmt.Errorf("unsupported platform %s", platform) } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -1023,38 +1033,72 @@ func batchSetProfileLabelAssociationsDB( exclude = VALUES(exclude) ` + selectStmt := ` + SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels + WHERE (%s_profile_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) ) + labelsToInsert := make(map[string]*fleet.ConfigurationProfileLabel, len(profileLabels)) for i, pl := range profileLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &profileLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, platformPrefix, platformPrefix, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) - } - - return ctxerr.Wrap(ctx, err, "setting label associations for profile") + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") } - deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, deleteBuilder.String(), platformPrefix) + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) + } + + return false, ctxerr.Wrap(ctx, err, "setting label associations for profile") + } + updatedDB = true + } + + deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, selectOrDeleteBuilder.String(), platformPrefix) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -1064,13 +1108,18 @@ func batchSetProfileLabelAssociationsDB( deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for profiles") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) { diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 2f0d294121..a8d765baca 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -358,13 +359,15 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { wantApple []*fleet.MDMAppleConfigProfile, wantWindows []*fleet.MDMWindowsConfigProfile, wantAppleDecl []*fleet.MDMAppleDeclaration, + wantUpdates fleet.MDMProfilesUpdates, ) { ctx := context.Background() - err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) + updates, err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) require.NoError(t, err) expectAppleProfiles(t, ds, tmID, wantApple) expectWindowsProfiles(t, ds, tmID, wantWindows) expectAppleDeclarations(t, ds, tmID, wantAppleDecl) + assert.Equal(t, wantUpdates, updates) } withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { @@ -383,7 +386,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { } // empty set for no team (both Apple and Windows) - applyAndExpect(nil, nil, nil, nil, nil, nil, nil) + applyAndExpect(nil, nil, nil, nil, nil, nil, nil, fleet.MDMProfilesUpdates{}) // single Apple and Windows profile set for a specific team applyAndExpect( @@ -398,6 +401,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1), }, []*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // single Apple and Windows profile set for no team @@ -409,6 +413,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { []*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")}, []*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")}, []*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profile sets for a specific team @@ -438,6 +443,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D1", "D1", "foo"), 1), withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // edited profiles, unchanged profiles, and new profiles for a specific team @@ -473,6 +479,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), withTeamIDDecl(declForTest("D3", "D3", "bar"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profiles to no team @@ -502,10 +509,43 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { declForTest("D5", "D4", "foo"), declForTest("D4", "D5", "foo"), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) + + // Apply the same profiles again -- no update should be detected + applyAndExpect( + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + nil, + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: false}, ) // Test Case 8: Clear profiles for a specific team - applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil) + applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) } func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { @@ -1063,17 +1103,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // bulk set for no target ids, does nothing - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) + // bulk set for combination of target ids, not allowed - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) + _, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) require.Error(t, err) // bulk set for all created hosts, no profiles yet so nothing changed allHosts := append(darwinHosts, unenrolledHost, linuxHost) allHosts = append(allHosts, windowsHosts...) - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: {}, darwinHosts[1]: {}, @@ -1100,7 +1147,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G2w", "L2"), windowsConfigProfileForTest(t, "G3w", "L3"), } - err = ds.BatchSetMDMProfiles( + updates, err = ds.BatchSetMDMProfiles( ctx, nil, macGlobalProfiles, @@ -1113,6 +1160,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, macGlobalProfiles, 3) globalProfiles := getProfs(nil) require.Len(t, globalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) // list profiles to install, should result in the global profiles for all // enrolled hosts @@ -1132,8 +1182,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // bulk set for all created hosts, enrolled hosts get the no-team profiles - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1311,7 +1364,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[0], windowsHosts[0]), nil, @@ -1319,6 +1372,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1482,7 +1538,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host via its uuid (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -1490,6 +1546,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { []string{darwinHosts[1].UUID, windowsHosts[1].UUID}, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1620,8 +1679,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.1w", "T1.1"), windowsConfigProfileForTest(t, "T1.2w", "T1.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) tm1Profiles := getProfs(&team1.ID) require.Len(t, tm1Profiles, 4) @@ -1644,8 +1706,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1827,15 +1892,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) newTm1Profiles := getProfs(&team1.ID) require.Len(t, newTm1Profiles, 4) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -1974,6 +2045,13 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }, }) + // update again -- nothing should change + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + // re-add tm1Profiles[0] to list of team1 profiles (T1.1 on Apple, T1.2 on Windows) // NOTE: even though it is the same profile, it's unique DB ID is different because // it got deleted and re-inserted from the team's profiles, so this is reflected in @@ -1989,14 +2067,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) newTm1Profiles = getProfs(&team1.ID) require.Len(t, newTm1Profiles, 6) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2154,15 +2238,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // TODO(roberto): add new darwin declarations for this and all subsequent assertions - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) newGlobalProfiles := getProfs(nil) require.Len(t, newGlobalProfiles, 6) // update status of the affected "no-team" - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[0].UUID, nil)) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[1].UUID, nil)) @@ -2289,15 +2379,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G5w", "G5"), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // bulk-set only those affected by the new Apple global profile newDarwinProfileUUID := newGlobalProfiles[3].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2407,8 +2503,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // bulk-set only those affected by the new Apple global profile newWindowsProfileUUID := newGlobalProfiles[7].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2531,14 +2630,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.1w", "T2.1"), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles := getProfs(&team2.ID) require.Len(t, tm2Profiles, 2) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status via tm2 id and the global 0 id to test that custom sql statement - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2714,7 +2819,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G7w", "G7", labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 12) @@ -2723,6 +2828,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[5], labels[2]) setProfileLabels(t, newGlobalProfiles[10], labels[3], labels[4]) setProfileLabels(t, newGlobalProfiles[11], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // simulate an entry with some values set to NULL ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -2737,7 +2845,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a sync of all hosts, should not change anything as no host is a member // of the new label-based profiles (indices change due to new Apple and // Windows profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2746,6 +2854,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2912,7 +3023,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a full sync, the new global hosts get the standard global profiles and // also the label-based profile that they are a member of - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2921,6 +3032,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3117,7 +3231,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // do a sync of those hosts, they will get the two label-based profiles of their platform - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3125,6 +3239,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3327,7 +3444,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, ds.DeleteLabel(ctx, labels[3].Name)) // sync the affected profiles - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3335,7 +3452,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3343,6 +3463,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // nothing changes - broken label-based profiles are simply ignored assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -3551,7 +3674,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3559,6 +3682,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3756,7 +3882,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[4], labels[1]) setProfileLabels(t, newGlobalProfiles[10], labels[4]) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3764,7 +3890,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3772,6 +3901,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3969,18 +4101,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.2w", "T2.2", labels[4], labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles = getProfs(&team2.ID) require.Len(t, tm2Profiles, 4) // TODO(mna): temporary until BatchSetMDMProfiles supports labels setProfileLabels(t, tm2Profiles[1], labels[1], labels[2]) setProfileLabels(t, tm2Profiles[3], labels[4], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // sync team 2, no changes because no host is a member of the labels (except // index change due to new profiles) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4178,8 +4316,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // sync team 2, the label-based profile of team2 is now pending install - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4388,8 +4529,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is left untouched (broken // profiles are ignored) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4603,8 +4747,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is still left untouched // because even if the hosts are not members anymore, the profile is broken - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4808,8 +4955,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, now it sees that the hosts are not members and the profile // gets removed - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5003,7 +5153,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) // sanity-check, a full sync does not change anything - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -5012,6 +5162,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5237,8 +5390,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) configProfileForTest(t, "T1.2", "T1.2", "e"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5275,8 +5431,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_1"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5354,8 +5513,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_3"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5444,8 +5606,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel4, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_4"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5524,8 +5689,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) windowsConfigProfileForTest(t, "T5.2", "T5.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5562,8 +5730,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_6"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5641,8 +5812,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5731,8 +5905,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5947,18 +6124,16 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { wantOtherWin := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherWinProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"), - ) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows") + require.NoError(t, err) + assert.True(t, updatedDB) // make it an "exclude" label on the other macos profile wantOtherMac := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin"), - ) + updatedDB, err = batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin") + require.NoError(t, err) + assert.True(t, updatedDB) platforms := map[string]string{ "darwin": macOSProfile.ProfileUUID, @@ -5991,7 +6166,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("empty input "+platform, func(t *testing.T) { want := []fleet.ConfigurationProfileLabel{} err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + require.NoError(t, err) + assert.False(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, want) @@ -6005,7 +6183,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6018,7 +6199,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6033,7 +6217,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6044,7 +6229,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: 12345}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) @@ -6053,7 +6239,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: "xyz", LabelID: 1235}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6074,7 +6261,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) // both are stored in the DB @@ -6085,7 +6275,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6098,12 +6291,13 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("unsupported platform", func(t *testing.T) { err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB( + _, err := batchSetProfileLabelAssociationsDB( ctx, tx, []fleet.ConfigurationProfileLabel{{}}, "unsupported", ) + return err }) require.Error(t, err) }) @@ -6185,7 +6379,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "W2", "l2"), } // set the initial profiles without error - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.NoError(t, err) // now ensure all steps are required (add a profile, delete a profile, set labels) @@ -6201,7 +6395,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { ds.testBatchSetMDMAppleProfilesErr = c.appleErr ds.testBatchSetMDMWindowsProfilesErr = c.windowsErr - err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.ErrorContains(t, err, c.wantErr) }) } @@ -7139,8 +7333,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { declForTest("D1", "D1", "{}", labels[3], labels[4], labels[5]), } - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) + updates, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) // must reload them to get the profile/declaration uuid getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload { @@ -7185,8 +7382,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { // do a sync, they get all platform-specific profiles since they are not part // of any label - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7225,8 +7425,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7257,8 +7460,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7293,8 +7499,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { err = ds.DeleteLabel(ctx, labels[3].Name) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to remove" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -7345,8 +7554,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnroll(t, ds, appleHost2, false) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to install" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index b7426bff00..ce4d73a9da 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -1581,7 +1581,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } @@ -1653,7 +1653,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMWindowsConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT name, @@ -1721,13 +1721,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1748,40 +1748,48 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepNames) + var result sql.Result if len(keepNames) > 0 { stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, keepNames) if err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") } } else { - if _, err := tx.ExecContext(ctx, deleteAllProfilesForTeam, profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, deleteAllProfilesForTeam, + profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete all profiles for team") + return false, ctxerr.Wrap(ctx, err, "delete all profiles for team") } } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 + } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, + p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1797,19 +1805,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Name] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1825,47 +1833,56 @@ ON DUPLICATE KEY UPDATE } // insert/delete the label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } profilesToInstall, err := listMDMWindowsProfilesToInstallDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to install") + return false, ctxerr.Wrap(ctx, err, "list profiles to install") } profilesToRemove, err := listMDMWindowsProfilesToRemoveDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to remove") + return false, ctxerr.Wrap(ctx, err, "list profiles to remove") } if len(profilesToInstall) == 0 && len(profilesToRemove) == 0 { - return nil + return false, nil } - if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + if len(profilesToRemove) > 0 { + if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + } + updatedDB = true + } + if len(profilesToInstall) == 0 { + return updatedDB, nil } var ( - pargs []any - psb strings.Builder - batchCount int + pargs []any + profilesToInsert = make(map[string]*fleet.MDMWindowsProfilePayload) + psb strings.Builder + batchCount int ) const defaultBatchSize = 1000 @@ -1877,10 +1894,48 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( resetBatch := func() { batchCount = 0 pargs = pargs[:0] + clear(profilesToInsert) psb.Reset() } executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + profile_uuid, + host_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + COALESCE(command_uuid, '') AS command_uuid, + COALESCE(profile_name, '') AS profile_name + FROM host_mdm_windows_profiles WHERE (profile_uuid, host_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.ProfileUUID, p.HostUUID) + } + var existingProfiles []fleet.MDMWindowsProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.ProfileUUID, exist.HostUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_windows_profiles ( profile_uuid, @@ -1898,11 +1953,25 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( detail = '' `, strings.TrimSuffix(valuePart, ",")) - _, err = tx.ExecContext(ctx, baseStmt, args...) - return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + _, err := tx.ExecContext(ctx, baseStmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + } + updatedDB = true + return nil } for _, p := range profilesToInstall { + profilesToInsert[fmt.Sprintf("%s\n%s", p.ProfileUUID, p.HostUUID)] = &fleet.MDMWindowsProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + Status: nil, + OperationType: fleet.MDMOperationTypeInstall, + Detail: p.Detail, + CommandUUID: p.CommandUUID, + Retries: p.Retries, + } pargs = append( pargs, p.ProfileUUID, p.HostUUID, p.ProfileName, fleet.MDMOperationTypeInstall) @@ -1910,7 +1979,7 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( batchCount++ if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1918,11 +1987,11 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } func (ds *Datastore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 02a4c9d149..263a6e5491 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1885,7 +1886,7 @@ func testSetOrReplaceMDMWindowsConfigProfile(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -1983,7 +1984,9 @@ func expectWindowsProfiles( var got []*fleet.MDMWindowsConfigProfile ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + tmID) }) // create map of expected profiles keyed by name @@ -2025,9 +2028,13 @@ func expectWindowsProfiles( func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { ctx := context.Background() - applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile) map[string]string { + applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile, + wantUpdated bool) map[string]string { err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + updatedDB, err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + require.NoError(t, err) + assert.Equal(t, wantUpdated, updatedDB) + return err }) require.NoError(t, err) return expectWindowsProfiles(t, ds, tmID, want) @@ -2041,7 +2048,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -2057,14 +2064,14 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } // apply empty set for no-team - applyAndExpect(nil, nil, nil) + applyAndExpect(nil, nil, nil, false) // apply single profile set for tm1 mTm1 := applyAndExpect([]*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), - }) + }, true) profTm1N1 := getProfileByTeamAndName(ptr.Uint(1), "N1") // apply single profile set for no-team @@ -2072,7 +2079,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "N1", "l1"), }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), - }) + }, true) // wait a second to ensure timestamps in the DB change time.Sleep(time.Second) @@ -2084,7 +2091,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), profTm1N1.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1["I1"], mTm1b["I1"]) profTm1N2 := getProfileByTeamAndName(ptr.Uint(1), "N2") @@ -2102,7 +2109,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { withTeamID(windowsConfigProfileForTest(t, "N1", "l1b"), 1), withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), profTm1N2.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N3", "l3"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1b["I1"], mTm1c["I1"]) // uuid for N2-I2 is unchanged @@ -2119,10 +2126,19 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N4", "l4"), windowsConfigProfileForTest(t, "N5", "l5"), - }) + }, true) + + // apply the same thing again -- nothing updated + applyAndExpect([]*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, nil, []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, false) // clear profiles for tm1 - applyAndExpect(nil, ptr.Uint(1), nil) + applyAndExpect(nil, ptr.Uint(1), nil, true) } // if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise diff --git a/server/datastore/mysql/migrations/tables/20240827132940_AddAutoIncrementColumnToProfiles.go b/server/datastore/mysql/migrations/tables/20240827132940_AddAutoIncrementColumnToProfiles.go new file mode 100644 index 0000000000..595421ec3c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240827132940_AddAutoIncrementColumnToProfiles.go @@ -0,0 +1,40 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240827132940, Down_20240827132940) +} + +func Up_20240827132940(tx *sql.Tx) error { + // The AUTO_INCREMENT columns are used to determine if a row was updated by an INSERT ... ON DUPLICATE KEY UPDATE statement. + // This is needed because we are currently using CLIENT_FOUND_ROWS option to determine if a row was found. + // And in order to find if the row was updated, we need to check LAST_INSERT_ID(). + // MySQL docs: https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html + + if !columnExists(tx, "mdm_windows_configuration_profiles", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_windows_configuration_profiles +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_windows_configuration_profiles: %w", err) + } + } + + if !columnExists(tx, "mdm_apple_declarations", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_apple_declarations +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_apple_declarations: %w", err) + } + } + return nil +} + +func Down_20240827132940(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 568761b42e..3acfa46f63 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1242,21 +1242,7 @@ func (ds *Datastore) ProcessList(ctx context.Context) ([]fleet.MySQLProcess, err return processList, nil } -func insertOnDuplicateDidInsert(res sql.Result) bool { - // Note that connection string sets CLIENT_FOUND_ROWS (see - // generateMysqlConnectionString in this package), so LastInsertId is 0 - // and RowsAffected 1 when a row is set to its current values. - // - // See [the docs][1] or @mna's comment in `insertOnDuplicateDidUpdate` - // below for more details - // - // [1]: https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html - lastID, _ := res.LastInsertId() - affected, _ := res.RowsAffected() - return lastID != 0 && affected == 1 -} - -func insertOnDuplicateDidUpdate(res sql.Result) bool { +func insertOnDuplicateDidInsertOrUpdate(res sql.Result) bool { // From mysql's documentation: // // With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if @@ -1266,7 +1252,10 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { // connecting to mysqld, the affected-rows value is 1 (not 0) if an // existing row is set to its current values. // - // https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html + // If a table contains an AUTO_INCREMENT column and INSERT ... ON DUPLICATE KEY UPDATE + // inserts or updates a row, the LAST_INSERT_ID() function returns the AUTO_INCREMENT value. + // + // https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html // // Note that connection string sets CLIENT_FOUND_ROWS (see // generateMysqlConnectionString in this package), so it does return 1 when @@ -1281,7 +1270,8 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { lastID, _ := res.LastInsertId() aff, _ := res.RowsAffected() - return lastID == 0 || aff != 1 + // something was updated (lastID != 0) AND row was found (aff == 1 or higher if more rows were found) + return lastID != 0 && aff > 0 } type parameterizedStmt struct { diff --git a/server/datastore/mysql/operating_system_vulnerabilities.go b/server/datastore/mysql/operating_system_vulnerabilities.go index c7598e0464..8a6a30dec0 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities.go +++ b/server/datastore/mysql/operating_system_vulnerabilities.go @@ -123,7 +123,7 @@ func (ds *Datastore) InsertOSVulnerability(ctx context.Context, v fleet.OSVulner return false, ctxerr.Wrap(ctx, err, "insert operating system vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) DeleteOSVulnerabilities(ctx context.Context, vulnerabilities []fleet.OSVulnerability) error { diff --git a/server/datastore/mysql/operating_system_vulnerabilities_test.go b/server/datastore/mysql/operating_system_vulnerabilities_test.go index 715d8eada3..0b477091cb 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities_test.go +++ b/server/datastore/mysql/operating_system_vulnerabilities_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -233,10 +234,15 @@ func testInsertOSVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, didInsert) - // Inserting the same vulnerability should not insert - didInsert, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + // Inserting the same vulnerability should not insert, but update + didInsertOrUpdate, err := ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) require.NoError(t, err) - require.Equal(t, false, didInsert) + assert.True(t, didInsertOrUpdate) + + // Inserting the exact same vulnerability again should not insert and not update + didInsertOrUpdate, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + require.NoError(t, err) + assert.False(t, didInsertOrUpdate) expected := vulnsUpdate expected.Source = fleet.MSRCSource diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 1ee3c2f2c9..631eb0f9af 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -802,7 +802,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") } - if insertOnDuplicateDidUpdate(res) { + if insertOnDuplicateDidInsertOrUpdate(res) { // when the upsert results in an UPDATE that *did* change some values, // it returns the updated ID as last inserted id. if lastID, _ := res.LastInsertId(); lastID > 0 { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 1a5d7cb3fa..d079b41c5f 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -810,10 +810,12 @@ CREATE TABLE `mdm_apple_declarations` ( `checksum` binary(16) NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`declaration_uuid`), UNIQUE KEY `idx_mdm_apple_declaration_team_identifier` (`team_id`,`identifier`), - UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -994,8 +996,10 @@ CREATE TABLE `mdm_windows_configuration_profiles` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, `profile_uuid` varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`profile_uuid`), - UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`) + UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; @@ -1030,9 +1034,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=308 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=309 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170024,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240827132940,1,'2020-01-01 01:01:01'),(303,20240829165448,1,'2020-01-01 01:01:01'),(304,20240829165605,1,'2020-01-01 01:01:01'),(305,20240829165715,1,'2020-01-01 01:01:01'),(306,20240829165930,1,'2020-01-01 01:01:01'),(307,20240829170023,1,'2020-01-01 01:01:01'),(308,20240829170024,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index d3c79f7e25..4826e0ceba 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2020,7 +2020,7 @@ func (ds *Datastore) InsertSoftwareVulnerability( return false, ctxerr.Wrap(ctx, err, "insert software vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) ListSoftwareVulnerabilitiesByHostIDsSource( diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index bcb5eb41e7..1d034eb42b 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -1958,11 +1958,14 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ SoftwareID: host.Software[0].ID, CVE: "cve-1", }, fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -2001,9 +2004,12 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -2567,9 +2573,9 @@ func testDeleteOutOfDateVulnerabilities(t *testing.T, ds *Datastore) { require.NoError(t, err) // This should update the 'updated_at' timestamp. - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) require.NoError(t, err) - require.False(t, inserted) + assert.True(t, insertedOrUpdated) err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, 2*time.Hour) require.NoError(t, err) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 29424190a4..9a3bb005f8 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -1,6 +1,7 @@ package fleet import ( + "bytes" "context" "crypto/md5" // nolint: gosec "encoding/hex" @@ -204,6 +205,13 @@ type MDMAppleConfigProfile struct { UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change } +// MDMProfilesUpdates flags updates that were done during batch processing of profiles. +type MDMProfilesUpdates struct { + AppleConfigProfile bool + WindowsConfigProfile bool + AppleDeclaration bool +} + // ConfigurationProfileLabel represents the many-to-many relationship between // profiles and labels. // @@ -309,6 +317,20 @@ func (p *MDMAppleProfilePayload) FailedToInstallOnHost() bool { return p.Status != nil && *p.Status == MDMDeliveryFailed && p.OperationType == MDMOperationTypeInstall } +func (p MDMAppleProfilePayload) Equal(other MDMAppleProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return p.ProfileUUID == other.ProfileUUID && + p.ProfileIdentifier == other.ProfileIdentifier && + p.ProfileName == other.ProfileName && + p.HostUUID == other.HostUUID && + p.HostPlatform == other.HostPlatform && + bytes.Equal(p.Checksum, other.Checksum) && + statusEqual && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID +} + type MDMAppleBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileIdentifier string @@ -660,6 +682,18 @@ type MDMAppleHostDeclaration struct { Checksum string `db:"checksum" json:"-"` } +func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.HostUUID == other.HostUUID && + p.DeclarationUUID == other.DeclarationUUID && + p.Name == other.Name && + p.Identifier == other.Identifier && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.Checksum == other.Checksum +} + func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration { var decl MDMAppleDeclaration diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index 16799f6cc3..f31796d4a4 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -7,18 +7,20 @@ import ( "crypto/x509" "encoding/json" "fmt" + "reflect" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mozilla.org/pkcs7" - - "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" ) func TestMDMAppleConfigProfile(t *testing.T) { @@ -416,3 +418,199 @@ func TestMDMProfileIsWithinGracePeriod(t *testing.T) { }) } } + +func TestMDMAppleHostDeclarationEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleHostDeclaration is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleHostDeclaration{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].DeclarationUUID = items[0].DeclarationUUID + fieldsInEqualMethod++ + items[1].Name = items[0].Name + fieldsInEqualMethod++ + items[1].Identifier = items[0].Identifier + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].Checksum = items[0].Checksum + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers to nil + items[0].Status = nil + items[1].Status = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestMDMAppleProfilePayloadEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleProfilePayload is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleProfilePayload{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + case reflect.Slice: + switch field.Type().Elem().Kind() { + case reflect.Uint8: + valueToSet := []byte("test") + field.Set(reflect.ValueOf(valueToSet)) + default: + t.Fatalf("unhandled slice type %s", field.Type().Elem().Kind()) + } + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + checksum0 := []byte("checksum") + checksum1 := []byte("checksum") + items[0].Checksum = checksum0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].ProfileIdentifier = items[0].ProfileIdentifier + fieldsInEqualMethod++ + items[1].ProfileName = items[0].ProfileName + fieldsInEqualMethod++ + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].HostPlatform = items[0].HostPlatform + fieldsInEqualMethod++ + items[1].Checksum = checksum1 + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].CommandUUID = items[0].CommandUUID + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleProfilePayload.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers and slices to nil + items[0].Status = nil + items[1].Status = nil + items[0].Checksum = nil + items[1].Checksum = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestConfigurationProfileLabelEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the cmp.Equal method on ConfigurationProfileLabel is updated when new fields are added. + // The cmp.Equal method is used to identify whether database update is needed. + + items := [...]ConfigurationProfileLabel{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Uint: + field.SetUint(uint64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + assert.False(t, cmp.Equal(items[0], items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].LabelName = items[0].LabelName + fieldsInEqualMethod++ + items[1].LabelID = items[0].LabelID + fieldsInEqualMethod++ + items[1].Broken = items[0].Broken + fieldsInEqualMethod++ + items[1].Exclude = items[0].Exclude + fieldsInEqualMethod++ + + assert.Equal(t, fieldsInEqualMethod, numberOfFields, + "Does cmp.Equal for ConfigurationProfileLabel needs to be updated for new/updated field(s)?") + assert.True(t, cmp.Equal(items[0], items[1])) + +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 764c6d2d6a..76d121e8ce 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1163,7 +1163,9 @@ type Datastore interface { // remove for each affected host to pending for the provided criteria, which // may be either a list of hostIDs, teamIDs, profileUUIDs or hostUUIDs (only // one of those ID types can be provided). - BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error + BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, + profileUUIDs, hostUUIDs []string) (updates MDMProfilesUpdates, + err error) // GetMDMAppleProfilesContents retrieves the XML contents of the // profiles requested. @@ -1515,7 +1517,8 @@ type Datastore interface { // BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or // no team in a single transaction. - BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, macDeclarations []*MDMAppleDeclaration) error + BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, + macDeclarations []*MDMAppleDeclaration) (updates MDMProfilesUpdates, err error) // NewMDMAppleDeclaration creates and returns a new MDM Apple declaration. NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error) diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 4487765008..acc90b3a51 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -158,6 +158,18 @@ type MDMWindowsProfilePayload struct { Retries int `db:"retries"` } +func (p MDMWindowsProfilePayload) Equal(other MDMWindowsProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.ProfileUUID == other.ProfileUUID && + p.HostUUID == other.HostUUID && + p.ProfileName == other.ProfileName && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID && + p.Retries == other.Retries +} + type MDMWindowsBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileName string diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 7f72c1e738..1a522ef026 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -780,7 +780,7 @@ type ListMDMAppleProfilesToRemoveFunc func(ctx context.Context) ([]*fleet.MDMApp type BulkUpsertMDMAppleHostProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error -type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error +type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) type GetMDMAppleProfilesContentsFunc func(ctx context.Context, profileUUIDs []string) (map[string]mobileconfig.Mobileconfig, error) @@ -972,7 +972,7 @@ type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindow type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error -type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error +type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) @@ -5304,7 +5304,7 @@ func (s *DataStore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload return s.BulkUpsertMDMAppleHostProfilesFunc(ctx, payload) } -func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { +func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BulkSetPendingMDMHostProfilesFuncInvoked = true s.mu.Unlock() @@ -5976,7 +5976,7 @@ func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp f return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp) } -func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { +func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BatchSetMDMProfilesFuncInvoked = true s.mu.Unlock() diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 4a02bd608e..1131a84b2d 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -380,7 +380,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r } return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -470,7 +470,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r i return nil, err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } @@ -773,7 +773,7 @@ func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID return ctxerr.Wrap(ctx, err) } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -853,7 +853,7 @@ func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID stri return ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1978,7 +1978,7 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 718bfd183c..a8b20b04ec 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -599,8 +599,9 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) { ds.GetMDMAppleProfilesSummaryFunc = func(context.Context, *uint) (*fleet.MDMProfilesSummary, error) { return nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } mockGetFuncWithTeamID := func(teamID uint) mock.GetMDMAppleConfigProfileFunc { return func(ctx context.Context, puid string) (*fleet.MDMAppleConfigProfile, error) { @@ -706,8 +707,9 @@ func TestNewMDMAppleConfigProfile(t *testing.T) { ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, false) @@ -1499,8 +1501,9 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil @@ -1815,8 +1818,9 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil diff --git a/server/service/client.go b/server/service/client.go index b811277e38..3ae6def1b3 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1285,9 +1285,13 @@ func (c *Client) DoGitOps( team["webhook_settings"] = map[string]interface{}{} clearHostStatusWebhook := true if webhookSettings, ok := config.TeamSettings["webhook_settings"]; ok { - if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { - clearHostStatusWebhook = false - team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + if _, ok := webhookSettings.(map[string]interface{}); ok { + if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { + clearHostStatusWebhook = false + team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + } + } else if webhookSettings != nil { + return nil, fmt.Errorf("team_settings.webhook_settings config is not a map but a %T", webhookSettings) } } if clearHostStatusWebhook { diff --git a/server/service/hosts.go b/server/service/hosts.go index 359799b050..a4db5b73dc 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -826,7 +826,7 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return err } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } @@ -962,7 +962,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, fi if err := svc.ds.AddHostsToTeam(ctx, teamID, hostIDs); err != nil { return err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 1e9e663ed6..d44f9fcf4d 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -610,8 +610,9 @@ func TestHostAuth(t *testing.T) { } return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -889,8 +890,9 @@ func TestAddHostsToTeamByFilter(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -931,8 +933,9 @@ func TestAddHostsToTeamByFilterLabel(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -963,8 +966,9 @@ func TestAddHostsToTeamByFilterEmptyHosts(t *testing.T) { ds.AddHostsToTeamFunc = func(ctx context.Context, teamID *uint, hostIDs []uint) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } emptyFilter := &map[string]interface{}{} diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 597f6e4f3d..caae54a183 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -3781,17 +3781,18 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity items + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, @@ -4061,12 +4062,13 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ef662a0589..7a1dcfd9f1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -4426,7 +4426,9 @@ func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint } var cfgProfs []*fleet.MDMWindowsConfigProfile mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID) + return sqlx.SelectContext(context.Background(), q, &cfgProfs, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + teamID) }) label := "exist" diff --git a/server/service/mdm.go b/server/service/mdm.go index 1b9e28c457..db6ce00944 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1167,7 +1167,7 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1412,7 +1412,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1600,7 +1600,8 @@ func (svc *Service) BatchSetMDMProfiles( return nil } - if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { + var profUpdates fleet.MDMProfilesUpdates + if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { return ctxerr.Wrap(ctx, err, "setting config profiles") } @@ -1609,7 +1610,8 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range windowsProfiles { winProfUUIDs = append(winProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil); err != nil { + winUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } @@ -1618,33 +1620,42 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range appleProfiles { appleProfUUIDs = append(appleProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil); err != nil { + appleUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } + updates := fleet.MDMProfilesUpdates{ + AppleConfigProfile: profUpdates.AppleConfigProfile || winUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile, + WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile || appleUpdates.WindowsConfigProfile, + AppleDeclaration: profUpdates.AppleDeclaration || winUpdates.AppleDeclaration || appleUpdates.AppleDeclaration, + } - // TODO(roberto): should we generate activities only of any profiles were - // changed? this is the existing behavior for macOS profiles so I'm - // leaving it as-is for now. - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + if updates.AppleConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + if updates.WindowsConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + if updates.AppleDeclaration { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + } } return nil diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 435126d2b1..731184726e 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1068,8 +1068,10 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { ds.ListMDMConfigProfilesFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } checkShouldFail := func(t *testing.T, err error, shouldFail bool) { @@ -1142,8 +1144,10 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { cp.ProfileUUID = uuid.New().String() return &cp, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cases := []struct { @@ -1225,16 +1229,20 @@ func TestMDMBatchSetProfiles(t *testing.T) { ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { return &fleet.Team{ID: id, Name: "team"}, nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } testCases := []struct { diff --git a/server/service/teams_test.go b/server/service/teams_test.go index 9011e8bce7..cf71aab795 100644 --- a/server/service/teams_test.go +++ b/server/service/teams_test.go @@ -53,8 +53,9 @@ func TestTeamAuth(t *testing.T) { ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { return []*fleet.Host{}, nil diff --git a/server/service/testing_client.go b/server/service/testing_client.go index ba0a336fdb..702d85b495 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -495,3 +495,24 @@ func (ts *withServer) lastActivityOfTypeMatches(name, details string, id uint) u t.Fatalf("no activity of type %s found in the last %d activities", name, len(listActivities.Activities)) return 0 } + +func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id uint) { + t := ts.s.T() + + var listActivities listActivitiesResponse + ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, + &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10") + require.True(t, len(listActivities.Activities) > 0) + + for _, act := range listActivities.Activities { + if act.Type == name { + if details != "" { + require.NotNil(t, act.Details) + assert.NotEqual(t, details, string(*act.Details)) + } + if id > 0 { + assert.NotEqual(t, id, act.ID) + } + } + } +} From 50cc41c7f9fb41007019152cf9f3cd18c50b50f8 Mon Sep 17 00:00:00 2001 From: JoGSal <59185898+JoGSal@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:06:55 -0700 Subject: [PATCH 10/21] Documentation: Internationalize Render deployment guide (#20725) Row 11: Internationalize Render deployment guide. Updated language to reflect need to be aware of regional settings when outside the United States. # Checklist for submitter If some of the following don't apply, delete the relevant line. Co-authored-by: Eric --- docs/Deploy/deploy-fleet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Deploy/deploy-fleet.md b/docs/Deploy/deploy-fleet.md index c0e80f4b2e..ee5299bfc3 100644 --- a/docs/Deploy/deploy-fleet.md +++ b/docs/Deploy/deploy-fleet.md @@ -43,7 +43,7 @@ Render is a cloud hosting service that makes it easy to get up and running fast,
-1. Click "Deploy to Render" to open the Fleet Blueprint on Render. You will be prompted to create or log in to your Render account with associated payment information. +1. Click "Deploy to Render" to open the Fleet Blueprint on Render. Ensure that the Redis instance is manually set to the same region as your other resources. You will be prompted to create or log in to your Render account with associated payment information. 2. Give the Blueprint a unique name like `yourcompany-fleet`. From 29af66076aad8d807a4c1a1b91aa509b494bef26 Mon Sep 17 00:00:00 2001 From: Ian Littman Date: Fri, 30 Aug 2024 16:12:43 -0500 Subject: [PATCH 11/21] Add docs for --dev default MinIO buckets (#21721) I noticed this wasn't documented when i went hunting for what I figured were defaults baked into `--dev`; thanks @jahzielv for pointing me in the right direction! --- docs/Contributing/File-carving.md | 7 ++++++- docs/Contributing/Testing-and-local-development.md | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/Contributing/File-carving.md b/docs/Contributing/File-carving.md index 4daad97fb0..b283f11f4c 100644 --- a/docs/Contributing/File-carving.md +++ b/docs/Contributing/File-carving.md @@ -77,7 +77,7 @@ The same is not true if S3 is used as the storage backend. In that scenario, it ### Alternative carving backends -#### Minio +#### MinIO Configure the following: - `FLEET_S3_ENDPOINT_URL=minio_host:port` @@ -87,6 +87,11 @@ Configure the following: - `FLEET_S3_FORCE_S3_PATH_STYLE=true` - `FLEET_S3_REGION=minio` or any non-empty string otherwise Fleet will attempt to derive the region. +If you're testing file carving locally with the docker-compose environment, the `--dev` flag on Fleet server will +automatically point carves to the local MinIO container and write to the `carves-dev` bucket without needing to set +additional configuration. Note that this bucket is *not* created automatically when bringing MinIO up; you'll need to +log in via `http://localhost:9001` with credentials `minio` / `minio123!` to create the bucket. + ### Troubleshooting #### Check carve status in osquery diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md index 8f6bc0ce80..45c5a93fe0 100644 --- a/docs/Contributing/Testing-and-local-development.md +++ b/docs/Contributing/Testing-and-local-development.md @@ -489,7 +489,9 @@ FLEET_SERVER_SANDBOX_ENABLED=1 FLEET_PACKAGING_GLOBAL_ENROLL_SECRET=xyz ./build Be sure to replace the `FLEET_PACKAGING_GLOBAL_ENROLL_SECRET` value above with the global enroll secret from the `fleetctl package` command used to build the installers. -MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. +MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. When starting the +Fleet server up with `--dev` the server will look for installers in the `software-installers-dev` MinIO bucket. You can +create this bucket via the MinIO web UI (it is *not* created by default when setting up the docker-compose environment). ## Telemetry From 6ed3ce84391e0617f313c23ba138eec8b8d316ed Mon Sep 17 00:00:00 2001 From: Savannah Friend <157323611+SFriendLee@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:26:58 -0500 Subject: [PATCH 12/21] Add "Complete Digital Experience KPIs" to digital-experience.rituals.yml (#21726) --- .../digital-experience/digital-experience.rituals.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index 64224badb1..5e2e58a922 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -1,5 +1,14 @@ # https://github.com/fleetdm/fleet/pull/13084 - +- + task: "Complete Digital Experience KPIs" + startedOn: "2024-08-30" + frequency: "Weekly" + description: "Complete Digital Experience KPIs for this week" + moreInfoUrl: "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0&range=DB1" + dri: "SFriendLee" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "fleet" - task: "Prep 1:1s for OKR planning" startedOn: "2024-09-09" From 5b2f8d75ef7136673298792f6af172dc509f3315 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 30 Aug 2024 16:34:06 -0500 Subject: [PATCH 13/21] Website: Update contacts created by talk to us form submissions (>700 hosts) (#21671) Closes: https://github.com/fleetdm/confidential/issues/7815 Changes: - Updated deliver-talk-to-us-form-submission to set contacts who fill out the "talk to us form" to book a meeting as Psychological stage 4. --- .../deliver-talk-to-us-form-submission.js | 19 +++++++++++++++---- ...ate-contact-and-account-and-create-lead.js | 7 ++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/website/api/controllers/deliver-talk-to-us-form-submission.js b/website/api/controllers/deliver-talk-to-us-form-submission.js index af26395dc1..aae36db70f 100644 --- a/website/api/controllers/deliver-talk-to-us-form-submission.js +++ b/website/api/controllers/deliver-talk-to-us-form-submission.js @@ -73,17 +73,28 @@ module.exports = { if(_.includes(sails.config.custom.bannedEmailDomainsForWebsiteSubmissions, emailDomain.toLowerCase())){ throw 'invalidEmailDomain'; } - + // Set a default psychological stage and change reason. + let psyStageAndChangeReason = { + psychologicalStage: '4 - Has use case', + psychologicalStageChangeReason: 'Website - Contact forms' + }; + if(this.req.me){ + // If this user is logged in, check their current psychological stage, and if it is higher than 4, we won't set a psystage. + // This way, if a user has a psytage >4, we won't regress their psystage because they submitted this form. + if(['4 - Has use case', '5 - Personally confident', '6 - Has team buy-in'].includes(this.req.me.psychologicalStage)) { + psyStageAndChangeReason = {}; + } + } if(numberOfHosts >= 700){ - sails.helpers.salesforce.updateOrCreateContactAndAccountAndCreateLead.with({ + sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ emailAddress: emailAddress, firstName: firstName, lastName: lastName, organization: organization, - numberOfHosts: numberOfHosts, primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, contactSource: 'Website - Contact forms', - leadDescription: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + ...psyStageAndChangeReason// Only (potentially) set psystage and change reason for >700 hosts. }).exec((err)=>{ if(err) { sails.log.warn(`Background task failed: When a user submitted the "Talk to us" form, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err); diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js index 980ffae8c0..05e9b29c95 100644 --- a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js @@ -30,6 +30,10 @@ module.exports = { '6 - Has team buy-in' ] }, + psychologicalStageChangeReason: { + type: 'string', + example: 'Website - Organic start flow' + }, // For new leads. leadDescription: { type: 'string', @@ -58,7 +62,7 @@ module.exports = { - fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, contactSource, leadDescription, numberOfHosts}) { + fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, leadDescription, numberOfHosts}) { if(sails.config.environment !== 'production') { sails.log('Skipping Salesforce integration...'); return; @@ -72,6 +76,7 @@ module.exports = { linkedinUrl, primaryBuyingSituation, psychologicalStage, + psychologicalStageChangeReason, contactSource, description: leadDescription, }); From ee7b05cb26782a98b7a369464fb9b72b0165cfea Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 30 Aug 2024 16:54:08 -0500 Subject: [PATCH 14/21] Website: Replace eo-it quote (#21734) Closes: #21695 Changes: - Removed the quote from Harrison in testimonials.yml - Updated the order of quotes on the endpoint ops page - Replaced the quote from Harrison on the /contact and /signup pages --- handbook/company/testimonials.yml | 9 --------- website/api/controllers/view-endpoint-ops.js | 4 ++-- website/views/pages/contact.ejs | 10 +++++----- website/views/pages/entrance/signup.ejs | 8 ++++---- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index 96fefc9427..909daf02e3 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -141,15 +141,6 @@ quoteAuthorProfileImageFilename: testimonial-author-dhruv-majumdar-48x48@2x.png quoteAuthorJobTitle: Director Of Cyber Risk & Advisory productCategories: [Vulnerability management, Endpoint operations] -- - quote: When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. - quoteImageFilename: logo-deputy-118x28@2x.png - quoteAuthorName: Harrison Ravazzolo - quoteAuthorProfileImageFilename: testimonial-author-harrison-ravazzolo-48x48@2x.png - quoteLinkUrl: https://www.linkedin.com/in/harrison-ravazzolo/ - quoteAuthorJobTitle: Lead platform and identity engineer - youtubeVideoUrl: https://www.youtube.com/watch?v=5W0q5yQE3R0 - productCategories: [Endpoint operations] - quote: Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didn’t expect. It spread so naturally, even our corporate and infrastructure teams want to run it. quoteAuthorName: Charles Zaffery diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index df2fb2680d..fbeeda63ae 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -35,9 +35,9 @@ module.exports = { // Specify an order for the testimonials on this page using the last names of quote authors - let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Harrison Ravazzolo','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; + let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; if(['eo-it', 'mdm'].includes(pagePersonalization)){ - testimonialOrderForThisPage = [ 'Harrison Ravazzolo', 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; + testimonialOrderForThisPage = [ 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; } else if(['eo-security', 'vm'].includes(pagePersonalization)){ testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar','Alvaro Gutierrez', 'Joe Pistone']; } diff --git a/website/views/pages/contact.ejs b/website/views/pages/contact.ejs index 52bb23bf6f..d9045a69e4 100644 --- a/website/views/pages/contact.ejs +++ b/website/views/pages/contact.ejs @@ -145,17 +145,17 @@
-
Deputy logo
+
Deputy logo

- When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.

- Harrison Ravazzolo + Wes Whetstone
-

Harrison Ravazzolo

-

Lead platform and identity engineer

+

Wes Whetstone

+

Staff CPE

diff --git a/website/views/pages/entrance/signup.ejs b/website/views/pages/entrance/signup.ejs index 437deb454a..40b71c7c1d 100644 --- a/website/views/pages/entrance/signup.ejs +++ b/website/views/pages/entrance/signup.ejs @@ -80,15 +80,15 @@
an opening quotation mark

- When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.

- Harrison Ravazzolo + Wes Whetstone
-

Harrison Ravazzolo

-

Lead platform and identity engineer

+

Wes Whetstone

+

Staff CPE

From 5f2eaefabd167303827c8b114fa906c7e8aa0b31 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 30 Aug 2024 18:58:10 -0300 Subject: [PATCH 15/21] Prevent installing on pending host+installer (#21722) #21428 Figma: https://www.figma.com/design/4pfUOYy7IyMIrjMH2fuCdU/%2319551-Policy-automations%3A-install-software?node-id=5871-12100&t=pKh926u8a30iYFBA-4 - [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] Added/updated tests - [X] Manual QA for all new/changed functionality --- ...21428-prevent-install-when-already-pending | 1 + ee/server/service/software_installers.go | 18 +++++++ server/service/integration_enterprise_test.go | 47 +++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 changes/21428-prevent-install-when-already-pending diff --git a/changes/21428-prevent-install-when-already-pending b/changes/21428-prevent-install-when-already-pending new file mode 100644 index 0000000000..d01006d6f9 --- /dev/null +++ b/changes/21428-prevent-install-when-already-pending @@ -0,0 +1 @@ +* Added validation to `POST /api/_version_/fleet/hosts/{host_id}/software/install/{software_title_id}` to prevent installing on a host that already has a pending installation for that software title. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index eca4166129..d4969b7199 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -385,6 +385,24 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw // if we found an installer, use that if installer != nil { + lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID) + } + if lastInstallRequest != nil && lastInstallRequest.Status != nil && *lastInstallRequest.Status == fleet.SoftwareInstallerPending { + return &fleet.BadRequestError{ + Message: "Couldn't install software. Host has a pending install request.", + InternalErr: ctxerr.WrapWithData( + ctx, err, "host already has a pending install for this installer", + map[string]any{ + "host_id": host.ID, + "software_installer_id": installer.InstallerID, + "team_id": host.TeamID, + "title_id": softwareTitleID, + }, + ), + } + } return svc.installSoftwareTitleUsingInstaller(ctx, host, installer) } } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 99904d6a04..bba5df5c89 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -11117,7 +11117,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { host := createOrbitEnrolledHost(t, "linux", "", s.ds) - // create a software installer and some host install requests + // Create software installers and corresponding host install requests. payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install script", PreInstallQuery: "pre install query", @@ -11127,6 +11127,24 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } s.uploadSoftwareInstaller(payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + payload2 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 2", + PreInstallQuery: "pre install query 2", + PostInstallScript: "post install script 2", + Filename: "vim.deb", + Title: "vim", + } + s.uploadSoftwareInstaller(payload2, http.StatusOK, "") + titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages") + payload3 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 3", + PreInstallQuery: "pre install query 3", + PostInstallScript: "post install script 3", + Filename: "emacs.deb", + Title: "emacs", + } + s.uploadSoftwareInstaller(payload3, http.StatusOK, "") + titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages") latestInstallUUID := func() string { var id string @@ -11138,9 +11156,10 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { // create some install requests for the host installUUIDs := make([]string, 3) + titleIDs := []uint{titleID, titleID2, titleID3} for i := 0; i < len(installUUIDs); i++ { resp := installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp) installUUIDs[i] = latestInstallUUID() } @@ -11203,7 +11222,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Status: fleet.SoftwareInstallerFailed, PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy), }) - wantAct.InstallUUID = installUUIDs[1] + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload2.Title, + SoftwarePackage: payload2.Filename, + InstallUUID: installUUIDs[1], + Status: string(fleet.SoftwareInstallerFailed), + } s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) s.Do("POST", "/api/fleet/orbit/software_install/result", @@ -11225,8 +11251,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")), PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")), }) - wantAct.InstallUUID = installUUIDs[2] - wantAct.Status = string(fleet.SoftwareInstallerInstalled) + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload3.Title, + SoftwarePackage: payload3.Filename, + InstallUUID: installUUIDs[2], + Status: string(fleet.SoftwareInstallerInstalled), + } lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) // non-existing installation uuid @@ -13073,6 +13105,11 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) prevExecutionID := host1LastInstall.ExecutionID + // Request a manual installation on the host for the same installer, which should fail. + var installResp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", + host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp) + // Submit same results as before, which should not trigger a installation because the policy is already failing. distributedResp = submitDistributedQueryResultsResponse{} s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( From c6e20456a5fa73d8ed8758584b87f911fc65fea1 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 30 Aug 2024 18:58:20 -0300 Subject: [PATCH 16/21] Do not queue installations on vanilla osquery devices (#21718) Another small fix for #21428. --- server/service/integration_enterprise_test.go | 20 ++++++++++++++++++- server/service/osquery.go | 7 ++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index bba5df5c89..b4f4148bb3 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12764,7 +12764,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) require.NoError(t, err) - newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + newHost := func(name string, teamID *uint, platform string) *fleet.Host { h, err := s.ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), @@ -12778,6 +12778,10 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers TeamID: teamID, }) require.NoError(t, err) + return h + } + newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + h := newHost(name, teamID, platform) orbitKey := setOrbitEnrollment(t, h, s.ds) h.OrbitNodeKey = &orbitKey return h @@ -12787,6 +12791,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin") host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu") host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows") + hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin") // Upload dummy_installer.pkg to team1. pkgPayload := &fleet.UploadSoftwareInstallerPayload{ @@ -13351,4 +13356,17 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.NotNil(t, actor.UserName) require.Equal(t, "Test Name admin1@example.com", *actor.UserName) require.Equal(t, "admin1@example.com", actor.UserEmail) + + // hostVanillaOsquery5Team1 sends policy results with failed policies with associated installers. + // Fleet should not queue an install for vanilla osquery hosts. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + hostVanillaOsquery5Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + hostVanillaOsquery5Team1LastInstall, err := s.ds.GetHostLastInstallData(ctx, hostVanillaOsquery5Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, hostVanillaOsquery5Team1LastInstall) } diff --git a/server/service/osquery.go b/server/service/osquery.go index a38d26407e..f98c2cdc79 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1008,7 +1008,7 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } - if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, policyResults); err != nil { + if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil { logging.WithErr(ctx, err) } @@ -1616,8 +1616,13 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies( hostID uint, hostTeamID *uint, hostPlatform string, + hostOrbitNodeKey *string, incomingPolicyResults map[uint]*bool, ) error { + if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" { + // We do not want to queue software installations on vanilla osquery hosts. + return nil + } if hostTeamID == nil { // TODO(lucas): Support hosts in "No team". return nil From 78bd5db0b87da33b4e5175867b396aea551a7a1a Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 30 Aug 2024 18:58:33 -0300 Subject: [PATCH 17/21] Remove invalid node keys from server logs (#21731) #21412 Tested using the curl command in the issue description. - [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] Manual QA for all new/changed functionality --- changes/21412-remove-node-key-from-server-logs | 1 + server/service/osquery.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changes/21412-remove-node-key-from-server-logs diff --git a/changes/21412-remove-node-key-from-server-logs b/changes/21412-remove-node-key-from-server-logs new file mode 100644 index 0000000000..c6555bd5bc --- /dev/null +++ b/changes/21412-remove-node-key-from-server-logs @@ -0,0 +1 @@ +* Removed invalid node keys from server logs. diff --git a/server/service/osquery.go b/server/service/osquery.go index f98c2cdc79..689c1e776e 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -81,7 +81,7 @@ func (svc *Service) AuthenticateHost(ctx context.Context, nodeKey string) (*flee case err == nil: // OK case fleet.IsNotFound(err): - return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key: " + nodeKey) + return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key") default: return nil, false, newOsqueryError("authentication error: " + err.Error()) } From 4ffde1dc09e63565026da894f5cbd2c42835c83b Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 30 Aug 2024 17:13:07 -0500 Subject: [PATCH 18/21] Website: add new metrics to HistoricalUsageSnapshot model. (#21625) Related to: #21259 Changes: - Updated the `receive-usage-analytics` webhook to add new inputs for metrics added in [#21131](https://github.com/fleetdm/fleet/pull/21131) - Updated the `HIstoricalUsageSnapshot` model to add attributes for the new metrics. When this PR is merged: - [ ] Put the website into maintenance mode - [ ] Update the HistoricalUsageSnapshot table to add new columns - [ ] Update all existing records to set the default value for the new columns --- .../webhooks/receive-usage-analytics.js | 4 ++ website/api/models/HistoricalUsageSnapshot.js | 4 ++ .../send-aggregated-metrics-to-datadog.js | 63 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/website/api/controllers/webhooks/receive-usage-analytics.js b/website/api/controllers/webhooks/receive-usage-analytics.js index 9094c11d79..94f7fec744 100644 --- a/website/api/controllers/webhooks/receive-usage-analytics.js +++ b/website/api/controllers/webhooks/receive-usage-analytics.js @@ -39,6 +39,10 @@ module.exports = { numHostSoftwareInstalledPaths: {type: 'number', defaultsTo: 0}, numSoftwareCPEs: {type: 'number', defaultsTo: 0}, numSoftwareCVEs: {type: 'number', defaultsTo: 0}, + aiFeaturesDisabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsEnabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsConfigured: {type: 'boolean', defaultsTo: false }, + numHostsFleetDesktopEnabled: {type: 'number', defaultsTo: 0 }, }, diff --git a/website/api/models/HistoricalUsageSnapshot.js b/website/api/models/HistoricalUsageSnapshot.js index 5481d42e53..1f14b9b291 100644 --- a/website/api/models/HistoricalUsageSnapshot.js +++ b/website/api/models/HistoricalUsageSnapshot.js @@ -43,6 +43,10 @@ module.exports = { numHostSoftwareInstalledPaths: {required: true, type: 'number'}, numSoftwareCPEs: {required: true, type: 'number'}, numSoftwareCVEs: {required: true, type: 'number'}, + aiFeaturesDisabled: {required: true, type: 'boolean'}, + maintenanceWindowsEnabled: {required: true, type: 'boolean'}, + maintenanceWindowsConfigured: {required: true, type: 'boolean'}, + numHostsFleetDesktopEnabled: {required: true, type: 'number'}, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js index 959c3625b5..af3cccceb6 100644 --- a/website/scripts/send-aggregated-metrics-to-datadog.js +++ b/website/scripts/send-aggregated-metrics-to-datadog.js @@ -406,6 +406,69 @@ module.exports = { }], tags: [`enabled:false`], }); + // aiFeaturesDisabled + let numberOfInstancesWithAiFeaturesDisabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {aiFeaturesDisabled: true}).length; + let numberOfInstancesWithAiFeaturesEnabled = numberOfInstancesToReport - numberOfInstancesWithAiFeaturesDisabled; + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsEnabled + let numberOfInstancesWithMaintenanceWindowsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithMaintenanceWindowsDisabled = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsConfigured + let numberOfInstancesWithMaintenanceWindowsConfigured = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsConfigured + }], + tags: [`configured:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithoutMaintenanceWindowsConfigured + }], + tags: [`configured:false`], + }); // Create two metrics to track total number of hosts reported in the last week. let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'premium'}), 'numHostsEnrolled')); From 6d1de32713dabbb403999f8a0d458d6598fdecb5 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:36:29 -0500 Subject: [PATCH 19/21] Remove APNS certificate validation from server start up (#21728) --- changes/21683-apns-cert-validation-on-start | 2 ++ cmd/fleet/serve.go | 21 +++++---------------- 2 files changed, 7 insertions(+), 16 deletions(-) create mode 100644 changes/21683-apns-cert-validation-on-start diff --git a/changes/21683-apns-cert-validation-on-start b/changes/21683-apns-cert-validation-on-start new file mode 100644 index 0000000000..9f17143599 --- /dev/null +++ b/changes/21683-apns-cert-validation-on-start @@ -0,0 +1,2 @@ +- Removed validation of APNS certificate from server startup. This was no longer necessary because + we now allow for APNS certificates to be renewed in the UI. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 348f20ed42..67ce51f7d0 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -22,7 +22,6 @@ import ( "github.com/e-dard/netbug" "github.com/fleetdm/fleet/v4/ee/server/licensing" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server" configpkg "github.com/fleetdm/fleet/v4/server/config" @@ -75,8 +74,10 @@ import ( var allowedURLPrefixRegexp = regexp.MustCompile("^(?:/[a-zA-Z0-9_.~-]+)+$") -const softwareInstallerUploadTimeout = 4 * time.Minute -const liveQueryMemCacheDuration = 1 * time.Second +const ( + softwareInstallerUploadTimeout = 4 * time.Minute + liveQueryMemCacheDuration = 1 * time.Second +) type initializer interface { // Initialize is used to populate a datastore with @@ -510,7 +511,7 @@ the way that the Fleet server works. initFatal(errors.New("inserting APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } - apnsCert, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() + _, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() if err != nil { initFatal(err, "validate Apple APNs certificate and key") } @@ -520,18 +521,6 @@ the way that the Fleet server works. initFatal(err, "validate Apple SCEP certificate and key") } - const ( - apnsConnectionTimeout = 10 * time.Second - apnsConnectionURL = "https://api.sandbox.push.apple.com" - ) - - // check that the Apple APNs certificate is valid to connect to the API - ctx, cancel := context.WithTimeout(context.Background(), apnsConnectionTimeout) - if err := certificate.ValidateClientAuthTLSConnection(ctx, apnsCert, apnsConnectionURL); err != nil { - initFatal(err, "validate authentication with Apple APNs certificate") - } - cancel() - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM}, {Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM}, From ebf1a2d8f5a5d91414435b9800709c22da8a04ec Mon Sep 17 00:00:00 2001 From: Ian Littman Date: Fri, 30 Aug 2024 18:12:19 -0500 Subject: [PATCH 20/21] Show zeroes on software/software OSes/software vulns tables (#21584) #18897 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- changes/18897-shoe-zeroes | 1 + frontend/__mocks__/softwareMock.ts | 42 ++++++++++- .../cards/OperatingSystems/OSTable.tests.tsx | 2 +- .../SoftwareOSTable/SoftwareOSTable.tests.tsx | 70 +++++++++++++++++++ .../SoftwareOSTable/SoftwareOSTable.tsx | 2 +- .../SoftwareTable/SoftwareTable.tests.tsx | 26 +++---- .../SoftwareTable/SoftwareTable.tsx | 2 +- .../SoftwareVulnerabilitiesTable.tests.tsx | 15 ++-- .../SoftwareVulnerabilitiesTable.tsx | 4 +- 9 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 changes/18897-shoe-zeroes create mode 100644 frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx diff --git a/changes/18897-shoe-zeroes b/changes/18897-shoe-zeroes new file mode 100644 index 0000000000..7faddd522d --- /dev/null +++ b/changes/18897-shoe-zeroes @@ -0,0 +1 @@ +Added "0 items" description on empty software tables for UI consistency diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 02a5f2d185..9ef0b14e9a 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -14,6 +14,8 @@ import { ISoftwareVersionsResponse, ISoftwareVersionResponse, } from "services/entities/software"; +import { IOSVersionsResponse } from "../services/entities/operating_systems"; +import { IOperatingSystemVersion } from "../interfaces/operating_system"; const DEFAULT_SOFTWARE_MOCK: ISoftware = { hosts_count: 1, @@ -93,12 +95,48 @@ const DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK: ISoftwareVersionsResponse = { }, }; -export const createMockSoftwareVersionsReponse = ( +export const createMockSoftwareVersionsResponse = ( overrides?: Partial ): ISoftwareVersionsResponse => { return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides }; }; +const DEFAULT_OS_VERSION_MOCK = { + os_version_id: 1, + name: "macOS 14.6.1", + name_only: "macOS", + version: "14.6.1", + platform: "darwin", + hosts_count: 42, + generated_cpes: [], + vulnerabilities: [], +}; + +export const createMockOSVersion = ( + overrides?: Partial +): IOperatingSystemVersion => { + return { + ...DEFAULT_OS_VERSION_MOCK, + ...overrides, + }; +}; + +const DEFAULT_OS_VERSIONS_RESPONSE_MOCK: IOSVersionsResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + os_versions: [createMockOSVersion()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockOSVersionsResponse = ( + overrides?: Partial +): IOSVersionsResponse => { + return { ...DEFAULT_OS_VERSIONS_RESPONSE_MOCK, ...overrides }; +}; + const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = { name: "test app", app_store_id: 1, @@ -208,7 +246,7 @@ const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { }, }; -export const createMockSoftwareTitlesReponse = ( +export const createMockSoftwareTitlesResponse = ( overrides?: Partial ): ISoftwareTitlesResponse => { return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; diff --git a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx index eb2672562e..934bd5db44 100644 --- a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx +++ b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx @@ -4,7 +4,7 @@ import { render, screen } from "@testing-library/react"; import OSTable from "./OSTable"; describe("Dashboard OS table", () => { - it("renders data normally when present", async () => { + it("renders data normally when present", () => { render( { + it("Renders the page-wide disabled state when software inventory is disabled", async () => { + render( + + ); + + expect(screen.getByText("Software inventory disabled")).toBeInTheDocument(); + }); + + it("Renders the page-wide empty state when no software is present", () => { + render( + + ); + + expect( + screen.getByText("No operating systems detected") + ).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); + expect(screen.queryByText("Search")).toBeNull(); + expect(screen.queryByText("Updated")).toBeNull(); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx index b04f99fbe5..9be6ee4d16 100644 --- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx @@ -129,7 +129,7 @@ const SoftwareOSTable = ({ }; const renderSoftwareCount = () => { - if (!data?.os_versions || !data?.count) return null; + if (!data) return null; return ( <> diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx index add93fc3b1..1c07bc5cd7 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx @@ -4,8 +4,8 @@ import { createCustomRenderer } from "test/test-utils"; import createMockUser from "__mocks__/userMock"; import { - createMockSoftwareTitlesReponse, - createMockSoftwareVersionsReponse, + createMockSoftwareTitlesResponse, + createMockSoftwareVersionsResponse, } from "__mocks__/softwareMock"; import { noop } from "lodash"; @@ -25,7 +25,7 @@ const mockRouter = { }; describe("Software table", () => { - it("Renders the page-wide disabled state when software inventory is disabled", async () => { + it("Renders the page-wide disabled state when software inventory is disabled", () => { const render = createCustomRenderer({ context: { app: { @@ -40,7 +40,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled={false} // Set to false showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} @@ -68,7 +68,7 @@ describe("Software table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the page-wide empty state when no software are present", async () => { + it("Renders the page-wide empty state when no software are present", () => { const render = createCustomRenderer({ context: { app: { @@ -83,7 +83,8 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ + count: 0, counts_updated_at: null, software_titles: [], })} @@ -111,11 +112,12 @@ describe("Software table", () => { expect( screen.getByText("Expecting to see software? Check back later.") ).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); expect(screen.queryByText("Search")).toBeNull(); expect(screen.queryByText("Updated")).toBeNull(); }); - it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", async () => { + it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -130,7 +132,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions // Versions toggle applied - data={createMockSoftwareVersionsReponse({ + data={createMockSoftwareVersionsResponse({ counts_updated_at: null, software: [], })} @@ -160,7 +162,7 @@ describe("Software table", () => { ).toBeInTheDocument(); }); - it("Renders the empty search state when search query does not exist but dropdown is applied", async () => { + it("Renders the empty search state when search query does not exist but dropdown is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -175,7 +177,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} @@ -209,7 +211,7 @@ describe("Software table", () => { ).toBeInTheDocument(); }); - it("Renders the empty search state when search query does not exist but vulnerability filter is applied", async () => { + it("Renders the empty search state when search query does not exist but vulnerability filter is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -224,7 +226,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index 4985990537..9b8df49c2c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -269,7 +269,7 @@ const SoftwareTable = ({ }; const renderSoftwareCount = () => { - if (!tableData || !data?.count) return null; + if (!tableData || !data) return null; return ( <> diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx index ea0ac48389..2e869892d1 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx @@ -24,7 +24,7 @@ const mockRouter = { }; describe("Software Vulnerabilities table", () => { - it("Renders the page-wide disabled state when software inventory is disabled", async () => { + it("Renders the page-wide disabled state when software inventory is disabled", () => { const render = createCustomRenderer({ context: { app: { @@ -62,7 +62,7 @@ describe("Software Vulnerabilities table", () => { }); // TODO: Reinstate collecting software view - it("Renders the page-wide empty state when no software vulnerabilities are present", async () => { + it("Renders the page-wide empty state when no software vulnerabilities are present", () => { const render = createCustomRenderer({ context: { app: { @@ -97,13 +97,14 @@ describe("Software Vulnerabilities table", () => { ); expect(screen.getByText("No vulnerabilities detected")).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); expect( screen.getByText("Expecting to see vulnerabilities? Check back later.") ).toBeInTheDocument(); expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", async () => { + it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -145,7 +146,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", async () => { + it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -188,7 +189,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", async () => { + it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -233,7 +234,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", async () => { + it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -276,7 +277,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders premium columns", async () => { + it("Renders premium columns", () => { const render = createCustomRenderer({ context: { app: { diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx index d37eb63acb..390a3dc866 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx @@ -197,9 +197,9 @@ const SoftwareVulnerabilitiesTable = ({ }; const renderVulnerabilityCount = () => { - if (!data?.count) return null; + if (!data) return null; - const count = data.count; + const count = data?.count; return ( <> From 70a106ccb091f8dee66f30788c43d2a7b76ed9b7 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 2 Sep 2024 06:39:11 -0400 Subject: [PATCH 21/21] fix: update copy in VPP/ABM UI (#21730) > Related issue: #21723 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Manual QA for all new/changed functionality --- .../pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx | 4 ++-- .../AppleAutomaticEnrollmentCard.tsx | 2 +- .../cards/MdmSettings/components/VppSection/VppSection.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index 06108649bf..2eb7597997 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -38,11 +38,11 @@ const EnableVppCard = () => { Volume Purchasing Program (VPP) isn't enabled

- To add App Store apps, first enable VPP. + To add App Store apps, first add VPP.

diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx index dd0ca4c96d..69d2c65ad5 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx @@ -25,7 +25,7 @@ const AppleAutomaticEnrollmentCard = ({ "Add an Apple Business Manager (ABM) connection to automatically enroll newly " + "purchased Apple hosts when they're first unboxed and set up by your end users."; } else if (isAppleMdmOn && configured) { - msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) hosts enabled."; + msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) is enabled."; icon = "success"; } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx index e2b58dd29b..afcd3af3c2 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx @@ -46,7 +46,7 @@ const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => {

- Volume Purchasing Program (VPP) enabled. + Volume Purchasing Program (VPP) is enabled.