From 03bd837c9c9cfc7ea786bb4a018258177ea4de8e Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 12 Apr 2024 14:34:54 -0500 Subject: [PATCH 01/21] Add backend to resend host MDM profiles (#18212) Issue #17897 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/17897-api-resend-mdm-profile | 1 + server/datastore/mysql/mdm.go | 63 +++++++ server/datastore/mysql/testing_utils.go | 2 +- server/fleet/apple_mdm.go | 2 - server/fleet/datastore.go | 9 +- server/fleet/mdm.go | 4 + server/fleet/service.go | 3 + server/mock/datastore_mock.go | 24 +++ server/service/handler.go | 3 + server/service/integration_mdm_test.go | 181 ++++++++++++++++++++ server/service/mdm.go | 117 +++++++++++++ server/service/mdm_test.go | 209 ++++++++++++++++++++++++ 12 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 changes/17897-api-resend-mdm-profile diff --git a/changes/17897-api-resend-mdm-profile b/changes/17897-api-resend-mdm-profile new file mode 100644 index 0000000000..8bbdf7dd1a --- /dev/null +++ b/changes/17897-api-resend-mdm-profile @@ -0,0 +1 @@ +- Added API to support resending MDM profiles. diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 0ef788c464..ef16b993e8 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1098,3 +1098,66 @@ func (ds *Datastore) CleanSCEPRenewRefs(ctx context.Context, hostUUID string) er return nil } + +func (ds *Datastore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profUUID string) (fleet.MDMDeliveryStatus, error) { + table, column, err := getTableAndColumnNameForHostMDMProfileUUID(profUUID) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "getting table and column") + } + + selectStmt := fmt.Sprintf(` +SELECT + COALESCE(status, ?) as status + FROM + %s +WHERE + operation_type = ? + AND host_uuid = ? + AND %s = ? +`, table, column) + + var status fleet.MDMDeliveryStatus + if err := sqlx.GetContext(ctx, ds.writer(ctx), &status, selectStmt, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall, hostUUID, profUUID); err != nil { + if err == sql.ErrNoRows { + return "", notFound("HostMDMProfile").WithMessage("unable to match profile to host") + } + return "", ctxerr.Wrap(ctx, err, "get MDM profile status") + } + return status, nil +} + +func (ds *Datastore) ResendHostMDMProfile(ctx context.Context, hostUUID string, profUUID string) error { + table, column, err := getTableAndColumnNameForHostMDMProfileUUID(profUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting table and column") + } + + // update the status to NULL to trigger resending on the next cron run + updateStmt := fmt.Sprintf(`UPDATE %s SET status = NULL WHERE host_uuid = ? AND %s = ?`, table, column) + + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + res, err := tx.ExecContext(ctx, updateStmt, hostUUID, profUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "resending host MDM profile") + } + if rows, _ := res.RowsAffected(); rows == 0 { + // this should never happen, log for debugging + level.Debug(ds.logger).Log("msg", "resend profile status not updated", "host_uuid", hostUUID, "profile_uuid", profUUID) + } + + return nil + }) +} + +func getTableAndColumnNameForHostMDMProfileUUID(profUUID string) (table, column string, err error) { + switch { + case strings.HasPrefix(profUUID, fleet.MDMAppleDeclarationUUIDPrefix): + return "host_mdm_apple_declarations", "declaration_uuid", nil + case strings.HasPrefix(profUUID, fleet.MDMAppleProfileUUIDPrefix): + return "host_mdm_apple_profiles", "profile_uuid", nil + case strings.HasPrefix(profUUID, fleet.MDMWindowsProfileUUIDPrefix): + return "host_mdm_windows_profiles", "profile_uuid", nil + default: + return "", "", fmt.Errorf("invalid profile UUID prefix %s", profUUID) + } +} diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index a1eadba60d..4671b63b77 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -445,7 +445,7 @@ func InsertWindowsProfileForTest(t *testing.T, ds *Datastore, teamID uint) strin profUUID := "w" + uuid.NewString() prof := generateDummyWindowsProfile(profUUID) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - stmt := `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) VALUES (?, ?, ?, ?);` + stmt := `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml, uploaded_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP);` _, err := q.ExecContext(context.Background(), stmt, profUUID, teamID, fmt.Sprintf("name-%s", profUUID), prof) return err }) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 1ee60cb3b9..c9fe4360e2 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -451,8 +451,6 @@ const ( DEPAssignProfileResponseFailed DEPAssignProfileResponseStatus = "FAILED" ) -const MDMAppleDeclarationUUIDPrefix = "d" - // NanoEnrollment represents a row in the nano_enrollments table managed by // nanomdm. It is meant to be used internally by the server, not to be returned // as part of endpoints, and as a precaution its json-encoding is explicitly diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 1c03aca415..048d9e48e6 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1187,7 +1187,7 @@ type Datastore interface { MDMAppleDDMDeclarationItems(ctx context.Context, hostUUID string) ([]MDMAppleDDMDeclarationItem, error) // MDMAppleDDMDeclarationPayload returns the declaration payload for the specified identifier and team. MDMAppleDDMDeclarationsResponse(ctx context.Context, identifier string, hostUUID string) (*MDMAppleDeclaration, error) - //MDMAppleBatchSetHostDeclarationState + // MDMAppleBatchSetHostDeclarationState MDMAppleBatchSetHostDeclarationState(ctx context.Context) ([]string, error) // MDMAppleStoreDDMStatusReport receives a host.uuid and a slice // of declarations, and updates the tracked host declaration status for @@ -1259,6 +1259,13 @@ type Datastore interface { // corresponding to the criteria. ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt ListOptions) ([]*MDMConfigProfilePayload, *PaginationMetadata, error) + // ResendHostMDMProfile updates the host's profile status to NULL thereby triggering the profile + // to be resent upon the next cron run. + ResendHostMDMProfile(ctx context.Context, hostUUID string, profileUUID string) error + + // GetHostMDMProfileInstallStatus returns the status of the profile for the host. + GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (MDMDeliveryStatus, error) + /////////////////////////////////////////////////////////////////////////////// // MDM Commands diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index baa501bb4b..7d66519313 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -12,6 +12,10 @@ import ( const ( MDMPlatformApple = "apple" MDMPlatformMicrosoft = "microsoft" + + MDMAppleDeclarationUUIDPrefix = "d" + MDMAppleProfileUUIDPrefix = "a" + MDMWindowsProfileUUIDPrefix = "w" ) type AppleMDM struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index c38c9550c5..e05fa0b376 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -926,6 +926,9 @@ type Service interface { // assigned to any team). GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*MDMDiskEncryptionSummary, error) + // ResendHostMDMProfile resends the MDM profile to the host. + ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error + /////////////////////////////////////////////////////////////////////////////// // Host Script Execution diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c42c08ee6c..bad684e93d 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -828,6 +828,10 @@ type GetHostMDMWindowsProfilesFunc func(ctx context.Context, hostUUID string) ([ type ListMDMConfigProfilesFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) +type ResendHostMDMProfileFunc func(ctx context.Context, hostUUID string, profileUUID string) error + +type GetHostMDMProfileInstallStatusFunc func(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) + type GetMDMCommandPlatformFunc func(ctx context.Context, commandUUID string) (string, error) type ListMDMCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error) @@ -2114,6 +2118,12 @@ type DataStore struct { ListMDMConfigProfilesFunc ListMDMConfigProfilesFunc ListMDMConfigProfilesFuncInvoked bool + ResendHostMDMProfileFunc ResendHostMDMProfileFunc + ResendHostMDMProfileFuncInvoked bool + + GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFunc + GetHostMDMProfileInstallStatusFuncInvoked bool + GetMDMCommandPlatformFunc GetMDMCommandPlatformFunc GetMDMCommandPlatformFuncInvoked bool @@ -5057,6 +5067,20 @@ func (s *DataStore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt return s.ListMDMConfigProfilesFunc(ctx, teamID, opt) } +func (s *DataStore) ResendHostMDMProfile(ctx context.Context, hostUUID string, profileUUID string) error { + s.mu.Lock() + s.ResendHostMDMProfileFuncInvoked = true + s.mu.Unlock() + return s.ResendHostMDMProfileFunc(ctx, hostUUID, profileUUID) +} + +func (s *DataStore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) { + s.mu.Lock() + s.GetHostMDMProfileInstallStatusFuncInvoked = true + s.mu.Unlock() + return s.GetHostMDMProfileInstallStatusFunc(ctx, hostUUID, profileUUID) +} + func (s *DataStore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) { s.mu.Lock() s.GetMDMCommandPlatformFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index a67cf5cc8f..ecd586e472 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -593,6 +593,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/hosts/:id/profiles is now deprecated, replaced by // GET /hosts/:id/configuration_profiles. mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/profiles", getHostProfilesEndpoint, getHostProfilesRequest{}) + // TODO: Confirm if response should be updated to include Windows profiles and use mdmAnyMW mdmAppleMW.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/configuration_profiles", getHostProfilesEndpoint, getHostProfilesRequest{}) // Deprecated: PATCH /mdm/apple/setup is now deprecated, replaced by the @@ -684,6 +685,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAnyMW.POST("/api/_version_/fleet/mdm/profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{}) mdmAnyMW.POST("/api/_version_/fleet/configuration_profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{}) + mdmAnyMW.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/configuration_profiles/resend/{profile_uuid}", resendHostMDMProfileEndpoint, resendHostMDMProfileRequest{}) + // Deprecated: PATCH /mdm/apple/settings is deprecated, replaced by POST /disk_encryption. // It was only used to set disk encryption. mdmAnyMW.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{}) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 92c1053185..02d20a72e7 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -719,6 +719,145 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() { s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles + + // add a new profile to the team + mcUUID := "a" + uuid.NewString() + prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID) + wantTeamProfiles = append(wantTeamProfiles, prof) + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` + _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID)) + return err + }) + s.awaitTriggerProfileSchedule(t) + installs, removes = checkNextPayloads(t, mdmDevice, false) + require.Len(t, installs, 1) + require.Equal(t, prof, installs[0]) + require.Empty(t, removes) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + + // can't resend profile while verifying + res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + + // set the profile to pending, can't resend + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` + _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID) + return err + }) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil) + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + + // set the profile to failed, can resend + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` + _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID) + return err + }) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil) + _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted) + s.awaitTriggerProfileSchedule(t) + installs, removes = checkNextPayloads(t, mdmDevice, false) + require.Len(t, installs, 1) + require.Equal(t, prof, installs[0]) + require.Empty(t, removes) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + + // can't resend profile while verifying + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + + // set the profile to verified, can resend + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` + _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID) + return err + }) + _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted) + s.awaitTriggerProfileSchedule(t) + installs, removes = checkNextPayloads(t, mdmDevice, false) + require.Len(t, installs, 1) + require.Equal(t, prof, installs[0]) + require.Empty(t, removes) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + + // add a declaration to the team + declIdent := "decl-ident-" + t.Name() + fields := map[string][]string{ + "team_id": {fmt.Sprintf("%d", tm.ID)}, + } + body, headers := generateNewProfileMultipartRequest( + t, declIdent+".json", declarationForTest(declIdent), s.token, fields, + ) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers) + var resp newMDMConfigProfileResponse + err = json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + require.NotEmpty(t, resp.ProfileUUID) + require.Equal(t, "d", string(resp.ProfileUUID[0])) + declUUID := resp.ProfileUUID + + checkDDMSync := func(d *mdmtest.TestAppleMDMClient) { + require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger)) + cmd, err := d.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType) + cmd, err = d.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd)) + _, err = d.DeclarativeManagement("tokens") + require.NoError(t, err) + } + checkDDMSync(mdmDevice) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + + // can't resend declaration while verifying + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusConflict) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + + // set the declaration to verified, can resend + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?` + _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID) + return err + }) + _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted) + checkDDMSync(mdmDevice) + s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + + // transfer the host to the global team + err = s.ds.AddHostsToTeam(ctx, nil, []uint{host.ID}) + require.NoError(t, err) + + s.awaitTriggerProfileSchedule(t) + installs, removes = checkNextPayloads(t, mdmDevice, false) + require.Len(t, installs, len(wantGlobalProfiles)) + require.ElementsMatch(t, wantGlobalProfiles, installs) + require.Len(t, removes, len(wantTeamProfiles)) + s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{Verifying: 1}, nil) // host now verifying global profiles + + // can't resend profile from another team + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusNotFound) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Unable to match profile to host") + + // add a Windows profile, resend not supported when host is macOS + wpUUID := mysql.InsertWindowsProfileForTest(t, s.ds, 0) + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, wpUUID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Profile is not compatible with host platform") + + // invalid profile UUID prefix should return 404 + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Invalid profile UUID prefix") } func (s *integrationMDMTestSuite) TestAppleProfileRetries() { @@ -10550,6 +10689,11 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { checkHostsProfilesMatch(host, globalProfiles) checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying) + // can't resend a profile while it is verifying + res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusConflict) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + // create new label that includes host label := &fleet.Label{ Name: t.Name() + "foo", @@ -10612,6 +10756,11 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { Verifying: 0, }, nil) + // can resend a profile after it has failed + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusAccepted) + verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent + checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying + // add the host to a team err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID}) require.NoError(t, err) @@ -10639,6 +10788,16 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { checkHostsProfilesMatch(host, teamProfiles) checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying) + // can't resend a profile while it is verifying + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusConflict) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") + + // can't resend a profile from the wrong team + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusNotFound) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Unable to match profile to host.") + // another sync shouldn't return profiles verifyProfiles(mdmDevice, 0, false) @@ -10706,6 +10865,28 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { Failed: 1, Verifying: 0, }, nil) + + // can resend a profile after it has failed + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted) + verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent + checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying + + // add a macOS profile to the team + mcUUID := "a" + uuid.NewString() + prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID) + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` + _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID)) + return err + }) + + // trigger a profile sync, device doesn't get the macOS profile + verifyProfiles(mdmDevice, 0, false) + + // can't resend a macOS profile to a Windows host + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Profile is not compatible with host platform") } func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() { diff --git a/server/service/mdm.go b/server/service/mdm.go index 821ba0b1d8..2035bfa319 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1980,3 +1980,120 @@ func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, e } return svc.updateAppConfigMDMDiskEncryption(ctx, enableDiskEncryption) } + +//////////////////////////////////////////////////////////////////////////////// +// POST /hosts/{id:[0-9]+}/configuration_profiles/{profile_uuid} +//////////////////////////////////////////////////////////////////////////////// + +type resendHostMDMProfileRequest struct { + HostID uint `url:"host_id"` + ProfileUUID string `url:"profile_uuid"` +} + +type resendHostMDMProfileResponse struct { + Err error `json:"error,omitempty"` +} + +func (r resendHostMDMProfileResponse) error() error { return r.Err } + +func (r resendHostMDMProfileResponse) Status() int { return http.StatusAccepted } + +func resendHostMDMProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*resendHostMDMProfileRequest) + + if err := svc.ResendHostMDMProfile(ctx, req.HostID, req.ProfileUUID); err != nil { + return resendHostMDMProfileResponse{Err: err}, nil + } + + return resendHostMDMProfileResponse{}, nil +} + +func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error { + // first we perform a perform basic authz check, we use selective list action to include gitops users + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil { + return ctxerr.Wrap(ctx, err) + } + + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err) + } + + // now we can do a specific authz check based on team id of the host before proceeding + if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err) + } + + var profileTeamID *uint + switch { + case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix): + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled") + } + if host.Platform != "darwin" { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform") + } + prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting apple config profile") + } + profileTeamID = prof.TeamID + + case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix): + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled") + } + if host.Platform != "darwin" { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform") + } + decl, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting apple declaration") + } + profileTeamID = decl.TeamID + + case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix): + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check windows mdm enabled") + } + if host.Platform != "windows" { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform") + } + prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting windows config profile") + } + profileTeamID = prof.TeamID + + default: + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Invalid profile UUID prefix.").WithStatus(http.StatusNotFound), "check profile UUID prefix") + } + + // check again based on team id of profile before we proceeding + if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: profileTeamID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err, "authorizing profile team") + } + + status, err := svc.ds.GetHostMDMProfileInstallStatus(ctx, host.UUID, profileUUID) + if err != nil { + if fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Unable to match profile to host.").WithStatus(http.StatusNotFound), "getting host mdm profile status") + } + return ctxerr.Wrap(ctx, err, "getting host mdm profile status") + } + if status == fleet.MDMDeliveryPending || status == fleet.MDMDeliveryVerifying { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.").WithStatus(http.StatusConflict), "check profile status") + } + if status != fleet.MDMDeliveryFailed && status != fleet.MDMDeliveryVerified { + // this should never happen, but just in case + return ctxerr.Errorf(ctx, "unrecognized profile status %s", status) + } + + if err := svc.ds.ResendHostMDMProfile(ctx, host.UUID, profileUUID); err != nil { + return ctxerr.Wrap(ctx, err, "resending host mdm profile") + } + + // TODO: log activity? + + return nil +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 472981e518..aeca6c87e3 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1524,3 +1524,212 @@ func TestBackwardsCompatProfilesParamUnmarshalJSON(t *testing.T) { }) } } + +func TestMDMResendConfigProfileAuthz(t *testing.T) { + ds := new(mock.Store) + // while the config profiles are not premium-only, teams are and we want to test with teams. + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + testCases := []struct { + name string + user *fleet.User + shouldFailGlobalRead bool + shouldFailTeamRead bool + shouldFailGlobalWrite bool + shouldFailTeamWrite bool + }{ + { + "global admin", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + false, + false, + false, + }, + { + "global maintainer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + false, + false, + false, + false, + }, + { + "global observer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + true, + true, + true, + true, + }, + { + "global observer+", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + true, + true, + true, + true, + }, + { + // this is authorized because gitops can access hosts by identifier (the + // first authorization check) and then gitops have write-access the + // profiles. + "global gitops", + &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + false, + false, + false, + false, + }, + { + "team admin, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + true, + false, + true, + false, + }, + { + "team admin, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + true, + true, + true, + true, + }, + { + "team maintainer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + true, + false, + true, + false, + }, + { + "team maintainer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + true, + true, + true, + true, + }, + { + "team observer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + true, + true, + true, + true, + }, + { + "team observer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + true, + true, + true, + true, + }, + { + "team observer+, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + true, + true, + true, + true, + }, + { + "team observer+, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + true, + true, + true, + true, + }, + { + // this is authorized because gitops can access hosts by identifier (the + // first authorization check) and then gitops have write-access the + // profiles. + "team gitops, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + true, + false, + true, + false, + }, + { + "team gitops, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + true, + true, + true, + true, + }, + { + "user no roles", + &fleet.User{ID: 1337}, + true, + true, + true, + true, + }, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + WindowsEnabledAndConfigured: true, + }, + }, nil + } + + ds.HostLiteFunc = func(ctx context.Context, hid uint) (*fleet.Host, error) { + if hid == 1 { + return &fleet.Host{ID: hid, UUID: "host-uuid-1", Platform: "darwin", TeamID: ptr.Uint(1)}, nil + } else if hid == 1337 { + return &fleet.Host{ID: hid, UUID: "host-uuid-no-team", Platform: "darwin", TeamID: nil}, nil + } + return nil, ¬FoundErr{} + } + ds.GetMDMAppleConfigProfileFunc = func(ctx context.Context, pid string) (*fleet.MDMAppleConfigProfile, error) { + var tid uint + if pid == "a-team-1-profile" { + tid = 1 + } + return &fleet.MDMAppleConfigProfile{ + ProfileUUID: pid, + TeamID: &tid, + }, nil + } + ds.GetHostMDMProfileInstallStatusFunc = func(ctx context.Context, hostUUID string, profUUID string) (fleet.MDMDeliveryStatus, error) { + return fleet.MDMDeliveryFailed, nil + } + ds.ResendHostMDMProfileFunc = func(ctx context.Context, hostUUID, profUUID string) error { + return nil + } + + checkShouldFail := func(t *testing.T, err error, shouldFail bool) { + if !shouldFail { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) + } + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + // ds.TeamFunc = mockTeamFuncWithUser(tt.user) + + // test authz resend config profile (no team) + err := svc.ResendHostMDMProfile(ctx, 1337, "a-no-team-profile") + checkShouldFail(t, err, tt.shouldFailGlobalWrite) + + // test authz resend config profile (team 1) + err = svc.ResendHostMDMProfile(ctx, 1, "a-team-1-profile") + checkShouldFail(t, err, tt.shouldFailTeamWrite) + }) + } +} From 1e6839c0048a48fd51acf0aff4386245c2b8efac Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Mon, 15 Apr 2024 14:17:08 +0100 Subject: [PATCH 02/21] Feat UI resend profile (#18111) relates to #17896 UI implementation of the resend profile feature. This adds a resend button on the OS Settings modal row items that will request the profile is resent. ![image](https://github.com/fleetdm/fleet/assets/1153709/f9072ccc-2d28-4638-adea-da3cb25da33b) - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Roberto Dip --- changes/issue-17896-ui-resend-profile | 1 + .../TooltipTruncatedTextCell.tsx | 7 +- .../TableContainer/TableContainer.tsx | 2 +- frontend/interfaces/activity.ts | 1 + .../ActivityItem/ActivityItem.tsx | 13 ++ .../details/DeviceUserPage/DeviceUserPage.tsx | 8 +- .../HostDetailsPage/HostDetailsPage.tsx | 7 +- .../OSSettingsModal/OSSettingsModal.tsx | 21 ++- .../OSSettingStatusCell.tsx | 13 +- .../OSSettingStatusCell/helpers.ts | 9 +- .../OSSettingsErrorCell.tsx | 147 ++++++++++++++++ .../OSSettingsErrorCell/_styles.scss | 56 ++++++ .../OSSettingsErrorCell/index.ts | 1 + .../OSSettingsTable/OSSettingsTable.tsx | 22 ++- .../OSSettingsTable/OSSettingsTableConfig.tsx | 160 +++++------------- .../OSSettingsTable/_styles.scss | 13 ++ frontend/services/entities/hosts.ts | 6 + frontend/utilities/endpoints.ts | 2 + 18 files changed, 352 insertions(+), 137 deletions(-) create mode 100644 changes/issue-17896-ui-resend-profile create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts diff --git a/changes/issue-17896-ui-resend-profile b/changes/issue-17896-ui-resend-profile new file mode 100644 index 0000000000..3911edd2bf --- /dev/null +++ b/changes/issue-17896-ui-resend-profile @@ -0,0 +1 @@ +- add UI for resending a profile for a host on the host details page in the OS Settings modal diff --git a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx index 64dda5d065..0a78718146 100644 --- a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx @@ -9,14 +9,16 @@ import { COLORS } from "styles/var/colors"; interface ITooltipTruncatedTextCellProps { value: React.ReactNode; /** Tooltip to dispay. If this is provided then this will be rendered as the tooltip content. If - * not the value will be displayed as the tooltip content. Defaults to `undefined` */ + * not, the value will be displayed as the tooltip content. Defaults to `undefined` */ tooltip?: React.ReactNode; /** If set to `true` the text inside the tooltip will break on words instead of any character. * By default the tooltip text breaks on any character. * Default is `false`. */ tooltipBreakOnWord?: boolean; + /** @deprecated use the prop `className` in order to add custom classes to this component */ classes?: string; + className?: string; } const baseClass = "tooltip-truncated-cell"; @@ -26,8 +28,9 @@ const TooltipTruncatedTextCell = ({ tooltip, tooltipBreakOnWord = false, classes = "w250", + className, }: ITooltipTruncatedTextCellProps): JSX.Element => { - const classNames = classnames(baseClass, classes, { + const classNames = classnames(baseClass, classes, className, { "tooltip-break-on-word": tooltipBreakOnWord, }); diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 4435260157..a78c023c1f 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import classnames from "classnames"; -import { Row, UseExpandedRowProps } from "react-table"; +import { Row } from "react-table"; import ReactTooltip from "react-tooltip"; import useDeepEffect from "hooks/useDeepEffect"; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index dd871d7be6..841c326ab2 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -70,6 +70,7 @@ export enum ActivityType { CreatedDeclarationProfile = "created_declaration_profile", DeletedDeclarationProfile = "deleted_declaration_profile", EditedDeclarationProfile = "edited_declaration_profile", + ResentConfigurationProfile = "resent_configuration_profile", } // This is a subset of ActivityType that are shown only for the host past activities diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 9998278692..9827afc2ec 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -810,6 +810,16 @@ const TAGGED_TEMPLATES = { ); }, + + resentConfigProfile: (activity: IActivity) => { + return ( + <> + {" "} + resent {activity.details?.profile_name} configuration profile to{" "} + {activity.details?.host_display_name}. + + ); + }, }; const getDetail = ( @@ -980,6 +990,9 @@ const getDetail = ( case ActivityType.EditedDeclarationProfile: { return TAGGED_TEMPLATES.editedDeclarationProfile(activity, isPremiumTier); } + case ActivityType.ResentConfigurationProfile: { + return TAGGED_TEMPLATES.resentConfigProfile(activity); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index b9fa3811bc..629f4956b5 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -445,10 +445,12 @@ const DeviceUserPage = ({ policy={selectedPolicy} /> )} - {showOSSettingsModal && ( + {!!host && showOSSettingsModal && ( )} diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 6a2839035e..5370d3f6b9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -932,9 +932,12 @@ const HostDetailsPage = ({ )} {showOSSettingsModal && ( )} {showUnenrollMdmModal && !!host && ( diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx index cc2db63d17..a9c916f570 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx @@ -7,17 +7,27 @@ import OSSettingsTable from "./OSSettingsTable"; import { generateTableData } from "./OSSettingsTable/OSSettingsTableConfig"; interface IOSSettingsModalProps { - platform?: string; - hostMDMData?: IHostMdmData; + hostId: number; + platform: string; + hostMDMData: IHostMdmData; + /** controls showing the action for a user to resend a profile. Defaults to `false` */ + canResendProfiles?: boolean; onClose: () => void; + /** handler that fires when a profile was reset. Requires `canResendProfiles` prop + * to be `true`, otherwise has no effect. + */ + onProfileResent?: () => void; } const baseClass = "os-settings-modal"; const OSSettingsModal = ({ + hostId, platform, hostMDMData, + canResendProfiles = false, onClose, + onProfileResent, }: IOSSettingsModalProps) => { // the caller should ensure that hostMDMData is not undefined and that platform is "windows" or // "darwin", otherwise we will allow an empty modal will be rendered. @@ -36,7 +46,12 @@ const OSSettingsModal = ({ width="large" > <> - +
+ ); +}; + +/** + * generates the formatted tooltip for the error column. + * the expected format of the error string is: + * "key1: value1, key2: value2, key3: value3" + */ +const generateFormattedTooltip = (detail: string) => { + const keyValuePairs = detail.split(/, */); + const formattedElements: JSX.Element[] = []; + + // Special case to handle bitlocker error message. It does not follow the + // expected string format so we will just render the error message as is. + if ( + detail.includes("BitLocker") || + detail.includes("preparing volume for encryption") + ) { + return detail; + } + + keyValuePairs.forEach((pair, i) => { + const [key, value] = pair.split(/: */); + if (key && value) { + formattedElements.push( + + {key.trim()}: {value.trim()} + {/* dont add the trailing comma for the last element */} + {i !== keyValuePairs.length - 1 && ( + <> + ,
+ + )} +
+ ); + } + }); + + return formattedElements.length ? <>{formattedElements} : detail; +}; + +/** + * generates the error tooltip for the error column. This will be formatted or + * unformatted. + */ +const generateErrorTooltip = ( + cellValue: string, + profile: IHostMdmProfileWithAddedStatus +) => { + if (profile.status !== "failed") return null; + + if (profile.platform !== "windows") { + return cellValue; + } + return generateFormattedTooltip(profile.detail); +}; + +interface IOSSettingsErrorCellProps { + canResendProfiles: boolean; + hostId: number; + profile: IHostMdmProfileWithAddedStatus; + onProfileResent?: () => void; +} + +const OSSettingsErrorCell = ({ + canResendProfiles, + hostId, + profile, + onProfileResent = noop, +}: IOSSettingsErrorCellProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isLoading, setIsLoading] = useState(false); + + const onResendProfile = async () => { + setIsLoading(true); + try { + await hostAPI.resendProfile(hostId, profile.profile_uuid); + onProfileResent(); + } catch (e) { + renderFlash("error", "Couldn't resend. Please try again."); + } + setIsLoading(false); + }; + + const isFailed = profile.status === "failed"; + const isVerified = profile.status === "verified"; + const showRefetchButton = canResendProfiles && (isFailed || isVerified); + const value = (isFailed && profile.detail) || DEFAULT_EMPTY_CELL_VALUE; + + const tooltip = generateErrorTooltip(value, profile); + + return ( +
+ + {showRefetchButton && ( + + )} +
+ ); +}; + +export default OSSettingsErrorCell; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss new file mode 100644 index 0000000000..c78f92985b --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss @@ -0,0 +1,56 @@ +.os-settings-error-cell { + display: flex; + justify-content: space-between; + align-items: center; + gap: $pad-small; + + &__failed-message { + max-width: calc(250px - 48px); + min-width: 100px; + + .data-table__tooltip-truncated-text--cell { + // for some reason this is need to vertically align the text and + // the resend button + display: block; + } + } + + &__resend-button { + display: flex; + + .children-wrapper { + display: flex; + + .icon { + vertical-align: middle; + margin-right: 8px; + } + } + } + + &__resending { + color: $core-vibrant-blue; + cursor: default; + font-size: $x-small; + height: 38px; + opacity: 50%; + filter: saturate(100%); + + .icon { + vertical-align: middle; + animation: spin 2s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + transform-origin: center center; + } + + 100% { + transform: rotate(360deg); + transform-origin: center center; + } + } + } +} diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts new file mode 100644 index 0000000000..aabe7454f5 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts @@ -0,0 +1 @@ +export { default } from "./OSSettingsErrorCell"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx index 7aea4d34ad..ee667c5217 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx @@ -1,23 +1,37 @@ import React from "react"; import TableContainer from "components/TableContainer"; -import tableHeaders, { +import generateTableHeaders, { IHostMdmProfileWithAddedStatus, } from "./OSSettingsTableConfig"; const baseClass = "os-settings-table"; interface IOSSettingsTableProps { - tableData?: IHostMdmProfileWithAddedStatus[]; + canResendProfiles: boolean; + hostId: number; + tableData: IHostMdmProfileWithAddedStatus[]; + onProfileResent?: () => void; } -const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => { +const OSSettingsTable = ({ + canResendProfiles, + hostId, + tableData, + onProfileResent, +}: IOSSettingsTableProps) => { + const tableConfig = generateTableHeaders( + hostId, + canResendProfiles, + onProfileResent + ); + return (
; type ITableStringCellProps = IStringCellProps; +/** Non DDM profiles can have an `action_required` as a profile status. DDM + * Profiles will never have this status. + */ export type INonDDMProfileStatus = MdmProfileStatus | "action_required"; export type OsSettingsTableStatusValue = | MdmDDMProfileStatus | INonDDMProfileStatus; -/** - * generates the formatted tooltip for the error column. - * the expected format of the error string is: - * "key1: value1, key2: value2, key3: value3" - */ -const generateFormattedTooltip = (detail: string) => { - const keyValuePairs = detail.split(/, */); - const formattedElements: JSX.Element[] = []; - - // Special case to handle bitlocker error message. It does not follow the - // expected string format so we will just render the error message as is. - if ( - detail.includes("BitLocker") || - detail.includes("preparing volume for encryption") - ) { - return detail; - } - - keyValuePairs.forEach((pair, i) => { - const [key, value] = pair.split(/: */); - if (key && value) { - formattedElements.push( - - {key.trim()}: {value.trim()} - {/* dont add the trailing comma for the last element */} - {i !== keyValuePairs.length - 1 && ( - <> - ,
- - )} -
- ); - } - }); - - return formattedElements.length ? <>{formattedElements} : detail; -}; - -/** - * generates the error tooltip for the error column. This will be formatted or - * unformatted. - */ -const generateErrorTooltip = ( - cellValue: string, - platform: ProfilePlatform, - detail: string -) => { - if (platform !== "windows") { - return cellValue; - } - return generateFormattedTooltip(detail); -}; - -const tableHeaders: ITableColumnConfig[] = [ - { - Header: "Name", - disableSortBy: true, - accessor: "name", - Cell: (cellProps: ITableStringCellProps) => { - return ; +const generateTableConfig = ( + hostId: number, + canResendProfiles: boolean, + onProfileResent?: () => void +): ITableColumnConfig[] => { + return [ + { + Header: "Name", + disableSortBy: true, + accessor: "name", + Cell: (cellProps: ITableStringCellProps) => { + return ; + }, }, - }, - { - Header: "Status", - disableSortBy: true, - accessor: "status", - Cell: (cellProps: ITableStringCellProps) => { - return ( - { + return ( + + ); + }, + }, + { + Header: "Error", + disableSortBy: true, + accessor: "detail", + Cell: (cellProps: ITableStringCellProps) => ( + - ); + ), }, - }, - { - Header: "Error", - disableSortBy: true, - accessor: "detail", - Cell: (cellProps: ITableStringCellProps): JSX.Element => { - const profile = cellProps.row.original; - - const value = - (profile.status === "failed" && profile.detail) || - DEFAULT_EMPTY_CELL_VALUE; - - const tooltip = - profile.status === "failed" - ? generateErrorTooltip( - value, - cellProps.row.original.platform, - profile.detail - ) - : null; - - return ( - - ); - }, - }, -]; + ]; +}; const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => { const rows: IHostMdmProfileWithAddedStatus[] = []; @@ -195,13 +133,9 @@ const makeDarwinRows = ({ profiles, macos_settings }: IHostMdmData) => { }; export const generateTableData = ( - hostMDMData?: IHostMdmData, - platform?: string + hostMDMData: IHostMdmData, + platform: string ) => { - if (!platform || !hostMDMData) { - return null; - } - switch (platform) { case "windows": return makeWindowsRows(hostMDMData); @@ -212,4 +146,4 @@ export const generateTableData = ( } }; -export default tableHeaders; +export default generateTableConfig; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss index a01007917a..1e8c8aeed2 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss @@ -4,4 +4,17 @@ white-space: nowrap; } } + + // row hover effect for resend button. we dont want this behavior when the + // button is resending + .resend-link:not(.os-settings-error-cell__resending) { + opacity: 0; + transition: opacity 250ms; + } + + tr:hover { + .resend-link:not(.os-settings-error-cell__resending) { + opacity: 1; + } + } } diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index c5598a3aee..cd7be3beb2 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -512,4 +512,10 @@ export default { const { HOST_WIPE } = endpoints; return sendRequest("POST", HOST_WIPE(id)); }, + + resendProfile: (hostId: number, profileUUID: string) => { + const { HOST_RESEND_PROFILE } = endpoints; + + return sendRequest("POST", HOST_RESEND_PROFILE(hostId, profileUUID)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 58c2b4c5db..f1169b31e0 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -44,6 +44,8 @@ export default { HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`, HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`, HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`, + HOST_RESEND_PROFILE: (hostId: number, profileUUID: string) => + `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`, INVITES: `/${API_VERSION}/fleet/invites`, LABELS: `/${API_VERSION}/fleet/labels`, From ecdcb7c2fb5772ff23fd0269699e5a6a61a89ce2 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:18:09 -0500 Subject: [PATCH 03/21] Add activity item for resend configuration profile (#18271) --- docs/Using Fleet/Audit-logs.md | 19 +++++++++++++++++++ server/fleet/activities.go | 24 ++++++++++++++++++++++++ server/service/integration_mdm_test.go | 18 +++++++++++++++--- server/service/mdm.go | 12 +++++++++++- server/service/mdm_test.go | 3 +++ 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index 700f2e869a..d0bda3d634 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1108,6 +1108,25 @@ This activity contains the following fields: } ``` +## resent_configuration_profile + +Generated when a user resends an MDM configuration profile to a host. + +This activity contains the following fields: +- "host_id": The ID of the host. +- "host_display_name": The display name of the host. +- "profile_name": The name of the configuration profile. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", + "profile_name": "Passcode requirements" +} +``` + diff --git a/server/fleet/activities.go b/server/fleet/activities.go index f299222c24..00a2964455 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -88,6 +88,8 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeCreatedDeclarationProfile{}, ActivityTypeDeletedDeclarationProfile{}, ActivityTypeEditedDeclarationProfile{}, + + ActivityTypeResentConfigurationProfile{}, } type ActivityDetails interface { @@ -1390,6 +1392,28 @@ func (a ActivityTypeEditedDeclarationProfile) Documentation() (activity string, }` } +type ActivityTypeResentConfigurationProfile struct { + HostID *uint `json:"host_id"` + HostDisplayName *string `json:"host_display_name"` + ProfileName string `json:"profile_name"` +} + +func (a ActivityTypeResentConfigurationProfile) ActivityName() string { + return "resent_configuration_profile" +} + +func (a ActivityTypeResentConfigurationProfile) Documentation() (activity string, details string, detailsExample string) { + return `Generated when a user resends an MDM configuration profile to a host.`, + `This activity contains the following fields: +- "host_id": The ID of the host. +- "host_display_name": The display name of the host. +- "profile_name": The name of the configuration profile.`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", + "profile_name": "Passcode requirements" +}` +} + // LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams. func LogRoleChangeActivities(ctx context.Context, ds Datastore, adminUser *User, oldGlobalRole *string, oldTeamRoles []UserTeam, user *User) error { if user.GlobalRole != nil && (oldGlobalRole == nil || *oldGlobalRole != *user.GlobalRole) { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ff68f1f013..d1a0e4ddc2 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -791,14 +791,18 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() { require.Equal(t, prof, installs[0]) require.Empty(t, removes) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + s.lastActivityMatches( + fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID), + 0) // add a declaration to the team - declIdent := "decl-ident-" + t.Name() + declIdent := "decl-ident-" + uuid.NewString() fields := map[string][]string{ "team_id": {fmt.Sprintf("%d", tm.ID)}, } body, headers := generateNewProfileMultipartRequest( - t, declIdent+".json", declarationForTest(declIdent), s.token, fields, + t, "some-declaration.json", declarationForTest(declIdent), s.token, fields, ) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers) var resp newMDMConfigProfileResponse @@ -837,6 +841,10 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() { _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted) checkDDMSync(mdmDevice) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) + s.lastActivityMatches( + fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": "some-declaration"}`, host.ID, host.DisplayName()), + 0) // transfer the host to the global team err = s.ds.AddHostsToTeam(ctx, nil, []uint{host.ID}) @@ -11007,7 +11015,11 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { // can resend a profile after it has failed res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted) verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent - checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying + checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so back to verifying + s.lastActivityMatches( + fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+teamProfiles[0]), + 0) // add a macOS profile to the team mcUUID := "a" + uuid.NewString() diff --git a/server/service/mdm.go b/server/service/mdm.go index 2035bfa319..322298d02b 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2025,6 +2025,7 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi } var profileTeamID *uint + var profileName string switch { case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix): if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { @@ -2038,6 +2039,7 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi return ctxerr.Wrap(ctx, err, "getting apple config profile") } profileTeamID = prof.TeamID + profileName = prof.Name case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix): if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { @@ -2051,6 +2053,7 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi return ctxerr.Wrap(ctx, err, "getting apple declaration") } profileTeamID = decl.TeamID + profileName = decl.Name case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix): if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { @@ -2064,6 +2067,7 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi return ctxerr.Wrap(ctx, err, "getting windows config profile") } profileTeamID = prof.TeamID + profileName = prof.Name default: return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Invalid profile UUID prefix.").WithStatus(http.StatusNotFound), "check profile UUID prefix") @@ -2093,7 +2097,13 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi return ctxerr.Wrap(ctx, err, "resending host mdm profile") } - // TODO: log activity? + if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeResentConfigurationProfile{ + HostID: &host.ID, + HostDisplayName: ptr.String(host.DisplayName()), + ProfileName: profileName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for resend config profile") + } return nil } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index aeca6c87e3..013571ff31 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1708,6 +1708,9 @@ func TestMDMResendConfigProfileAuthz(t *testing.T) { ds.ResendHostMDMProfileFunc = func(ctx context.Context, hostUUID, profUUID string) error { return nil } + ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { + return nil + } checkShouldFail := func(t *testing.T, err error, shouldFail bool) { if !shouldFail { From 8b9099717df4ea44f5fc49da1fe67746b506557b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:44:54 -0500 Subject: [PATCH 04/21] Bump tar from 6.1.11 to 6.2.1 in /tools/fleetctl-npm (#18179) --- tools/fleetctl-npm/yarn.lock | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/fleetctl-npm/yarn.lock b/tools/fleetctl-npm/yarn.lock index fabb81d6c0..edab6c3351 100644 --- a/tools/fleetctl-npm/yarn.lock +++ b/tools/fleetctl-npm/yarn.lock @@ -128,6 +128,11 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" @@ -166,13 +171,13 @@ rimraf@3.0.2: glob "^7.1.3" tar@^6.1.9: - version "6.1.11" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" - integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" - minipass "^3.0.0" + minipass "^5.0.0" minizlib "^2.1.1" mkdirp "^1.0.3" yallist "^4.0.0" From 989764969604a87128cf8b3fc9e14e4bc175f908 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:56:25 -0500 Subject: [PATCH 05/21] Update osquery-perf with DDM functionality (#18273) --- cmd/osquery-perf/agent.go | 210 ++++++++++++++++++++++++++++++++++---- server/fleet/apple_mdm.go | 20 ++++ 2 files changed, 210 insertions(+), 20 deletions(-) diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index a1aea3b1bd..6a7af0964c 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -29,6 +29,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service" "github.com/google/uuid" @@ -135,25 +136,33 @@ func init() { } type Stats struct { - startTime time.Time - errors int - osqueryEnrollments int - orbitEnrollments int - mdmEnrollments int - mdmSessions int - distributedWrites int - mdmCommandsReceived int - distributedReads int - configRequests int - configErrors int - resultLogRequests int - orbitErrors int - mdmErrors int - desktopErrors int - distributedReadErrors int - distributedWriteErrors int - resultLogErrors int - bufferedLogs int + startTime time.Time + errors int + osqueryEnrollments int + orbitEnrollments int + mdmEnrollments int + mdmSessions int + distributedWrites int + mdmCommandsReceived int + distributedReads int + configRequests int + configErrors int + resultLogRequests int + orbitErrors int + mdmErrors int + ddmDeclarationItemsErrors int + ddmConfigurationErrors int + ddmActivationErrors int + ddmStatusErrors int + ddmDeclarationItemsSuccess int + ddmConfigurationSuccess int + ddmActivationSuccess int + ddmStatusSuccess int + desktopErrors int + distributedReadErrors int + distributedWriteErrors int + resultLogErrors int + bufferedLogs int l sync.Mutex } @@ -236,6 +245,54 @@ func (s *Stats) IncrementMDMErrors() { s.mdmErrors++ } +func (s *Stats) IncrementDDMDeclarationItemsErrors() { + s.l.Lock() + defer s.l.Unlock() + s.ddmDeclarationItemsErrors++ +} + +func (s *Stats) IncrementDDMConfigurationErrors() { + s.l.Lock() + defer s.l.Unlock() + s.ddmConfigurationErrors++ +} + +func (s *Stats) IncrementDDMActivationErrors() { + s.l.Lock() + defer s.l.Unlock() + s.ddmActivationErrors++ +} + +func (s *Stats) IncrementDDMStatusErrors() { + s.l.Lock() + defer s.l.Unlock() + s.ddmStatusErrors++ +} + +func (s *Stats) IncrementDDMDeclarationItemsSuccess() { + s.l.Lock() + defer s.l.Unlock() + s.ddmDeclarationItemsSuccess++ +} + +func (s *Stats) IncrementDDMConfigurationSuccess() { + s.l.Lock() + defer s.l.Unlock() + s.ddmConfigurationSuccess++ +} + +func (s *Stats) IncrementDDMActivationSuccess() { + s.l.Lock() + defer s.l.Unlock() + s.ddmActivationSuccess++ +} + +func (s *Stats) IncrementDDMStatusSuccess() { + s.l.Lock() + defer s.l.Unlock() + s.ddmStatusSuccess++ +} + func (s *Stats) IncrementDesktopErrors() { s.l.Lock() defer s.l.Unlock() @@ -274,7 +331,7 @@ func (s *Stats) Log() { defer s.l.Unlock() log.Printf( - "uptime: %s, error rate: %.2f, osquery enrolls: %d, orbit enrolls: %d, mdm enrolls: %d, distributed/reads: %d, distributed/writes: %d, config requests: %d, result log requests: %d, mdm sessions initiated: %d, mdm commands received: %d, config errors: %d, distributed/read errors: %d, distributed/write errors: %d, log result errors: %d, orbit errors: %d, desktop errors: %d, mdm errors: %d, buffered logs: %d", + "uptime: %s, error rate: %.2f, osquery enrolls: %d, orbit enrolls: %d, mdm enrolls: %d, distributed/reads: %d, distributed/writes: %d, config requests: %d, result log requests: %d, mdm sessions initiated: %d, mdm commands received: %d, config errors: %d, distributed/read errors: %d, distributed/write errors: %d, log result errors: %d, orbit errors: %d, desktop errors: %d, mdm errors: %d, ddm declaration items success: %d, ddm declaration items errors: %d, ddm activation success: %d, ddm activation errors: %d, ddm configuration success: %d, ddm configuration errors: %d, ddm status success: %d, ddm status errors: %d, buffered logs: %d", time.Since(s.startTime).Round(time.Second), float64(s.errors)/float64(s.osqueryEnrollments), s.osqueryEnrollments, @@ -293,6 +350,14 @@ func (s *Stats) Log() { s.orbitErrors, s.desktopErrors, s.mdmErrors, + s.ddmDeclarationItemsSuccess, + s.ddmDeclarationItemsErrors, + s.ddmActivationSuccess, + s.ddmActivationErrors, + s.ddmConfigurationSuccess, + s.ddmConfigurationErrors, + s.ddmStatusSuccess, + s.ddmStatusErrors, s.bufferedLogs, ) } @@ -943,10 +1008,115 @@ func (a *agent) runMacosMDMLoop() { a.stats.IncrementMDMErrors() break INNER_FOR_LOOP } + if mdmCommandPayload != nil && mdmCommandPayload.Command.RequestType == "DeclarativeManagement" { + a.doDeclarativeManagement(mdmCommandPayload) + } } } } +func (a *agent) doDeclarativeManagement(cmd *mdm.Command) { + // defer log.Printf("Exiting DeclarativeManagement for command %s", cmd.CommandUUID) + + // get declaration-items endpoint + r, err := a.macMDMClient.DeclarativeManagement("declaration-items") + if err != nil { + log.Printf("DDM %s declaration-items request failed: %s", cmd.CommandUUID, err) + a.stats.IncrementDDMDeclarationItemsErrors() + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("DDM %s declaration-items read body failed: %s", cmd.CommandUUID, err) + a.stats.IncrementDDMDeclarationItemsErrors() + return + } + var items fleet.MDMAppleDDMDeclarationItemsResponse + err = json.Unmarshal(body, &items) + if err != nil { + log.Printf("DDM %s declaration-items unmarshal failed: %s", cmd.CommandUUID, err) + a.stats.IncrementDDMDeclarationItemsErrors() + return + } + a.stats.IncrementDDMDeclarationItemsSuccess() + + // get declaration/configuration/:identifer endpoint + for _, d := range items.Declarations.Configurations { + path := fmt.Sprintf("declaration/%s/%s", "configuration", d.Identifier) + r, err := a.macMDMClient.DeclarativeManagement(path) + if err != nil { + log.Printf("DDM %s request failed: %s", path, err) + a.stats.IncrementDDMConfigurationErrors() + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("DDM %s read body failed: %s", path, err) + a.stats.IncrementDDMConfigurationErrors() + return + } + var decl fleet.MDMAppleDeclaration + err = json.Unmarshal(body, &decl) + if err != nil { + log.Printf("DDM %s unmarshal failed: %s", path, err) + a.stats.IncrementDDMConfigurationErrors() + return + } + } + a.stats.IncrementDDMConfigurationSuccess() + + // get declaration/activation/:identifer endpoint + for _, d := range items.Declarations.Activations { + path := fmt.Sprintf("declaration/%s/%s", "activation", d.Identifier) + r, err := a.macMDMClient.DeclarativeManagement(path) + if err != nil { + log.Printf("DDM %s request failed: %s", path, err) + a.stats.IncrementDDMActivationErrors() + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("DDM %s read body failed: %s", path, err) + a.stats.IncrementDDMActivationErrors() + return + } + var act fleet.MDMAppleDDMActivation + err = json.Unmarshal(body, &act) + if err != nil { + log.Printf("DDM %s unmarshal failed: %s", path, err) + a.stats.IncrementDDMActivationErrors() + return + } + } + a.stats.IncrementDDMActivationSuccess() + + // sent status report + for _, d := range items.Declarations.Configurations { + report := fleet.MDMAppleDDMStatusReport{} + report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{ + {Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: d.Identifier, ServerToken: d.ServerToken}, + } + r, err := a.macMDMClient.DeclarativeManagement("status", report) + if err != nil { + log.Printf("DDM %s status request failed: %s", d.Identifier, err) + a.stats.IncrementDDMStatusErrors() + return + } + + // Apple's documentation has some conflicting information about the expected status here so we'll + // just check for both. + // + // https://developer.apple.com/documentation/devicemanagement/get_the_device_status#response-codes + // https://developer.apple.com/documentation/devicemanagement/statusreport#discussion + if r.StatusCode != http.StatusOK && r.StatusCode != http.StatusNoContent { + log.Printf("DDM %s status response unexpected: %d", d.Identifier, r.StatusCode) + a.stats.IncrementDDMStatusErrors() + return + } + } + a.stats.IncrementDDMStatusSuccess() +} + func (a *agent) runWindowsMDMLoop() { mdmCheckInTicker := time.Tick(a.MDMCheckInInterval) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 1ee60cb3b9..a646bddcad 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -819,3 +819,23 @@ type MDMAppleDDMStatusErrorReason struct { // error. Details map[string]any `json:"Details"` } + +// MDMAppleDDMActivationPayload represents the payload of an activation declaration. +// +// https://developer.apple.com/documentation/devicemanagement/activationsimple +type MDMAppleDDMActivationPayload struct { + Predicate string `json:"Predicate"` + StandardConfigurations []string `json:"StandardConfigurations"` +} + +// MDMAppleDDMActivation represents the declaration of an activation. It combines the base +// declaation with the activation payload. +// +// https://developer.apple.com/documentation/devicemanagement/declarationbase +// https://developer.apple.com/documentation/devicemanagement/activationsimple +type MDMAppleDDMActivation struct { + Identifier string `json:"Identifier"` + Payload MDMAppleDDMActivationPayload `json:"Payload"` + ServerToken string `json:"ServerToken"` + Type string `json:"Type"` // "com.apple.activation.simple" +} From 1cc76c7cec9a71e140d80b52e028f272f80f837b Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Mon, 15 Apr 2024 16:10:11 -0500 Subject: [PATCH 06/21] Fixed label (#18286) cc @JoStableford --- handbook/business-operations/business-operations.rituals.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handbook/business-operations/business-operations.rituals.yml b/handbook/business-operations/business-operations.rituals.yml index e5f2c58c76..814f3cdf95 100644 --- a/handbook/business-operations/business-operations.rituals.yml +++ b/handbook/business-operations/business-operations.rituals.yml @@ -50,7 +50,7 @@ moreInfoUrl: dri: "jostableford" autoIssue: - labels: [ "#g-digital-experience" ] + labels: [ "#g-business-operations" ] repo: "confidential" - task: "AP invoice monitoring" # TODO tie this to a responsibility @@ -108,7 +108,7 @@ frequency: "Monthly" description: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" - dri: "hughestaylor" + dri: "joStableford" autoIssue: labels: [ "#g-business-operations" ] repo: "confidential" From 775fa70c53a5e10866e3a4edc2579353c2ddaf58 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 15 Apr 2024 17:50:14 -0400 Subject: [PATCH 07/21] Fix Apple profile upload error message when identifier is a duplicate. (#18272) #18081 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#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] Manual QA for all new/changed functionality --- changes/18081-upload-apple-profile-error-message | 1 + server/datastore/mysql/errors.go | 4 ++++ server/service/apple_mdm.go | 8 +++++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changes/18081-upload-apple-profile-error-message diff --git a/changes/18081-upload-apple-profile-error-message b/changes/18081-upload-apple-profile-error-message new file mode 100644 index 0000000000..4b6ad0f0da --- /dev/null +++ b/changes/18081-upload-apple-profile-error-message @@ -0,0 +1 @@ +* Fixed the error message so that it indicates if a conflict error on uploading an Apple profile was caused by the profile's name or its identifier. diff --git a/server/datastore/mysql/errors.go b/server/datastore/mysql/errors.go index 7bcaf3ac32..24d7c371cc 100644 --- a/server/datastore/mysql/errors.go +++ b/server/datastore/mysql/errors.go @@ -106,6 +106,10 @@ func (e *existsError) IsExists() bool { return true } +func (e *existsError) Resource() string { + return e.ResourceType +} + func isDuplicate(err error) bool { err = ctxerr.Cause(err) if driverErr, ok := err.(*mysql.MySQLError); ok { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 2d6e847f6a..d1fa9f5f7e 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -359,7 +359,13 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r if err != nil { var existsErr existsErrorInterface if errors.As(err, &existsErr) { - err = fleet.NewInvalidArgumentError("profile", "Couldn't upload. A configuration profile with this name already exists."). + msg := "Couldn't upload. A configuration profile with this name already exists." + if re, ok := existsErr.(interface{ Resource() string }); ok { + if re.Resource() == "MDMAppleConfigProfile.PayloadIdentifier" { + msg = "Couldn't upload. A configuration profile with this identifier (PayloadIdentifier) already exists." + } + } + err = fleet.NewInvalidArgumentError("profile", msg). WithStatus(http.StatusConflict) } return nil, ctxerr.Wrap(ctx, err) From 46f7b6b04324f990236acbb900df840d12467ddc Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 15 Apr 2024 16:14:21 -0600 Subject: [PATCH 08/21] Add Failing Policy Counts to Health API (#17758) --- changes/16205-health-failing-counts | 1 + server/datastore/mysql/hosts.go | 32 ++++++- server/fleet/hosts.go | 29 +++++-- server/service/integration_core_test.go | 38 +++++---- server/service/integration_enterprise_test.go | 85 ++++++++++++++++++- 5 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 changes/16205-health-failing-counts diff --git a/changes/16205-health-failing-counts b/changes/16205-health-failing-counts new file mode 100644 index 0000000000..df792a3fa6 --- /dev/null +++ b/changes/16205-health-failing-counts @@ -0,0 +1 @@ +- The Host Health API now includes failing policy counts \ No newline at end of file diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 6daf64222b..f3d66c7f4e 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -15,7 +15,9 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" @@ -4973,7 +4975,11 @@ func (ds *Datastore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHea for _, s := range host.Software { if len(s.Vulnerabilities) > 0 { - hh.VulnerableSoftware = append(hh.VulnerableSoftware, s) + hh.VulnerableSoftware = append(hh.VulnerableSoftware, fleet.HostHealthVulnerableSoftware{ + ID: s.ID, + Name: s.Name, + Version: s.Version, + }) } } @@ -4982,12 +4988,34 @@ func (ds *Datastore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHea return nil, err } + isPremium := license.IsPremium(ctx) for _, p := range policies { if p.Response == "fail" { - hh.FailingPolicies = append(hh.FailingPolicies, p) + var critical *bool + if isPremium { + critical = &p.Critical + } + hh.FailingPolicies = append(hh.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: p.ID, + Name: p.Name, + Resolution: p.Resolution, + Critical: critical, + }) } } + hh.FailingPoliciesCount = len(hh.FailingPolicies) + + if license.IsPremium(ctx) { + var count int + for _, p := range hh.FailingPolicies { + if p.Critical != nil && *p.Critical { + count++ + } + } + hh.FailingCriticalPoliciesCount = ptr.Int(count) + } + return &hh, nil } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 809ab12d54..ae0124c0d8 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -367,13 +367,28 @@ type HostOrbitInfo struct { // HostHealth contains a subset of Host data that indicates how healthy a Host is. For fields with // the same name, see the comments/docs for the Host field above. type HostHealth struct { - UpdatedAt time.Time `json:"updated_at,omitempty" db:"updated_at"` - OsVersion string `json:"os_version,omitempty" db:"os_version"` - DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty" db:"disk_encryption_enabled"` - VulnerableSoftware []HostSoftwareEntry `json:"vulnerable_software,omitempty"` - FailingPolicies []*HostPolicy `json:"failing_policies,omitempty"` - Platform string `json:"-" db:"platform"` // Needed to fetch failing policies. Not returned in HTTP responses. - TeamID *uint `json:"team_id,omitempty" db:"team_id"` // Needed to verify that user can access this host's health data. Not returned in HTTP responses. + UpdatedAt time.Time `json:"updated_at,omitempty" db:"updated_at"` + OsVersion string `json:"os_version,omitempty" db:"os_version"` + DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty" db:"disk_encryption_enabled"` + FailingPoliciesCount int `json:"failing_policies_count"` + FailingCriticalPoliciesCount *int `json:"failing_critical_policies_count,omitempty"` // Fleet Premium Only + VulnerableSoftware []HostHealthVulnerableSoftware `json:"vulnerable_software,omitempty"` + FailingPolicies []*HostHealthFailingPolicy `json:"failing_policies,omitempty"` + Platform string `json:"-" db:"platform"` // Needed to fetch failing policies. Not returned in HTTP responses. + TeamID *uint `json:"team_id,omitempty" db:"team_id"` // Needed to verify that user can access this host's health data. Not returned in HTTP responses. +} + +type HostHealthVulnerableSoftware struct { + ID uint `json:"id"` + Name string `json:"name"` + Version string `json:"version"` +} + +type HostHealthFailingPolicy struct { + ID uint `json:"id"` + Name string `json:"name"` + Critical *bool `json:"critical,omitempty"` // Fleet Premium Only + Resolution *string `json:"resolution"` } func (hh HostHealth) AuthzType() string { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 273f4ec513..d3d1e99db8 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -10612,11 +10612,6 @@ func results(num int, hostID string) string { func (s *integrationTestSuite) TestHostHealth() { t := s.T() - team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ - Name: "team1", - }) - require.NoError(t, err) - host, err := s.ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), OsqueryHostID: ptr.String(t.Name() + "hostid1"), @@ -10631,7 +10626,7 @@ func (s *integrationTestSuite) TestHostHealth() { OSVersion: "Mac OS X 10.14.6", Platform: "darwin", CPUType: "cpuType", - TeamID: ptr.Uint(team.ID), + TeamID: nil, }) require.NoError(t, err) require.NotNil(t, host) @@ -10670,19 +10665,17 @@ func (s *integrationTestSuite) TestHostHealth() { require.NoError(t, err) require.True(t, inserted) - user1 := test.NewUser(t, s.ds, "Joe", "joe@example.com", true) - - q1 := test.NewQuery(t, s.ds, nil, "passing_query", "select 1", 0, true) - defer cleanupQuery(s, q1.ID) - passingPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, &user1.ID, fleet.PolicyPayload{ - QueryID: &q1.ID, + passingPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "passing_policy", + Query: "select 1", + Resolution: "Run this command to fix it", }) require.NoError(t, err) - q2 := test.NewQuery(t, s.ds, nil, "failing_query", "select 0", 0, true) - defer cleanupQuery(s, q2.ID) - failingPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, &user1.ID, fleet.PolicyPayload{ - QueryID: &q2.ID, + failingPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "failing_policy", + Query: "select 0", + Resolution: "Run this command to fix it", }) require.NoError(t, err) @@ -10698,7 +10691,20 @@ func (s *integrationTestSuite) TestHostHealth() { assert.NotNil(t, hh.HostHealth) assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion) assert.Len(t, hh.HostHealth.VulnerableSoftware, 1) + assert.Equal(t, hh.HostHealth.VulnerableSoftware[0], fleet.HostHealthVulnerableSoftware{ + ID: soft1.ID, + Name: soft1.Name, + Version: soft1.Version, + }) + assert.Equal(t, 1, hh.HostHealth.FailingPoliciesCount) + assert.Nil(t, hh.HostHealth.FailingCriticalPoliciesCount) assert.Len(t, hh.HostHealth.FailingPolicies, 1) + assert.Equal(t, hh.HostHealth.FailingPolicies[0], &fleet.HostHealthFailingPolicy{ + ID: failingPolicy.ID, + Name: failingPolicy.Name, + Resolution: failingPolicy.Resolution, + Critical: nil, + }) assert.True(t, *hh.HostHealth.DiskEncryptionEnabled) // Check that the TeamID didn't make it into the response assert.Nil(t, hh.HostHealth.TeamID) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 7504218bb4..56272fa1b6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -32,8 +32,8 @@ import ( "github.com/fleetdm/fleet/v4/server/pubsub" "github.com/fleetdm/fleet/v4/server/service/schedule" "github.com/fleetdm/fleet/v4/server/test" - kitlog "github.com/go-kit/kit/log" "github.com/go-kit/log" + kitlog "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" @@ -3279,6 +3279,89 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() { } } +func (s *integrationEnterpriseTestSuite) TestHostHealth() { + t := s.T() + + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + OsqueryHostID: ptr.String(t.Name() + "hostid1"), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(t.Name() + "nodekey1"), + UUID: t.Name() + "uuid1", + Hostname: t.Name() + "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + OSVersion: "Mac OS X 10.14.6", + Platform: "darwin", + CPUType: "cpuType", + TeamID: ptr.Uint(team.ID), + }) + require.NoError(t, err) + require.NotNil(t, host) + + passingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{ + Name: "Passing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + }) + require.NoError(t, err) + + failingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{ + Name: "Failing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + Critical: true, + }) + require.NoError(t, err) + + passingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "Passing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + }) + require.NoError(t, err) + + failingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "Failing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + Critical: false, + }) + require.NoError(t, err) + + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingGlobalPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingGlobalPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingTeamPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingTeamPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + + hh := getHostHealthResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", host.ID), nil, http.StatusOK, &hh) + require.Equal(t, host.ID, hh.HostID) + assert.NotNil(t, hh.HostHealth) + assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion) + assert.Equal(t, 2, hh.HostHealth.FailingPoliciesCount) + assert.Equal(t, ptr.Int(1), hh.HostHealth.FailingCriticalPoliciesCount) + assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: failingTeamPolicy.ID, + Name: failingTeamPolicy.Name, + Resolution: failingTeamPolicy.Resolution, + Critical: ptr.Bool(true), + }) + assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: failingGlobalPolicy.ID, + Name: failingGlobalPolicy.Name, + Resolution: failingGlobalPolicy.Resolution, + Critical: ptr.Bool(false), + }) +} + func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() { t := s.T() var resp listVulnerabilitiesResponse From 79fbc0064719ef14a31f0d719f554fe33bbcf124 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Mon, 15 Apr 2024 17:29:03 -0500 Subject: [PATCH 09/21] As of Go 1.21, toolchain versions must use the 1.N.P syntax (#18288) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cdc4b0a28e..7a218e1cd3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/v4 -go 1.21 +go 1.21.0 require ( cloud.google.com/go/pubsub v1.33.0 From 04e88afba48653c71cc7713b4933c2ea63286385 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Mon, 15 Apr 2024 17:49:28 -0500 Subject: [PATCH 10/21] Add myself to codeowners for /it-and-security/ as a fallback approver (#18292) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 320a4a4c5d..5f5e75918c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -57,7 +57,7 @@ go.mod @fleetdm/go /infrastructure/ @rfairburn @ksatter @lukeheath @edwardsb /charts/ @rfairburn @ksatter @lukeheath @edwardsb /terraform/ @rfairburn @ksatter @lukeheath @edwardsb -/it-and-security/ @noahtalerman +/it-and-security/ @noahtalerman @lukeheath ############################################################################################## # ⚗️ Reference, config surface, built-in queries, API, and other documentation. From 160448f7d311dc7381fc91a8e20772cb6352654c Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Mon, 15 Apr 2024 17:52:15 -0500 Subject: [PATCH 11/21] Add spaces after emojis in team names (#18249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind of a silly PR 😅 The team names used to have spaces after the emojis and I thought it looked a little more polished 💅 --- .github/workflows/dogfood-gitops.yml | 2 +- it-and-security/teams/servers-canary.yml | 2 +- it-and-security/teams/servers.yml | 2 +- it-and-security/teams/workstations-canary.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index 95f55254a3..61921b9a7e 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -52,7 +52,7 @@ jobs: FLEET_GITOPS_DIR: ${{ github.workspace }}/it-and-security FLEET_URL: https://dogfood.fleetdm.com FLEET_API_TOKEN: ${{ secrets.DOGFOOD_API_TOKEN }} - DOGFOOD_APPLE_BM_DEFAULT_TEAM: "💻Workstations" + DOGFOOD_APPLE_BM_DEFAULT_TEAM: "💻 Workstations" DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL: ${{ secrets.DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL }} DOGFOOD_GLOBAL_ENROLL_SECRET: ${{ secrets.DOGFOOD_GLOBAL_ENROLL_SECRET }} DOGFOOD_SSO_ISSUER_URI: ${{ secrets.DOGFOOD_SSO_ISSUER_URI }} diff --git a/it-and-security/teams/servers-canary.yml b/it-and-security/teams/servers-canary.yml index d87ea5bcba..36d5c906e1 100644 --- a/it-and-security/teams/servers-canary.yml +++ b/it-and-security/teams/servers-canary.yml @@ -1,4 +1,4 @@ -name: "☁️🐣Servers (canary)" +name: "☁️🐣 Servers (canary)" team_settings: features: enable_host_users: false diff --git a/it-and-security/teams/servers.yml b/it-and-security/teams/servers.yml index 212a2bd0ab..c43085a695 100644 --- a/it-and-security/teams/servers.yml +++ b/it-and-security/teams/servers.yml @@ -1,4 +1,4 @@ -name: "☁️Servers" +name: "☁️ Servers" team_settings: features: enable_host_users: true diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index 7ec46d1f36..6523094cee 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -1,4 +1,4 @@ -name: "💻🐣Workstations (canary)" +name: "💻🐣 Workstations (canary)" team_settings: features: enable_host_users: true From 5311aec0fec0b04b071b1e2e9637689825d8cf1a Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 15 Apr 2024 18:09:16 -0500 Subject: [PATCH 12/21] Website: Add calendar section to homepage (#18209) Closes: https://github.com/fleetdm/confidential/issues/6059 Changes: - Updated the layout and styles of the homepage to match the latest wireframes. @mike-j-thomas I'm creating this as a draft PR so you can review the changes while I set up the Salesforce integration. --------- Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- .../images/homepage-calendar-1280x420@2x.png | Bin 0 -> 148184 bytes website/assets/styles/pages/homepage.less | 111 +++++++++++------- website/views/pages/homepage.ejs | 34 +++++- 3 files changed, 102 insertions(+), 43 deletions(-) create mode 100644 website/assets/images/homepage-calendar-1280x420@2x.png diff --git a/website/assets/images/homepage-calendar-1280x420@2x.png b/website/assets/images/homepage-calendar-1280x420@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..00e87d8e59500f5fc649df6e47f5ad70ef3a0876 GIT binary patch literal 148184 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

5mcQqni?ly_h* zyXnnn(wz4}_Zd=$VGCus(5sz$zhEPoktu*0YO?v)3m?zHing*ZU%DA)CJir^PNmTm z^b}!r=&NA4-{f0~kF&@vx-F3hyuwq?xzyiFlyz(DMB+J`$jMxxDxgd*%`4)KIMM`1 zeeMjeOE(^+b{WQ_&7rLP>m@MBP`0-+kfxz3CuV!Ts8{uzZBvBbPAY?}PK^w|(cG9BvN0AHzJod~;{6l7eFVShPt0?0?3=zL3(_N@%T4n4oXpb*@sb!zHW?$Et1oP@hI}~L<+flyU zF+im&cmj=Xo!`2@`O^@wRNI&NuTw@;F;`s2O$S(r1a{V0_l7jm=Xh>BR@Q9$CTCw- zPK>HfUBw<95gB%7Zq&5n84wpJq#$>yi3&Q#Ucj+7ncHtDhm2wD#MX&5?R6*eu|W{L0y*UjX4K{{uLWP2ln(7klJ|ALGAIJO5l|r>pjp9kIoR$I7rJ zOVFaYNwfTEkgV2GzF4zyOS7?!5D~k>oA`;9!0L81PqIMD6UZfMU3~H474rf(ifDbo z8NMe3WCZnT4YGfE=QZu!E&CBRRSL!Ss;lJ~0yrb5txjm{(8-Sx(tEeOmczR%Pi~s} zH!t_uAR64sMI{Y1&C-)Pk9ei*h13NPu1=~cztXP!939PyZZf1n6&gFULYGMrx=GU} z`+%%fz$Z6o>5^?cbL4tiu*WtmIa5Nxud{ERJfW&Wp7;q48T-484X3ZF&9y;4KE zNio2acVW((BX7t(d@|~t0=sVkffSV`^8l)}4XMk`Pr9ngRaZ0J#0^xFI`ak9cKtA9H z$Or8I!#UfLw4YyE`DTCL!&ro!J{Twk3ZXjEck_Zuj&zb!0^Pxo`x7>E)q~w`mmqlg6~LA$qA4UDxXW>0214r}DrZ@qqxEoJe&5NpCt zhC<^5E=r45@Y~7$-AY*hbvgFbw$M%{eL>E&4L4nu*s)zsQ-F$=A+V^NZd!COd$2s6 zNiDCEAiHVjwq~K4xP}7o8Ky42Fb<-S2U38?zk{MYqgJDZ1~olv+MY&%*!Z!01^tX6 zk9&!`3^;H5&jJK*cF4$#G#}}g zr?1{d<>uE*SWh^ftyG*N{(zF-?VGc(gts3#|0a^f3jKR!BBtj`Bta?U8UQ@epl80u zI))tuwpR7)WINY)&I$dEYmNU=<*rE|wgmo;gyVfvE5~0C%n(uf2Y?*lxi?~OSC*9# zj3@rxjH3x{FgG7M`7UmJzV3jK2u6%RhTpY67_}|wk-46@rYyr?{`wO}0c@!hSWb1m zw)UliBy;5NZNLihpWhYzJG!}H_A(d@=A_F_aN3Ta@`FDAn|s-Jo14mC+Ws#PHtaW^ zIk+k(deV?9C`t@Sby;OXb_AbCH}2)J`<|e?kfo4nJo(sgY#y$;cX%WT)A%;wk%3mJ zI|4g&Q}7W~cT9StrN@fk3olxLmEf;7Tl0M5QkX2v*bNkor6+E%u>|+!Zsr zmp-{rvX!nr`e2S0EkY{%=N4+kJgYJ(UC=zxS-QTD&AKQ(^up;ai5e_m@yU+PL0azN ze2PFpUxwz7@8px)5*hp5cBTT3Vlc12WmC2>GaWhtL=;w zQF0gz*`%J!Cd8|L=C31e%ystxV*hORNZ>sJ@ra*pV1G-W*rbY&zr*3hrn99`D)k@1 z0eIxjzdoo~#~6XWm#XCYA5VfkG;@6wDRU-t?#ve6V0y>g943hVht5Szf4$DV)Zrj& zzF=}&YgIe4$U$8ZIibhBFmMPb!Kozj*?z_w27ULYU}hgM20G>j59X^$rgmQrG7;Ri ze{#G)^7szNXVhQFNEP+}@)<>!A`#w8S?X!EL$r989^RB6L(Yzaz0)cm#@}p1U%Doe zT(+I}wOxNn@Q+otXHk%1Efr;d)Zu{?J!-rB<Bjk`DefC4aO&B%!1@2$V?cTra1Y#?yX3LVta1MJN_1&Ar+f{%((EBIi%tW{2tB!%Sq)nVH||28&vIed>Jde^%B zl%tMj{N4dp*d9m@??1qfKNjrM!vz(4q|LTN@{3SpO9~*>7dLUgAC^tI?%?z+5d8gV zvj2MuM3+EV2ENp!I-F!%9_KS2DiqReY);_jand+tbuq^u#zfCz??J!u60MoB&yMGZ ztZ-b@QZ+3O&{@XA%ni4;rVfdd=@|rfbDlyiWSdM4BVy0K?xBDqGM$Rw{AM-$tO%p`dDN{JZ>Ec z4YZN}quT(Fu>9w1|BSF4a$5({&Y;1tdjmi`vLfR0ELFppKSzhoW|*Q*`rBV??WQ6mYdL5L9!ZV|YE<9~9E;E_f3ogg%J;AY$)c z`CBsRQSOY8$rx;&2U0tO8!uXzx{jEiu>7j$%hE=W(zEKFbms^=<8%izWiMV1m_EF7 zLu-{Q+d(~Zo&SEWKhf-Hp<)gd2?v40&GF-5*iaSRdte`{Z_LF|W#yQhjOZi<{jhmy z3dx9*{Ik-;R-FHyxes+of?acQ1k^x;9%g3Vlg%29+UumRCz98N*jqAJJ{~Y_N`d2# zRMT+214$^!d@}MA#rixzZUul);tV9U&@Ot-|Bv#R{j&%fWzSPxsJze4nDTa^hz&05 zMu{aDEBdtkw)MTs%zv^gij0b z{>`r<+++BwQh1sej#79-31lH{_tAzBOn4i6tZMrk>Ud$iHg9Z#@aXZ}z42NXfXES3 z1B)!=Go3n!((873w znCz5>zQGrHuS{x>l|5b??o6c!tldf@E;Sb|G!kskc1ybRNb8#;(b$YD=VhU?N9Co4 z#;wi=fVW%6-I#t;mOuO9-_QNf>wkwq6#jPtm*!3WcV%VyZOs@AhBrqNH+ekVh41Cs zs`hQ!(=6OAJn?3KoI^As>*d3p-QC3BvL2E`cT~**Q&|@HzWW4p6%XJZU&p+cLyH*% zuly!9gJ^kgDv&?Xb2mUSslV;?EGOHtu9*>p$wlLJlAMD{Ya#n7^&pZ=0H|V9RpPyU zSdbgc=|*8c8VM7ydAQg@-AIF^3;+_n?&8@9xc+&0)Lj05-W_WLPGh4_E(^GVE{8mz zot=%d{tFJsU@4H^vwCQ})E4aYn@s5nOiY0Yn|XN>9Cow@f=fIrw?ym2`eI4tVmaO% zNi>tvdhD{n&*_f4Npv~-4pV_KYOu3@rK-cS%TvA7l;p@1F6s}JW{Ae!P8}Dj1j1R! z#%9Ln626d~xnKGO;T!TiV1#RR@$Et@@B3zaoOUNS-xt|7<=HU&WGa-3q**>t{p8t( z9Nli@v9=IjAq?@%WEh;zjX7}WHsjuS^+|Mp;n--RG}x|LI}6cX=S#jum&dELXRYP- zc7uakinZNzcH6aG05WT>U1|Ew_5p*TZK)2n&1ta9<8#U2TL-z+P``$*i&7SP%4|xk zGO}H4JzLiSVo&W?cSug|$G>WCeuHjWa+H8J)(rfwvoK2AXX)FZ zJ^RpF?tRb!cJF|Q-S+MNgU5d;F=?Cslit|oQ6K4XFP^mTzhn4HI#)QO*|E`p4K{%k zOAYKapo7~K7pP;IWH<9*q`2w0xHxUs7O*XW!KZ*i>&`}w*AR|;%N2!9+*1IdPe3o* zQ-f)v=uaOnB;D%q8Wpo+UgQl%|Bm@!8)w%%S^k;r!T$;P;5#C z#S?#F%JU&Xv7B#ElwQ_SKA-)q9HZ9%IQKhvSf}z`lHN(0y4jYQ=_igM^R^<$;}7)b zEBU29KOP9gV5bj!?dxr=(l>a>W(X{rcCxmP7&l$q&Knj?&)op2S<*TF%+HOdH9o>m z+L`>R*7jbe-++l>dn@-FFJlXm!#}0RWk8v)L^Xm<5&La(;6_4NBqGJ>H++^jb z4?li4sjX>_1_mL3`ARuCi@iD^@6i=e6->79I797c%4#~7Mtt`ihBKC66}FJyyO*;E zGl))qeAlCJ=#?(cKiuNqe!PFXI1;uokY5jIY&cE2$+=C;9;cDGjH#4A<|CU2gPaExX z4o9^bKo|#+TCEFrg0O_CZtizeN1J2GR-oGHVwnpUQXxlH!RFnS+Xs1j3xWBtCGpVi zYo^gch5!WocScHZBOQ+oz8O@#jBJv_=H>UUy501WNqqF&RJrrU$I`*K#G8EHPY+P0 zj3bkGFUQD+zoMdybH1aCMh#icC+kpYD5r+G`GbvLGFV(zLIg664xI1*LO1~Oa>Dz= zHE|RYCZzTR%{;8Ny>$W4*85X^_H_I!2L`I7V#y68NAvI(0T%?QKR5>3g4NTKMKjgo zB%fvQ`-O6WeSfqm(;B`DVg_#&N%<5%3VevO92#0J%PR)m;~}IBsHc*DUDg(kc)vS! z_qURrlE&uo=mSU)BbZL^VZdcdzE8kwT;#z*g_&REPhf;-taRPiusVO?x!5KBxtZWc z{V^dPz7Nf%22FhtjhW*4g$-VQuT4+)x3k#2q%$@6R@FjUMUjB>S{sG ztww7Os=CV+HpOnfvI#wUnY&T(rCW^mQ#XmWN)(9T3;3%MLnb;v% zg=w&mGVAb$xk)PHhzE&(EF}BaBz1O~CgNN7piq_aoa`wj0ar}PZRpjUHM{qN&c(IO8pWl1eeXE>U_3m$vdBc4U_V@3G5%iZ(FV&{5 zBv*flv5oE`^f~_kizpsMW*C@ih}x*`yjcx$5I?U!cq7(P_Deg==B)N|@|XD}JSL49 zpP)24W@&(?bj6~;j6JjKqC~BGMOs~Y(-3reKo=v>)yevVdM!(n{elnQ?EUTqvSlUB zV5Pr{e#H;w8kwmf!TV`a>+}iSA84O44)ZH8^DHe;k&Q+j9gco4;mgQ$FW>!@x3Oyk zY@uOFCbQV<_llhHWE(pjVIacHx9t9Vwr(~DKdDftk8G_^G7CRaUP{OtC94t!Zr`&E z1gUl|m+W^uiHp~(vu5MNndyEmymSA{(9s`WYu>+8^PsBmir5Cti)W0va&Y*|@eqLv z5Hp^lSc?YJ=H!kP`FFuGARDtwAR}`M$Q&A^Jt-D64y8;0zU@7>7pk`ngZ$vR4y4GO z{oMyhc4?{hI=Ns zE-XcQj!wBxPXYC9A#IFep`Fg4<$9HN2ulGFrEahpdQRyYa@|~7(=Tv^NXVL- z`xWjGu$MEQQ>_66`RwNQJU(5Q*2cSgl?U9!F8r?hzs%NIW~Uf}Z=dgwbYGL3a{b+} z?yxgE>552>SQfEmMFnW{EWPMoXXu&^ixFMWeM6!2WXXfggOSEzj`|lZN@UDt}KkYULp3 z#CQSiGkJn*1nXay|Fpxk&Kxr>3D0u;9K(M3$u)1TqIqjUC~R8Pd-u3aRL>cXZ3(@0 zT1pSbibxtRo^Ke!&)#TgsgxFD{vWR1JD$z<3mA{Bc85lbs`lx!JB-?+EgecrY(iD- zRa;4HZMA6o7`3%VB?vWQRAN-MHCrVih#f@Gk|2@fP1-)+_xJw%bNhT;&biOI&vl)7 zor6cH!f&4^+|*FsKytbT3?#4)!~Qi3$ZBvN{j#K!{c#DQ9@E5fBl@EocE(*BN52)c zR;A^=0?#IdS&6>e-m~I=2j*HXv{)}rJsZTlh+KZG>`*rlcSrG+)Z#Y9mhS-YE;fT&Wu?`@A`Lgso2+^6(aVpak9woWZ> zmYfa6pPXA$J9VJ7zl}(!2(jl~w`kD*erDu*J!{q@R@+Z&Ja=}a*!MOZV~X;8b;4q_ z#1xjh*XZys`lN8g9cxP4V&hNM!w8;>D-VC|Rs63b(S;r2{~Y=&uW8_sun+K z8(lE>67tS98*s+K;`I%nCXW?~>xiEI*_uUR|6}PQ=VN3VYxaxr9*p0J1!LAK|FQMa#2v;GG@M<6yW+az1WQ&YjdTi_yuuQ^xD~K=%afK+#P2KL%~@ z{7Zp1w7EA|mIp>ZI*5T{t&_yg)e)t2e2!bXbR`PKCI3~1JamOAJs`O5aH_+F<_TE*`7J*6nE^4yB zcIeLCF9>_Bix?Y+)|t`e?dv>Gvw?M*4|0gFph|EJTUt8Z(MV(F<=l4?_lg@WI#Lj$ zapA&;du#Knv69g{*Ve0uRmai}hMS1>< z<~{oF3N)*oxruz|Wk-`uqqQWruEdt1%Qd#2ohBCvqBv!5YjjYpf7Bb100C}ZF~$0o z)Y5yxkAb`g?^@*ZMt5{n3KS0*0uStWIXh#S0Q@fIr5=1fUWF6Uu~iDP#Fj^Uhzo12 z3UZxC$yIK|o;@k_1ukM*tZ}sm(gl*qnkkymjCR>)Ui?^D<$JWpJa?I4^NTm)`@N2U zPFyt~g-#-^dJcx@&jXWgDIWB;nn?>gxn(07x(52@;dA`xcT}QK563{yYR_W7Vm|Y$ zvlI<>rOAkAvpFLIyRysCa-%1#?Ad8K%F#Zu9bqaUt*!BS!28@hxU)COD(#CoLRfY; z5bk$#TQAsBO5l05Ue&+x-U_<#>2ar^%9OKL#^p9H68ahn)!*Dd7&;Xc0t(K1QT{sq ztfw*~x_-)?Hmk!t_Tg)6>*~cd66nf1d~ZU$ENZD{1t}oI`Cq*DXLpI?;3~Tv_iTG? z{9-6A!}H}4vA_m|XFwyQV!yu*1S?gjr0Um$x#)6`w3$I$6T zz89S=e*-rCorl00i$>pzxge&=GQt!FGriIO`boR}w~NOS}{>9_z{Icy?x@C7{yMIsUx&Ay`<`bhz^|(0TY(jxiI# zd0wso$Hx(Y1%4kGEuEL|gfNh~9Z&J&mr>7$np0Fh0HfzQkK7CvQf}FZyOE`(;{ET{ z^yg;}x}}9pW1o17ac4?seISO|cYU2(@1t&o`{REGy+S=8uW*o`{b)#hCc?UsG4S-) z@uq`k`r>Ou3WsUG>}pI)&Q8@`zhkgeP98bYM3Xh^pTD^4eTVW$HWbaZBFBB}qYtL= z)QITL6}5QAl_zn&hIgD>XV(AbkAAl%Pq;OX3)!I$iW+|Y-2SJf`kz1ds8?xt?5str zDh!=kS#-G?6I!ga9}W9(>kYHz(&JrL6tdAx+n_Lfw{leZ+`MTXU}%2E5T9~DxOYsU zuX2z*ymMWWYsLvqs?v%s4rIhXHkl-kYMoCZ-6RxEblP3zQ4rg%zpFQqIi${?SH0U; z&gZkfb`H&|{=J}48k?cSUCK$&4H=hQW5D<~V-@Y60nBk935K~!QJ(Jc=Np4urUR=E zV4l_6+@Ot^^jVB9ntwVtw_#a37M5D5$Ec(;>M zYt|i?W24&^SIk5-n61}LVoF`dK0kTCvM0|qd&a>!NBYcU;k6*U9>}HYJN=&>SMAVF z#EA3R-cd*F8tb}=bxdUG(`%dIDn-(C+?_9B|**;0C-%1pyBk$>Fw+;gJ* zL0Ot#4octAC~~fJt%QMp`J$yU{ab14qr!u$Gxqn_*89)QwOb1fN?xulh^XN^p(`$+ zi9`(@sab7Pu&jv=3yG|bp|+lY-l#a*5}RhW^Z%s&UN_yc3++50n{Z1(vv5q&aajh+ z-hbMxF6kEnXLj{;kK9_SRN1QrD+!T_$-CdLYKLrX8YS<}B#exSE=Na4XOMT7S>;+| zVNl74Kn{*r0inHsT2Om2|7_}opLkZK&2@OYuo$O4n47a)=W(qasn^;3h17~MuHT`< zvafzC;h}6s(nkev;ly-Y0JfUvNyMQH zOXB9lM!~15ee9=g27*oc|q=D0hC6%En8bDN--@XEu#f5pO1DFvl}2=lK0 z0quvJotebin6cxzEJYosYdN>iyb8!JC{(t2 zV)%9mDQ|y5BVXjC0Ie*@Prmx?i~BRs42xm!{dpd>V7sZpEU{y$A6dIlsYmg$=E3zD z)FhCY_(oTJjg|9sc|^aY!6k<>Q4SM(_E23vtJ;mOU1cacy%Y2Q_W!^8U*c5Ow*!1! zP+Ifo0_f?Q)LpSH4uzdW&l}lmDO|N!f>!(vYZAP~Gz{_r2;_evDArO}cV(WZBdt&* zOEyY$^C-uN&bG)|QDn)5Gp8QkZSUl1_I{D1;?tlUZ*cYDJJ-1^KLye&IX!ox-777EGrGSt3e^a~CBO7TdvDVPd2SsAQQBN-4zQ3~+jf5&!|EqJ>GK9IF z!rbG7yo`f~-K7FvLYakH&oR9JlF!kE+wnaA%hR0x+b=#Ly-`f*L!#%*cEJ8Y(Yb}z zAdj4ND#72PQ=4H}h|Yc&5#^pxvpZ2HXHv?IUXdp?jX+ z@POv#t&_TOMI6Qavd-8Siyg!yB07Da^r5D=1~IXbS(@%A=M&Fz#0i0Ycx6AbW=vl+ zOtPY0uo*hc9W54rd;;F0T%jSfz71uzo>tDwupEj->1%MhD?HA7Q0f-k_ccbsG|>a( z=i;$lXAQl<*npWy7zi86D~Gt)*CNDz>2r_3hO;R^y7&COFw^lx>XwAT#7vs<*CWMW z6M;fvX)a*aU5_WrXSg-w(v6PrFNU(}Rckj8fXaO0Mz$|J099OG!5Kh4VG=Zx(#Hn`MH1-*A! zXhw7;Q40(g@ipSOcT$HLlDfQ`*4!W~&3pl%HAfRff<%_F_)lCNQiRjhk|5%oW*#P<+aza>@jo@i7+J8CWuyHVSF%Sj_4 zG-|o`0bXl3a@FD1_g`7LBn{;m?D$vm!Q(%)J|=u-U$?W&hQC99Q@zB!phD4De0OtO zv`2(Yg=z)2LY<~HpVg4#IY=%8({=})&V1Y6(0oD(^!2smb(P}m*b%-xWFJbu!*|o1 zvJD^zTxsrVYuuS$S6hi3mp*erdyg-`FtDH{f)=4#SO{M4>UtE+WPtVNIAmF6d*ywe z+LgWQH^)#L>KuQg`}N7xGZw67ki|=DV`tjhlJ7G)1G|pvSvKd1Bab1+pnLmH*0*fU;`{Hd{ytG5qA4bLAW;Y zZECb}kHYHff+Qu|a zD3A9kvIKEeDew{jp(Rk5I^f|BRFY+Fs|j8)+8n8rAy|Dxufc14CK* z`l~5Duf{a3EroyvMWM%bg8bbk=ikg-<>i%s@Te|Q3M_s+`iGtOm3DG0(BbDChMf!4qpx&^U4dI!}K`6fYLDaEb z)^O}*#(LShMBPngMtpsKtf9`_%WNmT8noDr6^VO3$MtpI8ail0r}jUPf>rA+h1C&< zovRg}vl_re9KV;q|4?OHq9l*iC{BcbQnF5zX}qeheiQ1Q6TbpWfKZ%GKdVb7=6LoNM{$`cnO zes27*Fcbd>VQBGRjV;E3vy~O*2)OXwmWpKlC$>5cw!Rli|VBIu!1>7n6S6k!QWQ!)L!f`FPzY z&;GmUfm~|$=%F)Z10Q<}n>1JN$*iRLMr==RosH!e!_!CJ@tzB1$31y5s&}f;aEW{ zft8MLB|d7lFjm-&%okw3bikLt(f{G`v}c!7hE=hU0-*#=6lw{dE_f$~Gh-FHh4Z9> z_~}io<|nK!R;(FaT;ekobBza{(kzS0J9Y3n+QFhi`y9F832|<2IhSdPq&t6WMEGS% zv8)?9pIwBn2$+$GZ*5|4g_r#}dHJE8BWu{09w{NcgX<^WtZ==Q2-fXg z%xuZtpQvROBC`to?AfU*QQyhay_rfGAwss-SrNl$H>1uwo?Y3z8kK_)B}-;1p9~h; z;z@Q(jf>y+ze_G9ZRV%+`7irK{2A+E^&x(f;~us&==9CDPt0>S`8|bMLd<35=>B&1 za2I63_Q1&l2|LQ2h7OfVQO!kW;^DQ0WpDJVIuj48z0uhmTZju+xz4K@-n4f|@vAi7 z$uoy63Hk)kXC4E?Pck^90Gt3Nu8=nq z)?$iQ!xF(}XB8BJ06wSB3eBu@{8#rT!>;8LxyhQ#SiX~%exOC9d%5|Wa?ikQVYs1* zw>Ac6_7g1m0{q?WM_upUxMG*072Kc0Zk763Gr_+J*SS zN-#Ecsr~xPjAiv%BZ4NsOA+uuw%7#}TZ{Iy8ts4n9KfIroL4ry_SC!&=w}fXScFzQ zB#BJzFMn4d-T;xy`O}s!^>08&NSXX zU-jP2w(^DYf{^PjC&$01&Zy`+&gygBIUsPh{ZJx+UFPy}SCvnmew@w_V2r zcu!uKbR*_X(vYK(qpWQ-6$Ta&9UT9$3(0%cw%zi9_+Ht;e%)VB835RP0yrMyJ%C4{ zZh427f~^fsXO&ZtqwM=KH=1s zsmVIRpjpcH&8|H=F_k1? zI&}_GbIDJWwFuz+Xg`2EpnvzsWgh1@=;*@Zya0!bIVw(a-@_0AM7q3768CT?7a>h^@+@_=!LdJauCb> zXkwQfRA2$riMHpC&RwV5c@x)y8;Rut$4U{eZ5Vl8}|WMiR6`KLBY2B zM*DGx;o&na<8~&8m~2Ybj4@Nq&Q2Axg#@U^eGa|EebEajiPaS3<(f^d(`&)t8X~_ErD|%Y*$I+8JAqH zu#xJA{iQU^!QBp5`=D4KeM_qkAu|PEoABma>bIPGXBgAgGFu4ZloDCAM_OC&EQ#V) zc2j#IYZDW@R~nBN__X1ZI6)jLceuG(0JCk^sFR#wwT6X=Rdv7UTp8R3YwskYZ48Ez zPEdRaosUbSdD)nQ;{nIn&%P%p_z#Rs{`rUaOnWfcyIiqeI@nZ8M=#GGmc$Sjq>bc1 zdW);nBCW_K;BIRRb4^t0*lavvzN1fN$*{|4>=C7`Wo4vx5@w z_%q~rZVjag0$hOoDFH18e+BoI48uAykq^|Jy5e7K=P3JO%221$AH9#$YDR}{HcTVF z9bwfZx4E>gm^gSO@mgKg!^hg3;YvR8vC+aD3Och8Dap^KR7FRoa?@xdQf)FW3W9ZF zVi^i1*^+m$WxHN+5Ps*uWWdd?{j>!(Zos7MU*SC>LXy;?-IJp`Nb zED9odqS9~D3#6h#&KYPg>ooR)?UJwM_`ZKENHXu}BB^s2;RZu6W7$!s_RB%pAnG;@ zTJM6|Y-R>CpNrF?v{0Sk>gd+>_&C(_?_wVV>EEN+tJ*0i{ty~e^mW=z>-&mJ9K6rz zRUJ`y9ot!9O0hkMZftVqmuM-5!fzW)+M4L`QK5yKot6gGmbLU9i zk3ji|n+QwA4~2+5>8vNi(T{8Xu@aQRsId__>xu!`A0b9WIC;p|XK8~t##GIqt-zr4 z(=`}xI79Ubdd1(Rq@u;k#4A~7O?8;oXr6m=RXxH8zJZ+bfs2S+8K57~Mh*ibJ+a(I zQ1-U3+hAP@kc+OpS7^E!1!~_hIw5#?u@lm~0%@K}you{`k#J_M0xXS&u=f{W=(Vu) zLr?4-^*r@eu~h`3^(z#lplo|YPjJT8_)P=BC{eI2vEyY(X}goxQzmFTH`S<=3j>6y zs#c+kdRovLHlb}Tc)!;1@<{Bot`UHJPF#EAA)5`-~%Eg;zscD#pJmaxFh5a|g_Ei98uuB! zpGE>Vg+!?_E^DLfX;p=|(7bDZaUSOQgZu8qk~Nc_0#Tk8H`wv1A`8E^3M@wLMUG1Z ztxGe?&aLEtGg?H{%J))lqY`xYR z+gIu?{8CJ2;_7&+M#!|Hf>3-M>7nTv2mdBvy=OL{FN28!7Kn&duE$4r!=iof?@UML zzNK06@Z`oEXaE25ZhGOra>QNlh}QKkNa;*$kgTo@Ws_)^J1n$IB<#}pSgZbbSOtIO zS@*e7k&^_k|K@)T2S(VUp3Z{u16Vs2HjJp)yW`H_y?8j%lm|WKv4ASeUo=oP>$QnB zXm~|^n;`)A6=VEX2Hi>Yex6?^^r|d*%ztEpgrAL2qiBJWYS5gSy5J@`a5{srG=gE0k2^})khY?Z1$-HA5(Kd!(iaLs1eInCmsLn{aZ77>Xz8qIQRvR zc+u^O-%7L3!)4xBT{4+#bZz~*d>gbD=xdlkehJZKGqg|d@5yvTaI;j+ zGV5;jN&mTE5D{(|MQ?eTT;*>>rTBU{Ti+?E`epr`^Mi^{c?G~m_~?WrNn*8B&C7<# zB%3?d6pqh)WrMqguzaBm6xf0`7GN8~EBPgosSOMDewBM!t?L;{M++_qvscyVES3Hi z8l{m1vRV@J8cVf>HvbS>_vX+6&BJqTCHBoW7;T*fi7YpCpkZIZo9GsPXA6!Ja59Ig zTgl0XD*ls{ZaKcwV7GvJt^+KH?ZLA(t!n+r)V)iSN=iyr!&_MEjk=K4M{zv#hb%SH z`ik>$Ew$(ii0{6MGy4^mME>p`yMv>W6*=Do-6(g5Ut1^c<|s_JXp&YUh;;G_qG|~f zI#s1k6wwp{9v;8JI@3(DBj1V}?ggOKwh+QP6L5EAhk^YlOoXl4vJ23R?P5K_#cnwW zLle&lsg-k9GLb09QEnJ-^y`%aP3i&^C5P~6?3V{EMX%tq;z_;9E1(= z;xEjQ?iL~bky=0glV{t8>j_${nGg?kxA9pOFqxOqYZ_EF_}i-ezmz{CmIN`;q>s3o zDCm6;FnlP>;qjmx10)?;p{Rsy%!ln(e+5G|^86hymmE3T1mmAg;vao4*cQ4ogTk4$ zP+4eW`s=)+N@Zh%W^`Ni&V^@5R%03o@_f{%F{gpref@v7Yzp8w`xS#LmecE8LX%(7 zB;!LQPjBVSgeM=K*l1I`-@)p6~`_# zIW^+TscZsN*?XXef)VL1g{!Mgd;*vrW&i^6DxVAC^pExoJnerf> zKS-1R!OJBeqSpZR+E(8h!MJe8}iIwpMkHJfH18JnJerKenZzCnCNo&tdm?NZGx(V`8Yi zEu5}r;8)0|7J&O7xfL+$W%AwH%xecx7BPdz?2nmcFjvZ`#V5Zdt&r5WfjLDk2w|CV zO_CV6fAP=(DJwvIv@cAM66JP4E`|*%cLq-1b%~+xIUFA}+tlm(0^0WsvvOFjo-()5 zK9_VL^&S6j*JJWO7i1aZx}^rK^-2=1!v$><+nw5Oh=20-Vvuv07CFgp>xRsh_AdjrvYMe@7JKvDe#0Fd~eW<=Wu{$z+nn7I>>tNGSb|868 z5rpjp?W7c=?w(b)5_r}nNJx?xAl&cQb z^FkdH)Mk}6&(BETJaZ(`4-$lfC$IXJtGjfyuuqM>^%}qF8rM70ue`mxqJPYhRgr9y zImXi#z%p+Rb6zT~DJ*0;)5%{U&2rHY9b76Hvu-xA*dzdbwaC`ZfJQ!X02_JH&5?~Z z))mN5N$NKT!i37Kbuu3jCf(%~+m-skNp&8PNR2$qxLGQ2j9gC+0qXlC^B!BpjZ;5s zw!H{l+F`?bP%Ku!ScRk_)(HMpUWJosT}*1bvyj-Q^dga_|Z&P0*|(fihHHwHDiaks!LNFz>~TtZi>w&M3X ztqNq5c_Khj3VR8!T{isNCA#`3dJVezYTwv%D}nU}@jag$QPGt^A?`&v+Ae7!E~?oa zTrn;K`a`L`CE&L$t<)gx_ZFn4rjQv$aD?8Ee82zEqyC4a_g@tCumde1ibtb+7eB7i zk;g!ix2Z76rIXPD;}c01T~l91qhwx5u;^&-dkEUD{EF8IB~`m6gV4sfryyQhF=w-= z05v4Kq1}`6o5@4Mf7SnM7lISIkhtZ2xbB9ur%eQ6t;jox4*R-YI%D2x#iP=EyQu(* zl}Qq*%K1DEW!clAs1OlK4zoO>Ez#WIA6uRmms%9r478{tG9_kH@S=NrEE(p+N?N!~ zSRbsX%+|F3^gqz~+lTp|JM{Wn>U&|9T)lWb0!AG3N76UNFGn4(A!+!O3*q3J)VqTq z8K2ImFG;+V{8VxzODCVcZ-ZY~XV%?_ooBMJ#f&YFYxDlRH?3^7ut}uWfIU6%4k7Im zLud#MW#?RbquHMQ83051M7Eg#D6;qCSHkaiPs(8C7)0{*o=vb$=ExO6It59}?els~ zuJ`+bI^B-3BoWg>$46dWOXl!CUB-wUu5D6jay09@5g(sw@SaUW`{G#-p#0LQ>CC&RLEs*QrkDZm91!UrHw=Y6QTx@_0@2)s<^lb?>o~NRM|^X}grnq19a?(^7=I>k=PC6W=bkeDYg}|HtUk7qU^0#sp$W zdp{(T)_9rrV0$=c#9O0Xztw3tft(|v3zTr+2N{jSj#t1|3ES`~YMgJ;l$oS8R!(4+ z%QqxMReG-<@5P*QY|C!My{_{w>q67DMkr}|n?5W()s(rAM0!3#-m8-1Tr><^R|MiQt(@hpe%=>VLNM-0t^|q$x$QX!kem+{9NnM2A{RZP3Hdzr0_0;p4RO?{87oK< zt=6RH^t5U6V6CX$T*<}n!Ty24{Iy=-T=Ki^^D?2N@eDtwmWt@Tfv<_ey&pZB-J;G0 z;D1eBQl=;>Bez9mT0*+^ED-1yYbZk4@C#$z+-f&iCchfvYNW-*B7S_e+$r~HWh25R7DJPS$8J3$ zN5=0Gy@{Spb_bP8y8QhV)+ceiI|Bt;S!<|G%8T8c!!kW?185x~z%eXZuRo;=IhiAJ ztm#x+*z&`cz(HZj#lj;}DV#xcZ|#@VU-Af3)sMeK2T-rA_RF9#mzGqQCjD>vU1mu3t95etlvaA_;UZ%1t2B=xd7X? zvN*7P1>##02?ol!;(YN6=95|^Nt0ZKSj9*-4tp;Or!ajb2H-iB{WdSsLw`D{y8aXO z_;$rTe30^w?K7b5I=4f|Ey((M{X0+NUW{6dv~A}AavDkUys($~_^A#qnsMVvd3pJh zIISpL@hoNUy?lJ=j{^MY>pH>&B>!0J)N$RQwSLOav5xbk@sQ{c@L>1%?{`C(^Kb;H$3dD_ONK>u23l{)8k-tQ^cqneN}^N6muY*K>fi}`TmG@ae&p^UC9CI5G7;j@W) zp7D>=l@*^017Ne0LCPO&CaoX$amM6?&h7$n3-xA@)*d(jKUFb4^1ekT{-tK-+ccdV zB}Y_VDwH->+vEG=)3-Ay%gUkhr%84_olgYF%Nl#XdI}>N+n}sbfmXd< zDY<(=O22;t0pa1#f-84n(&vUdM&WOwNA8W(v{$SRSvdQbJn`DY{}7SX7CKz!MK$!7 zT^*z@4S|VW1D_yuGW1YfFtuNh=VR1}uFT%ieNUvRh`QnODh99%x;QV7XmI%;ar{}A zsP)L&fJphdON~c#_rgjKS*5sKt~GZNVjhu3!sa7>?s;NoC#w$>XJP`-*9iB%2cV7~ z#U?X9-O*T|G1kQ#8(dp#aG^PG-o$B#P=hro4c`c3u>GGlz}k(uxi=CU2N%D)CCT#w z+HC^NkVng`?}-h{g%PTe^k898sV->qIE5o3#CltF2|Zp*iTxIv>*qdjV=BoJXN!fLhn7ZC;P*9OZ7}yH6x}y zg}b-OHnfuHi>*u82v5E^7t{Vh{j@uxR zrT)f7@xw%803h@z*hLxmDy=b+L#iAVWJIBileREP`SGAL1--yj zkzFmJLw4?kA!i#$Sox9qnU9Rf&b^Hc!b&>!EoiBiMgOguI2P9{c{a+RsfeMcT)&Hjfc{ohe}LetUfV4(?Nxt9k+c)olvrl9w_r2twlhZC$l8= z0{taN>*)1#jG9P+PS)-G>iCCnpt?vhF+O0?HQrO#(u3dx{VBB+sGUg-PcFVzm8CU> z9AMV8`FHW*e(7c}bLGCsOxe8Gt*^6ge2g00fQUrR1@zkC2W+DX008WlsG8dt9BkGd zGhONu;ku*5%ZREOOghkLkM^tf#aAO3>eb`wGr#xg(y_mG#Vw0C$V0L;2y5ZYPsrjUmp?iF+N0M?qZL<>!}w@$CY%ZM=DE_b7F2&X!*m0P-O z3?W5ya@owNhWgaE_tADE^00vAhMD-HPqRjpzn|n(Q+qxr<{&F0!V8$(K=X-8nQR_U z+PLTJ8xavP|FdN1%>Y_%xh-hM@R6Tu%v#&cETpzXx$h2Pk5-O1@o!#9g~&`vUR(`O zlJGFldqi4_sE0vCC&K(Gd18B90Oyl31s3{S1^jUCjX-)_ZB&7x#mntp}TO=TMyVYO-^k(V5iUHqt z`()$4-ELy}otmqoqe)%7+bmr*vf-rWgD6v%jt>bK)ZCXLE|s}AywT1wT70#LzTJAb zly5K@E4p%3nh4oGOi82>H6Yf$x!BI2`vS~eS~CR6LnzeyLSY^URtH_Il5%hFqhYI` zUBW4yN%FuXj`oE%nd>r_1Uh#&3X?>1!=0gac6KniP3egFI!~<%0oQZs$k#;$ow3U3 ztv1cAFzVK4Z{uOgL`}Iyr3y<--_c*(miUe_LjnqA)tiE0|eOLOhVH{@YW zW{zxZZeC$0RM7OZ|Hixiore4vVzl~JjUcdk8&@;i*?F%s>Q2+(*@<#l$XqZFy7F#O z(z~|c2pd&D{9#_e0)w_D?Nct&mM`NoC~8bI5PI5nP)_O?y_10Jfp|=@ZUg8DhJ8gX zEWq!&-5FtQStlP&7UwX_hx`tLADlOgEL)ckQ^#{eHK2E}YxYCRK&kq5ffgC$!gh!C z(LSDoLA13$CYui;ksE-?7FFDkc^PxPdJY^8u%IShWjKlC=NbAGqCT*A(fGtLh&W;Z#5K|YObE90Dw)cNF(#`}EL6o(JF4BxMVlEhrw9;`oV zYs}$aOy-QT8Z&j#5{#?3P=uTiBEtb$iQI}3P13ZW&F|lDb?q}V^6ilgK<45hl{Mv0 zBTfZAp7HFAg9A5vcL*EJK&Q_I2NU(&h5vy`gMXTBN$Ktll^bWChv`SGe(^7v8`N%9 zoM9c))#7d8(fMjtoR@Tc%Dw%;3xk8WjkXgG@T@Z1<0|S`0z5VDvA<}0R1xRzN(h>k zpl4ul*68SN+AaMr%8o^SSpV)UiTCQRK%{^^PvJybiG zA*3zQE7HE%KLFZxgtG~JMmV=ipaLO7MCx-i{v7*KyNj>zBu7L^ia!iK&$RE z@%U)F#b)3N&9|Yc&EB3q=pivqa0(B+5543ozcY82whjGd0m{A|TW2;A2BuCdn(H^!CoMr~ z+Ch~5&?C$!ZN|qe+%$fr6*ye0w{wH#Zn1J|IJph-wQ8ENQFx#RUgN&M)G>`PytVKa zUT=0g{(B^wqcOXzY;n7)apF_Ioz|3PW#xlbfQOc+g0_>-a_~EAqPJxB1M>LVPUmMy z8p3l%50BfM_&z~-@lVa9clauHls$RTNbTxKg8Lb^x{C9_1MoFLgQB#N%C#`Be{Q|Y zP%Tjr2M}JtXy*VPhZItbXm)P8GGFbTP?{X=t?QLsF>i59lW_rokIOa+4(08(StE;w zG@=$pFiA$UD8@Cd^^IbCIKWAX{~f__(@bPk94aLMpi-iQXoS%1O!{cYroHVCk)I-x;#*+J`Gf%XbjppWU@)T3Q{388jjGh@j{mAfu3U6b$duN1q17dj*kD^z)tsBUcj0&s9lxs_JU zTqR=!T)sWkn&*dF#(Zz^@!eP^&uv2r7*r@Lm~a1>vz3k52-12R`eB!mgyjK$pDzG$$X(BTsEp`!BpqZeNy_K0LjL*TwT9*)X zOO!LK+g3x=Q6+yP4pF0+cpA}fxA8f(76v#*#WM-}vRM7EYGSrMtDjPy;FQOiOv&nm9@_>rD(k! zgip0Ay7Nj$>vJCEV%)0)zd?5D7H+nsfm!_3dB?TL}@=i9v{G}`?% z<}QpVmPI1SJqOAORI7tJZuU~6b02{Jd>2+)BgVS`mVb_$75T4z#xSLu0fEC%s*K`k zpEOU+-iN(5?haaqHj;+GzP`RyRjb7`hzYp0Ar&A6_9o zWKKoWNOC*wM$E7D?a5pgj?niGJUWu8*7wLLiLaYoThw))l=$_K^F!80JHsYKBlai{ z@mlXVf9D-GV?PS9Un^L_`ufR%6lAlfu9!!Mo@rgXlMrn|bNOCIy33INoNl-5rfv@~>*+Kj#<*+iZ44R72?@m~{Uj5YsFP(q>%xq|K^fqp zVzv)-(PXgA;mkoKnT(R;Xu^o}6Un*7kSc#) zmk!OzYx3J8`GG{z+>F}jtLHdxqEMFixRbw}pjRaN*ijqWH~(ZlCq$;L|0q1mb6nyU za{-aUL-SDa8$vrPUp6cj3`NL3W(!T)vVMT#c)!W%#@;ax z$OirQi*soK*P(zm8RonrB!`DJN*s;OgfKR@hw=1QbUI*F;R7=^KV_Tw!7K6$?AXP` zB^U%PXX8#)(_;}fq9Zt6+EQ1u^3LMSM5IOHTWh{wcO`i($>@f8t{7G;&X2Hs%dm+C z7Xy&^>UJ{DTq9sZ<`wm%?~h(DhThcn3%38{tjR2dKGIcG`hnR2^$mhX8=p>f72%gY zOgTG6hv*%hg?B!pynU0HNa52t{Lhc==ZgnWQ%O8KhjefCr#+4&)77|HH@bx-r5SdQ z0n-!HG`;`3!>Vb6kO(u*nmYC=VhseaiAmo6y`a$SRT7=Nt!}j|X|-G95+0(}pYEXc zPfek}5r5A@m1%ClBerBYhpJC)pkdX$swD_vf_E2}j3#gN~qsnu6HlxUC*v579XTI~A>pqiB{Svq9Z+8buKViSh zuh|eJdXa)z`>8nr9B4}7dB zIA;pu_UQd&kAJe1e4tLhcs+fKwwerNXD(DETgxc1ZQ;*-F|&*u`w_s+j@IvFbhLF% zk5#{x2u%|6wnc^wxlcXPgZ(TrDqVp#FE>vYtUpVU)h3!ela5zYRFdS1g*RCH&7LpN$TgK z51o`okbIj1G0eti4K5E^pD$wW7y%~8vandecG161Ft%R$LG7%QR3nBnvbhFa{oS}y zKO4uV=VOy+w#1eK?1%7vKg`lkW1Ts<%?!xuE5Xf$r0s@?=1|vDzoZ~yzoqKGv};h& z4;NNd<&N9UuDD(D4PsB@o9^!J*~VElHnl#tmm_topJ-XXXbpa@r!<`1LHr3lWPiB=2Fv45?~C(l065e+r@&Kk64GGEOIM z>@}e2yI!$Bm63|MYsp7gSv%+{A*LXk%Ku(VI+yiYM$xqg&F=NsuYMlhW2I&qPo4+P zZW?hDIdXKsYX6&dI39+YcIjEUPe>jQ=WT9lLnL2+5Z&**8Jt=k?bH}g znfpQXFNvrfW=^suGUH9dCO3ZutDgN4_A3MPePzWfbcz)V%@$!&)-7p8xVOwS<toLA3kZ6@PTrWXqp?z@lNr0b@@mr7a+QhczoR z-Yg#)=u~m=8>@;Cu)LA0944yy3ojkqSQxeZ$aAnjXUy%#5rha+l5Z1yOo0#39;!k8 zm<8Lw{Q5Q3fi_V8Co16FYQ)}acR0=!x{>iBA_ua#n!C)hhlf!e-ZS4zd%L|eMKz(M z0{9wGKF}C~DqO@3(3g6Yv+TnzVkl8(*q<(D&HPWcRr8Y4)|t{9GXICIFOP?Ei~mPS z$(CZnh-95{aRy2~c|qCbTtx?&Og_&Gjw-FTH-tEyVdWOhbuNfg<|aZ-&g4us#o$L6tt z8*$FYo}@1D=!r%3HIKk_fv)ziSt@FvUp{05!(pXU3d?`P_jMG}^2 z!qvX-9dAxe(eci$9}JxDE_VSPeoW^@XM)i404qj%4-|vP7%sy8&!$B}?=-2<(QEii z;a!iwi;)09<$hTKP@Rt~Vx5 zBiDH!V+U4MamNB;dl%bIESUPZ#30V_^1SqP3RrI_OyB8@H3n%p?#U!9sP|1%2|3x9 z6zvOZt6#VkoilH6G7vw^)~NKOpenHC`!a?)ctLOeKy6wI&HLW^ZeZnPyANc7KTPY_ z#y0xG6!8IVT%2uQLx}Q+nmFG>)vjQttY}fka?m|0QUosJL@ew+ERDI)cAW0rR@`pw zv<@EZFz;A@h5Vft@jC@nWsm1v%{Pi$@D^0fB(!46CKYdoj5kD;s1J0y^}2MEs$TGx z?D)?v>t^n@N<<8~-u*@V?n}*nLNduA(36LZUSbtD+o9(?v*G!&2*OppKrNLWCkda< z%PHSLI4fI7Tg53`08iCQ|J>f^%(WAwl+DyLX;PqS=BT_RaGgJ34%r-UA)oY-x0Gy; zs3#Lp*!_)SrY{A)3d>jJ@Ugv<_Hp=;(I!Q+OfkE5G9}%N{lf?y@q|tHN`Px8$~ed` z0aVeGVlTQ{^i1#USiGRms}lkYMe?0{>~b@iWQF`Z*axc@09noBgzPLiK{^%Hf8Oe!uYchh)m3?Hs_~47xX+NIqOTxaSJ$(_13sjV$ z70sSEmTG3M;l6;cf*T)?Vq8>JFPrqSIwBu%mC_}Bb7fup5mW8|7Qqa`4)vJdraEkE zmw6QuaGL@YyJ1bd@(RHkKLe+?Wbk{8j$brrms0)V5UamvL%L{`QSg>OM z`y4&9phaYI$24#aqgH7xX*?=#gJxCAO}`!(V>z4cVMWX zh&mhzk5rkbK7XD8Tkn0wv0DheN~z|hP^?E&4|84xw39xW8&yKQN!gS;fvxbQm*z|x zo@dL85A)`uzQ1Jd4ulW1AJ!G}Buqh>mIK(qlf59GdC+2g0*m(|+*Ovh7 zoeNrRo{P;P<_y#r)?oovc!M5-R_Yfcvif^BTgx_;u`!qY%g%uvWG7w?;-@p7K#lwn za#HJGLC1!@+Xq*M7m4O)GU@Z}iE`h}5@*%delVXnNIJCD{*XE;TXCc`*h~3hw7!l3 z6k%77sCf;Ha?pDkfoJXFm`lsvuGPnOu$_tL38M`He0_cA!Lo{`{LeJBi?pl~?vlP| z`05SiEk_wywgbUBM!65t(zf@3!5SJey%mq84sUQCXYw@fnrn|YX`p>x`q|U2w^ewT z&Yw)nR>E8^{fr7#vsWX#s$XLZ?Gb2jV4_j@Fd3#+6>t?Z{j_UWOi%DgxV0L)M`$YO zh(vp#(o%Zw9dNwdkz$>DSo$sh)c55jyh^=!xO&lm#etK`^{lQ$!_7qlu4<3f>bmTS zmt}OUOm@Ali7uUYi{w}JmHC~Lc1j;HUmqyw`bL-z&u0Puu>BzRfLehR`1-jvuXgTa zm5ob`*$l9WFclJo9WqLUm zkB8&uka5XDCf^C73ToDa_#+OH(cMXwj}M|wCD1G>A4&V9$m$1A)+CIEpPKwwMdSys-TqZzy;lPzIUo_UmD|WWqZ|yU^j^7t8Os}{J7SyGf~Ib9hRm50 z-tWv!CcWQ%3q92td;3@3aQQ$Q{G2b++qbQE8JttQ@PT?^uhg1wT40K)p~<)fZgmGu zJd!laVp`o>N`{o}h4J0>G0Rm!kgA|yVw)OJ0|Mm;?_Y8M6nH$OeK+xO;`&87PRrA` z;Yw)B`>?|i?3Qr?ztl6BI4Sf*9MON95*xnmQmHm_`?~J_JYXlCZEaRJLEDcV{qeVc z!^ysh_e;YpJv%pe7c)=#w}}|p5$;2{yW20pCx`!Lu@>IyBpxM*)>Q+fPY8}`1KX2M zS#HZ`Z()AGy(Yhtc$?Dn$zbVC8`?wmP%*xz_9lqLApe|tq=A1`q7);1j}_{=qw*djf}LTG%J#EIKTjrL zphuj>F^x<6BGL?uctTa{v>}H*fj!RQ_GJOUS@ap(y2ZxIGZ?}q=j8C9VNj$mZS zPx-Ha?_+~SL#%}Ipj%pDcI_yc?51EWQd%XHhiS-RyLq0Cb$h~#TPCHZKfoqN0$ro4 zX&a~J^^|c(nTGwq>G4axmr&us#0n}o$NbR>c-0eZhLLK1#mScnk^Q^VhNi`-sSo3Z zC~v7mUcEI$8;;fC?D0i$p?50^(KEKZoY!G-yM!j|iJr#g3ctC-vdcSN6&;vf_0z94 z@4s*_H?hTha$gAc{oo-=cr$#5xJ#--u;WOT87yAkNwT~DC{$ykZ*1WEKp&a8xNkYq zHSvk-RBCWj1b1qlfR#AD&>y5Xkef+?bbz*ZeTPnb?Gq_EUrOdLkEZ^)Nwq)a(~hnsdv=N_!oTsr^fiIBF*WGvh29)h@S|8x$|)1bkqC(U`ZwhEkzsEwGHr9Rp2 z)DLBU<;}o}L!@Eu_cEsM7}*wl~X1~-|+ z9@ee#=4&rC*+bq5QXQ?9Ziat;DEN98%yBB0eLs9E3OnU4bHS*u%{*|o3#qH`9nDwE zmK;gcW4ltL+ZbLbP~xr71(F=PZ{AIERN*+r?LwWG7q9aa zi)rh+2UbY2Zx?*faOC5nUnJ2g=|~8~aoc0%)=BUS42Nqj9=#GH|1M+?IUUXW9=&VR zy<;zF8IQ8>1;28wOzFAgF`1;2((WmAHohonaWwI`cGR84hIoIIx%sL~&#}LT|E?W& zyd>tsw?5qS%PCT>Obp=|PM$%mtOVb5_I}MS?({9RNxz6j{79`;`cS6-&8BaA?MZr`nh59LSd75rf~o)aa) z`Ju;u@D}RBh5Y9575+gF^k4~KnJ=gY2)63C_BqzwsM0k|% zd5<#B_|O5mp=+rv3SciwXa#LHW0RV28Y1%_gl5an(){zgT%;{V_P+eo)FuSMO5f>P zsl%71NX=Hp-cA-PG}o#F*l_98srUEZI9n5v*jhuOR%+`-n{=JjCEirdU2&0TtPd3# z9Bp{J=J{=PutgV2ywoU7htC26h~(8`xJH z0L%T-DR+WC8%Ng96b|>FZfYNz5}2o51Y4i7My*Xav=`dStD(mo1I`C!ht{LKq#@8R zY;1?6s}ub3eVnUc}6wGF9|4KWMlL9yGw2ZSNckkzq5b`e6&zw{N;p z=14ifW{LKbIagy5A-RiMdmg&>-CjlX=;O-NVo|AHi&N}W+->8WTo`e&0ZvLZyYt&*6!mO}%^*(^shc=qHv|?5n$rd%l>S|}5omj3{ zrk?wNiwhofa0)SYwWi;DW&=XbRQxF&v+51YoVs@}__ef@S603a!rXKwSy~r*V2S1B z@pO?IclEAi{9lbHBF|bKcdv;H|8&zyEOT@`tp$AnULVZ!jgH347ae(d!mpvR|8lIg zdW^oBaVaste*9(a0(Z@U;XL!_z^=dIM;UKl52n37TM1+0D0&#|y+HNvw<2 z`#Jlz=(*M2zGH=-UiCwjFWEQk$UtxVy5LrxoQHc2vEJo_0B93Ki4UCW(bTNMGtmaV zGechDilfR4Cir@OwWl+V?XR&t&fZH4c5qj^Hngac#aRPBR4xZd?82gfpanSGfE_&m z1ryw$Z`~RvgMN2~6J$}?uigjDmePoV@W{X!kJ=|`f{Bllh=vo4)L0v;{l-Uz@>34q z@$8E76Xz#QPMpQl_0DR|@3|4J(%V`hcee)c3~kaj1v9Q)ziu_pdp@I~A{>x`g zFyqxYJ~}Arc6bt#+1rWQC=^;PQ z*AHC@ucwQ14H%?x3fn;vA}z5RV=81Y--*`(^6hiRs|90JpfGdN!OF&v-z%2bh1xOd zPb`G{kSN-%+1SX}p6AWXMyn!&z@ZDSCZ>gf%B>@^E_>dqafVJTm|c}^3!H}IuJy*5 z53$OX58NtD5~|gmGw2B0w>~f!{T-h<9d5#(KceVlvOsAq(2Q0PCj~S)5uqnaNK20K2($X(7AV4SfNf4t= zGaescjURuwQBsUJpBKN2yZC3SN$u3scr%z}w=nLrUc@xl^V5XBSFnq@#Y`1Lv{eMx zciuWXj}IN<%HDD|?oO7RN9bn$9gaI*`gcHI?q6v)YVi%Zz*6M&gA<99YHj3p>8e|M zQof-v*{32SCO(qWarES0R_BdF#yi0uK7Qj?u>z*FM;t&!=+93mLhX5jG_S_rOw?t{X)s`oM~1Ud3`3{#jqo`XmJSh@a4z4$mQ6xr#-nsI>qUiaE)@mJFqEx8_3q> zDu7E&Yw)VwwRJ^VBEST1;lY=@Je=w}NJ2VojD>%#)M46NmTZ(EkC`2hPS@?`Kgtd+ z3;k9_(^_w_gAlWWC9Pbuimzl^syU8#9ofIMwiAX~0GWsR{F#%OXZL6SDF6ZJajY#F zLSO5C*!4DCbeI-+`E$Mn^_GBLjGnI(k`^Uj$J7En0@W&=J=Dg zZ452hbjurBDs%fkNRRm=`_2U-f5+WqiV9A&XN)3W_@Gd#EbcSa9A|8RS%&QCqj&Oi za|=oqW;b~TBg1oa8Y-31ccwj5%j>+uT){$PB9Pj9bvVWUy_DAjLDo}VTn}N875d`@ z%FYFdxZnFcW;pcJ0#$S8CODhW0XL_^>tBui8OVc5H{E)T_4t51acjhzSJ(S=Dp=&7 z`KP>B4pyQX96>#~!ac;s7es_e{=v9<)HP+O#V=+FN}LR+6LKIEUjT#`Vf-gYz`3`U zox5@{*O&^KHrW_aVaW&XivM_f5P#PllhEn3dOte~k%=8VC&x}pQhsD5n@XQ4kH&W& zzyyek=9LBw2volD!<~5P?Mg~ly@^uWl7m#$mM~FEnD$2MjHPnc#&^+)bs$-sr2b_{metuf82{6Pzlg zjk*WXNGR;RJ78G;f#MdK;JL}_{Fsp$uEW$ZKWZ;Rj6*@kz61??iU7CmVt?bE#;PQ~ z9|en=*wrg>nrA|%NuuI_6Xu*<7kfW~cO{bNo!!;zh%4qjllh06XE+o~=e|qT2?P1$ z9vl>Zxlb~HM|J}3{>XVTN|YrG3DMi@)Io5ZBg|?ZJptaeeISL` zaSVNlJuDZ0m>GY%Hh&;w!JzaS&{eG~)#^nKxupn%o&(Yv#N&?{tggz_eRx6HB7XyW zs1$rRK86G=FPaudVqY%?Rf-ZwcedA{OSJYJ=goMf;;@&2jk2kKmF#wO!`cdJ^St_E z4>%=WrY(D1Wps&L@@2!9!d;{zVC(L+n9&T`M`Zf&sMGrJZr33PT6nrm=BjYmoK5{2 z{zWmez#WrfAj;Vb)L<1lW}@}s*s&KxqvdEnl*VnpFY@7!wY-{E z$t9Q)Xe9*^<<-P{=thM_j)bcsDkiZ@UiZ<_}xVSLaMnvzI z9K5XSCZw#CqXedco@C@PfGUm>i8)pg_oVF|j8ZczJ3N>hENKoh-e)v?$CX4%?K@pO zGEy6<(NPeo=YEmME)wk*^itO}T5g)J~1Sw%Nfd+#s-=xe|Z3%Fzi7N!3 z%Lavd?R>jFOXb^Err?(Bk&{Mb9KUXGl!RcHH}dlfQ*3D@OC7KVI0q$MDS&>kq;*&2 zvhu$MlQTeqwYEN7B|$31nz-n*zL*TD%Y~a!BQW#E-*Hm0uM4~etLjWc=4Dj`J$mpc ztEo!l3SD$I+`Td-JJKZ3Oe03<)q^qS;R^z~Yru>_?rg+DWy&X?r8j;x zDeW>r^Z0=hWVY|ML=l395&&lY63sX62a!uEB1}x@Y*~6Q!bQJ8{c!Clj?L5)ju4B; z{63hVZ)GWV;SZ3AD1@EsI_ryM46cCj$2PwT6HY9nH={YVxI9+DBtO4{{;ixO$#~H< zE{eQ!aeer57SYg$7SCHKy65scSOdA=WF8aM}xrXE^b^-Y9%6og*+mgYw35X_oh%lsyC znSv#+yLNkbZ-Oocuho={B1Q96M*P}f7Zw3K3Q3@6t`SJzSRYE?l6eABT#f9fqbo)1 z3XyEYbXR*Z?@doB;F#5U%S_vX!*r&1BVOoU|pM#hr z%C8b3S+xplZ@-<=g>E9*mdoDFf9me4fzZo5{5FG^^2>l)6>&zLx}iMyht2P%Uqwp} z20M!8u)VQNH#;Nhir=<|u1`PJ7X`jVfV_+`@5m@RG&8`eay;7HUxr2^ww4)pV4W+8 zfWi~kh2?+69s(pQIP~(5gQybpW3kF|XQ`SPvqeATL@B?oHR#+)F@jEOL1mib*1y;z zj{o}i)k?@xE_nM7?j@MqY|B)$6PuOl#an7KFPn>W_1pr`S zy}8rL^w%dxWF&Q7W^7+bBQG~?pfh5|hv1QFa zIxC&Gl!`L%Tt&KSB#CSW)?9gFX1yWqr-NE;DLY~^iK-({p%=8+nb6NX?lXzA{+l`2 zF0Zx^#0xJj8T8!>F!%2cxZ2kX&l&tRpQG=uDdFnJPxYT~Gsc6)2g8tzY~C?D-AwNR z5kU}@4XS!pB)glVqb@#?#9Nbr-^IThh99ck>G!O{;rewaO%`uJcf385ni; zgPmn1wyg$~EYGHBhpS6TltR1Sz)#ede|!!&MH~`E5lnJgis{NMpf&~9?n;GpEJ4U+ zAok!#p|!cY?mO>+H8Vh=DYxsPptKDmf@e))R=FDn4hdGg#D4MlJK}@0y@LTjw2qY> z-|q>SFoI8I-Rc$SdmWpnZ9*bU{-yx@wx;&^DMjFRLuPyOSBAIiIW`irG6K<`kPf0v zi>Jq$A|{fVmCw3txJR+a5}5!)M*t*bKBS29@%`YlHi&=_U-DKWmE{Keli0<} zIjA5{NUVSMJwj+V`ABDZxR*zFy1QB#+gUC8?s8kmF^HI>@4Pvo)ScQ&Gn^)v zh(_4?*knWF@t+yc`u#RiTKbppWo{R+v-QcB89%@~;npauerAzUrt01eukD{g8N>Xq zh+{y$;gjBrn2<CnJ7F7d0XS)Ie>TaL0O5GaX4~SJU~yy=~Ic0oPrVH1CRA>sI@zM<{dt za?of*J>y;!E)a8~>pex^a~HY%ra&<0=uZtYQLq<3Y!Aa-bB{cU&d;zbZu{+6$~BX# z#A_}t?e6rpIqK3na`>3I&HqUhQ!;4}$%VM+4oEu99jfpxvpGLM=;G=+Tw3pIvKUAE zEnD0-8s_=ob;R_luLyR68ZoYJ6|%k*Z~4H}945emlGK5s?n?ffIi$GZf7SfmxxPid z&HFukjZ3S=ksx&aj_@6=H{-H`K|f>2RFWrF2Uxq-dykvT9l?*oLzQ_U1ML1=v+GpY zB#q$xQuEoym*YPo91zM3Ro}_R&T9~k-)DATO@sKgfq7x7$)Z)YWISUUEkAuPITKif zL9!Ku(?R)6qVU|@rx6Z6u!f$Ks^K374Cb((3JbDL8-cw#cM3`8Q?{|JNXa=FUNGJ{ zDT#U0NzKKnHM(fcY9BSpjZO(ec4FvA-ssa>Aqgt+W+ef24Sj#(!f-!rCz%KFDdoXU zK`pn{0EuAlwTE6Zpv#~yE zoVk6QP4O_zWP7O9vzDni<%VEh%EFsdk(j-9yYfy}V3OS6L3X4kpO6{^UK>DRyY$F3 z$}qw|^JFDT(|{Bz$g5#VDD4-6YPrSa(M2`HxSDGdYRF3hIMtfLD3YjtE#% zho+Q$dO~VOD*c?4Ewg@Pr)>||aT~DWR-^%}%$7Y>kn4dQ487>)`;meZw7IXy3|-i5 z(vzUP3J^FF*pvMWt}uWAxLacpfTQfVZ+&n;8v6@qvVzoC5l6XgR3*5^K1;J}pIIx2 zR-5*33Dxf`^Iu(BW58xvKS-x%okQ9n^w_8!h@bW8#n_1qf((u^acOy^Y*yqwemKc$>@aFJe?`ibG z7u53{YGhE%*iKH$o2;EAVs-BN8}?Lu<41Dqt=Ut3^usteGSe4$j%4@P-a3W6PIDQt za>8&PrKveu=U~opi8j4$#Z-({oF>0ccb|;WX%2nMd6lDL?`b6;P~v4KVpCL8tSJz7 zA9ly)&38|o#4O_m74Pg)QF+wvvPJ^7b<%%Jv`bB^3vgc`n*y@A0Eqj&vU!4 zorH0Rz`-RL_u_khT(H%j`A#qBE$j_?mki>%g1J4MbLmc(=4W=o_ElU>evqD?j(*w$ zGUq&rGhiaL9m;_ui@Xg zW^pt9h3?6hXHOX;g^ZPD4 zdz~{>trrD@9e_x;@WHZ>?6n0@J^)!6Aq8Uu6X@@WDDOW ztt}KHTsjQpAO0ueY~qtqbXaL@G&Xg{z_S9|C&(Y` zI0uI`Z=>B0BaXNy4k(@fT9T*V+bF1V$SpB;o$a6ZR`XbHQs4zpv$v^fp3HA}$<|O{ zT>qP`^N!YpJlCspCex3Fv*LPWpxYjR3?p{+zt`*u$QOG+!PA=(Xu2G5>5eQ-kUIl@ zOn7Q>90IU=#ts!T;l>vR?`Wmc040=T75;8^7uFrv@FET(r?}5|k2K#QO+Qj$Uhpc` ztZ#h4Y-sj*tfHhu&5P|ozDgt>#0DmAV^=_}=*{5L`B4HUh=y}Pv#VY)HDhj z77cNU-|NnV?#?D0dt{&S}wo(qITJM4vCwS^p zV#OZWmxVT{Bm{J|wsXt2yhz76*6Z!B9()QWM<%vvFGf|eZ&ur{p8Cjob0SW`Fv(U? z-T3LPvNT|b4fyR>X?Vd7FAed$Ve+wHTWKp%EPi4!`nBpK_OMkXrcodT=NtEf$aJJj zD~v*!j1piC1*=HD`K7&cMxz_Neb}Rak_q(GXUsnjikFYT2Y7Mny|2H7+j@4IMhc@@2U1<6u4j8~1M@8x|+Havy&l%vHYY87@_m7Gm@E*1YuG!f_A39md*O0Y8F-i8%X{eTMg zBM#>{(hBNZ^IQ6?r<%OB8V9|zGlK*9?W>!p%`lW|;=d8f{MO_r?Rt>#TU)h+8)j3N zlgv@+ZJ}1NQ}^QW@Y1Q!-X~z~LW$~7XMOrSz6A9AyHsSp$9HZORToqA)KRyzV0Ld8 zG}n^C`J;I5ZPGoRL5H>briBkShrniC13*J2bL^6lF%mcz0tUrm0aTl0zs+`3*(uX@tz|Mc}O z1QV*ZV0k5&h^-Qt_Qw`!Kq1n;HV4m-xHx;AptSd~)fW5P$k(&~bhUpIY#sL^s+uFa zxZ_bJC4;vxy(DV7W_0sIBtG}<1y;9Ck({CK83V1vl9hrxSAC5~gZoIWOKCF`!rLzS z7HMLd$MB+wu%o<>5s)0H_A0QiuZsvbpJGp906t9X*IOw|v!}CUn_CP!0eSHjWKhb* ztM?ZGmAFHb2(i+j<9njQRxtIx2tC|sWUF%LF+Vg0sASGf&+ZeuEsJ=Hkd?9TypOab z&-4|yKtMm5?D-^uliluz)jp7MD)X{q1n^TtJx~+yk2|`|UiINH?I+=SVhII!g_VM!B@Z!8;Ta(CdJ1p zQetg$uO#k-A!hBdKD#|sV<=rsj@eg%45zq;Kv!w=EUe#>Yq$k9U2!M*BaS~YF_8q4 zZ2_rpDT6qqX@EhfxZgGORjp0MZ}zaphjD{?0Vtfcasec4+3evxF4%AY(RxopSvFRz z)@X!r>0qw64OH?1D_{?7TXXW3$(Y(mrd=io!1YCs8hJwR*&yL`!R!W>&p-)VA$#H! z;;Lnh<#Q@3f}&5UHfwf@x|7Wvq+clgdF8wvLv#2Y!)Hhaz5n}SKFp!WO1e+j1|Fo29uqUx4K-T~SWC@|oEV<7f0O0}w%K5q!N8 zfV9Kbx^r*4G0F?tWOv{8agr`XtmJ_T%whxg?Mbhc-DcS4(=>+elv$|NQ+#Hz(RO}Dp1(2COkhmZmrtf=TCIgn)K+?%T-hUNDP4Fr2s`U20 z166uV~T*e z3D&Pe^aGrIadRRevR$1j<~LMRf}X2pNzCN8XB2?d>kV7g0M@8Yxayoe*+{?4fE&+e znFm(}l&Cb}BNTwQgX&|*100vZnMEc*AEeeHlNfR^BQ8UZ=7%!yUnR*5$i!e>|H#}S zQF@A5&lyb`12EsAIJab;@AWQ{5qnUSslgE(z_`dNwaVVRtZQ*UaOwZp=*%IVQW0P1Tgd`@Ae<}5D@~V)|OW0 z5dDQA(K-j*{Jwu1crd?RxDVK!E@MM%D%|JIy(;+FPH(0td&g zIO_+tffWUd4TSPcYM?jm@>8l-F^0v=Y7Y;-?k`202iO@#21~gy zlzPk`3=xVkjJsURpOLTHDx<1Sm3k2qLzB+ZhpGboAqpnxMf+|9v>EyfuF&?24<33+ zQ2k*Uho8mS&8`n)M3aiK{_YFOR>9W;LE_dVM({s_8C6VrY&etpu4PxdoCLsTse`xX zKa_Vh3}RsYGF!a*EIdpYL**LTTo&X4YfK!Q0|anLbXX4bJbLsTws8beEJottkn zv1_^=u6Vys`U3SR5So>*e`BkM_(U_iex@)U#LvZDF04sn%2mvk<>@L+PMNT8YaIyp)4qxQF3h=D{tVPcs9ImCVrz0Jd;4y7tJ$h?607!?RhT>Pi%{#P{= zf91Ze(K{@2?Zx01S`oYDw)(MBZ?)tyuWusmYAA14k-XT+mf3MHDuVhc8vhVc_7GL# zQgb+UfP=UuYC<_1m?BW0<+sgqnR~?7`#(7Ak0fqq_?wpTs*w0v*B0WSCr#_YU`e?C zdN{w7fH%C+_IJ4BM%(Tf8(5)Kl&>3T`q98bgd0;7H={owd7>LfEWB1e*6G;uuN?vt}xcRC9SnTQBEKyW^A$nn>uD#w6#-vM$3SR!h> zzu?gqhQp)yjrN%9RoH0;W!3+bTN}loo+zwp6B;f=E^Np>0l)ADn_@u=Q5J>tt>5s@ zQe0?R{oDDNzR?>jfCZo4#c4|tc7|(;?|QfWK);`#Utfuv3r#bQXM?zY3ka0^^E?cs zpbcGI0OSuBppEsx5b$z4aJ1N(2&5GNKQqqr=N~niWTkJPYFUKMkG%lq8K}7pg#$1d z`-6GGC~^0^NWzrEe>dL_Xwn?{ClEus*BZ&PTh+nozlwDtF&Z4{>u1?90*( zNEG1w6*MA0{~-}^NNjU%;URUoPu=D#SU~)HYV|w-?_rhpSD+8v;J|4p+gOo+6AO?S zuUBIbl!Tks_2(vT#Y_(~w zZ?3kU?bz-B>Gajy@_IcOTYrngI$3YCr{*RDCoGq^5%in#NIAVrs?4JP(gunY6hJ2@)tSHsWbKKMIqecuW zlc%JV?6lgr)x*5SG2SceSDF@=dlv=zbx==*Nm(Qw8>3}ZM)>2`1dVzi+Sw)ER?h}* z183s|8>=5l?Tv?Qp<$!uzZ5J-|06I{kD8Mx3Q?8}z`NeT!af|HDQHvpJWLQH^_kj{ zogl2p%ZH@LB!YerjMk>+lg*w{?f460RnJ^fL7T)K`M-!B>p1UM*aelAuYf^(nLBkO z(E>DBuK8OT*gCK_AAK-i;WStveoaHo5^nsmR2{GJde^fMp1ViY(mK)Mn;woSnlQIZ z0BzoA_20cdl0&lZ99H0EWuaWUoM?}-W6L%jTd*EB0-byem6=3re4UZABX@Odc%Yu>oI?ErF{sl!>kfF?7?bHWW@ar$qF2ph!B9171;+G zUG?qhJoG63_Bg@?fwbW*RBhC~GyjU8FBM9mh^V6G@d92~qw~>b8TO6)m;4FLiz{jg z!JtXDe6EGK<Mz@2ky8$u)sBlAKoC-9(hg z&7g^P9hIu1>`4$v^RcoZBu#ds-T$@$!24Q87*Zn@)+w&uZu`_Prh1({Oqcw;iDu)k+|_j?Ye5$u^+8&p zv+eS;E@Tk(*i6+_@}9hZ5`^7~*)-(iomis4fZ&$*h$u879ItK$ZQ}-z;Vy@9lcL{# zhBg07XNNUMrl(nk9s8`3(J8;_R&U<}@6+|b$YbtU8E-TOlJ(#peZ?^?Lh(5G+%CUF zN3QKm7Opn2Tm3}FkMGFQrqGc*UpylU|8)Yf_q4_Tey*8bljxNPAhOoL9+d%3s+V=1 zh?V`2K!*K!+Jd+-wZGqqtrx7;xcRl^!ZoCsH(H3U-Q7V{+!T<16>_*L0DVt<-v9QE zNNLvG`yxp)r8n+(^P|v37Jbb(cxW&JbI_i0zv+1zTkOlUc%{g1p|1=p#;-&HDIbR_ zoX}$`l;ijaf%%{pB*=BgQ|88F{U4jQ@w*@lt5`{PMcF9sEO9WHX0lU5{=mM0B?=BV zX>Kd4Cw^6*+Xeb8{1kmQ8j+*u9gJ)hVb2lf4=Yi1lfKhr+J$89yPxgJ)jXoqe)21ruuIcaB$ zKLUnX3#rp$+@P>v#JIu1juuAGbblZRX9{~ao z-~1hJg;#Y|mdkC_^A9w{Ld%?gOIB-(GDFPCh@YAOM1M&81jSv93_9+Z1F8Q&% zlp#m~RxF!%hMOdu=cnj1%7FU;A_+i}7UJJCmwfN*z1Tk95?*{z&uJFx(9#syJ+zMC zA!DaNf=9K;V|162WjE`D~+Bgd0gFeiy+li|MRe7akbEXPND}E_<@K=mutGbBwo_lOr zl4ZW>js9_OnLxCWnB<&n9!E|-t#3_QP|gB`6c+}ju6JQX7oegWcl8nW`goAuMo<5@ z`T@UGeIMC#{MY0yDOM}35UXGEVu5%U8^8|#-5lXP^(pD* zM8)uNbEWE_=%BfWQco1)mN`9QIy?eGOQQ~?0y(}w`#+kQl9QTCQ&RVE#m)QQ!bq^* zwwwIa1U}&h`616U^+LON_y7s*Ki{P@096Jz`L^ii*zTlPKe7@{qqj|y*{mB`=4;;B zf90o~3-gX<>X$~chw<{PN#F`-RP`+qit|)dO$ZPh0OXT34Qk3o>2&X?!(oBM*nG2C zHZYst-G}VV+0ILo0=Bh4b6Y@+i|8%8xvL?b4a17h`Fz(j3-M(%u>UQFj`@DpL1t~= zJPnriqPff$vy?51Ej=(^-y(n1i-f!(_)Bplgjt6SbUt<=ab5Us4uN|t<%d?EDcA$M ztltw;;Ep_4Kd^bUf)T8|sgj{Ay{?OKOpSCS7F7d5zJNc0F?qt91RV!o{(z~-27=F= z;v0uUu0tG+RwV1w($d(2p_Ebq5-Yzp@=`&4xBU6_&`3aDR#5Z>B^pVfF~{WkwiKM& zMJyHu?tAk>oSY0?e*fY?ca3h9--@xG(MhZ=r~L=Q!9jMl<>5WyI#tnZo*4V1{{c@t zd?f}E|Dvk-nh1AHla8ar(Qv70v~Vg(blugp!x8krYHLw%H+a(|p)EJ*o0>FUY5J=1 zMWpPzYxjdr@^Pk7aRj&4Pl4B4H3ti`uXNjumjONz_`Bn%d&!X_(Xrs=?u3KDzJ+2W z(51zme#D=%a+T`e6eT-hCXR4xK)s1utz-+wt&)MAN~l=A;_R$Yfh|PyzwE~}2%y;yvIN{0KxPzB#SHGn zkD&&`BWA4)*?H<^ej?XCbHDZ~Utm^3>NeA=U^NRGYa3+Ff6*sjpc2}ND>hKG8yYBd z^x#KBQ!G^R0V6$W?O~%O(SKMbs}+dAoN5qyMg_D;uXhpMWNPO$19?UlQ#o*xXYSj8 zKsLu1ad@s+qY<#*t}DqS+mzp(01dCXDw>8l(c_SJ=tX_puLrIsBNsnl8hBNIfDH*9ALLVwS^4W(3a|L1)sJ={IX zDJ6VuIrd;1Fr{N$_t`}Dgh5EKWbr;*A&{+mBePLiIYnpO$=2dq{O@}=eXejFhMCmC z(26+R)f)!Ss_S&JvSlSuZyH>8OiIpytsW^+$ z3VFRP?B5v4=#6CBTA0%#G&36*+;a%Ej@SzMwQH+Z3~X1sjlnxc$!ziVMeZEjd>h?cS?Fljpk2|!?yvYz^4qB;ob>840IFQ=^j;108g6h~QRgS;w3p3lO z>!77(rc^||I&x+w<%m28(2wFY;MN;+nZKgcUGaT&KO1aRDWIhr1VElGz z`ly9asFJcoU42qJfqYvEL~yPWiF-3_n&~cUgg!pjV{4SJ+cGu^gc})Yv)pf8f4O~)yLwo@ z9JoTv8%5SD=ipc%?q*^&7DGmvIbQ1SucSv(b3ngEy$+a8|L2SO?`CK5uHAvI`Ss2Z z?1AN(O8=w2Q#aP^)ewLP1`+H$)+1cAIhd*EIBTc$|HyjpsHT?heHd(9Q9)5erCBHn zLQpTDv|zhRN2=6NRHTF^9g?6Z2q*|h5rkOiE%Y8DARZ)Pgq%pIdkUB%--|tXFq$X{G%Jw=S4%Ik)7hdlo|W;ZiM4|xe%V~U2$Yl#DW!h zs=(r*{&nzyb%3$k=iU)R;;2e;${So^KeM8oJ4fDd3F2j|FGmI&C;RX*4bhbFs8HU2 zXS)k-yq*f*cug9=dXpoSU2^SsUAYXNrGn$xnB))H_GM>^PY)|L$UJAfLGI;eR*caj zjDk7yD0H**Hw`xZ{Grd18g5SM-d_Lw8or(bCY`T%=%DJA2KcWY*zjZJ46ooJTX%wj zxZ4TqKoYqc=b0?CJIL&a0k^_|W(x&R*;fZPoge?2$Gx*2>^A-auyTm9+kX=PD0Dxi!*uK)T zYMW>v^c>*z&qVIO4W13f)$8Os4Z7p!;SBW{GN<=|hx`|D&5AmuvFCn%gMIq5<6FkJ zW5-l9G*g5vJK3!O;s9G_2o>--G!84&XgnACp9*YZ1KP@%3Bs~C38$ugssJtO+)X~> z#zHWV)ax*qG&=mGCBtWtzwxr4&E9P{ru|w=R@_G@ZXrTecF+;5R#waC#ufZZt17^7 z2uzh3G4^Ydtu6r%E7?H4sDXGrG&q0O`JacX{=6&BNT0${Fr-TXFl;>iyOG;yKQsfp znYVD?htOR`DYn5_671^KqI0B=-x zZ38(9ax>L1U1PNSMImE!LC#owS^v1}H0O`bcV2@mZ^5hE3JtZqh)dgmJXg`t2rhZk z&62iD|GwNT6?qm?8=8hXHe6iYP|2++u3~Z;5E`*9+~vkQeWBCL8!PJnO0Bph=<`j% z*rNra^5ZoAmZ`(xoqNQg!w$j%=JwZA!;Mi$G2xneOOvc+fV~DRKLbDaV|ZCpj{zD` zm|#?czZf&i9;QaUH%!zv4s<70}-rmcL*+-;2zLZ?u(u{y78!M8u@Ht|I?E zxb)+Ny{6ng{Hq%Q`HzU$=wqi;LvWr~$9~JMMg?JP96vH{wS=L3iv z;+RNhdihVKq)}=)F^m(yM@m9$!taC3vH&D*1Tzv6WL348bqvWe<%QXQ1Mtomlb66I% zEih~07iSPc-Dz7Pvb%qWu4wq+OheI(jQQM%Qmk? z-i%GX89o#KTW?VGz??+@$QH#|KvXqg)}X_yN?q&?X&IXqVm>04k->Se|3?|HMqjzU z%M_OuZo2u>{yJeZhfykE=Zpv;W_YxiALH0P1@xF<XqQ@&b0t9DQ5)8#86r? zKFDenKc%wd}bQVn#JSp(0b0!}iQriFZs zP-1=xj`dDm; zHkMsyTDd$7&l|P-x9d-NiS@q;C?mR$<%IwupL)m)zwo|`e0ygB^@K z*StraPGLNKGv2dz`%_=+D^9aqf6+Xj%z?N^4iRaTVd_o9$iIZ2#2i4=piD{d@GD$p z%ykTW@mF*UyKjQyx_8RkI&$JVy^UTLgc$W0@WIu>RR0Dz9m~#QQgeU1_kTf0pke3Z z5$jQB)F4cd-{R&>o9W_9s^d(Yb(>xBxxOkPET>6@^Z=$u|Ku8m{nNmD2$H~{@WO=? zfZ(ADF!jbDoSp&#<-gM}W_8_9;D>985scMD4eu7(Z1`rGpQF=fwI9k8 zZsn^SzMf2ypph&q-*{cuNPA?e>A+*3w-9jy;j_Vcb!m=G>K9BmW6pfB&Z2Cb`#8q` z%zKc_QSi%sn~*U@_?Q_WLbKED!9N%m5P@sC=$G5NjIW6jX^g-79t6<-JqR-U^+&-6 zxcf#p!{FAPnGZ9Y=n-S|+8tz$EPtt8g9@bZqBZEkWdz#eYV{tQg zu5+&XUqZ%5ufc5zvds#60z1KgBgTF3MPC9svTNG2D5CA6{_~8Cj{_DIZ-dWz}W$T`6R~mC& zcnGV)4&nK<xv(&Teuk_$<0YQ23i$uQCSnnw&UpbzkKTq%Wn| zR##IiwYiA3c01InHi30Lsy(cNuy<0AwlWr*#NAbKy5a0Mg!M_`>Uv(3^9!lr$R}Q>Xd{bvVRfl)F1XHH4zxN&!5@FfZ>;fNiBJt%L=})z$9c9K zBr(>K8sLf&kAi~AzWS~Q4NsxlLz(Bt1{;rqq9rnh=R z_UZj#cn0dQ0R5qL#F;wBk45Fg)fSM;*6ZMPlQXZWFFuMw$F27^Su6szt!~hdO`Dj? zGE@NyekP!`XA2Zs!WDc3A>p$laWgwbq9*GR4IeLmc=qvNLf{PGp`@FEiluyRq5@we zH;w71?8P zjW?=SOj&Lj7+WkefQhW{jwNq>eA))Y$kw!*nye3V%p#C>@yF87-gmw#4+fcWbpqSH zAlo4)!}scz%h`K{w;t0^uDY@oott5`yrIY4uht+CKpwNv;8SGZXW^v&I^O;Hyw)+Z z5SQjFS#Rtr#j%ia-q8NVh*=Df6=E9KD~7B6S#iRkhxp(ML&;TjbiG)v zFh5t}gN$wK;X7(hO60ZvoB{@X6L8}&ld#pE_6p)K7<9R#wvHI+bd9vE-`a=)mmR|GO1LrBmDrT5%TB?qhO+_0EP~6o_z8KH(S{8 zIAhnN%S*m0&Z@PWwLQwgUAojwC6dNySr)Mu4}zb>wRs^0r;^^ST?sbhopwp%aQ_7l z4wlR_lAMIM;Hgh0yH*=ld-RIYTZA=nGeLi8bcS@LB6($42|D71`|2~v4|Pk8Ol*9T zNY}>BWQp(IB(cXlo2r&-T$$FMJ7fJnedto_2#eyVJMsrpNYldA?s=cPRak(r1w1Qj z;Y)@uH$6Ssa;D#J+k?gBOMd)!N??6_aB#s3C}*@(@cQn}M`DQC5hcyjypChAHnQp9 zRTMAHI*aSW`U;RpGF&1qUQZr!d1=ML%nNR~O9N9)aIx3XpbZXi_mrP&AKUXYsPxp^ zBxA!{M1?*za>9BS%3S5n)Z?1*sh$w0mUY3==(ZY*<4Z4ZDw0Izt@EOiyJIux9KoDJ zg&X#v;)q4+U?AbuSgk-h$n-+z3*!p@Alr)ATRKr|d zxz7=VG)0-4mQ<{pv>TB*xUBM`M+nO0yAf`7Cq1Y9p@9_lfD%T}Qh5lH}vmk%H2(%)mQpo1G?EFini;Po$qvxMu1%-trG7|NbW zcI9Y3ide|Eww~RH&XsZ#ZHjB<$0q_U+oK?80k0=N9xJ_%;!#=6efQuJ8fH>ki#&OK22-wL@&+@?86y75Yk8-qm`I&v)5kkk3_G==3Ynri*x~ z4H#xU7;Dt#=q5C7#1jHA7P3W0>F1n#77on=1yw$zLB2KbVi#EcQ0$X-NVG5YlU0+#r3K(%kfO6q2c6LATQ=)%ZUnOL(bKw#%#s*tEUGseMJB1 zzx3QLip+?M5Yz4G+O#YWwWx zXKtD&rpv>(h|0e3^`6SNuHN3{GPPlXm%Hs&#Nf*P6RpSsXW6s=`Ms($P5GW5GqP#a zD=Q@RllmCt`ofQ9J^H42w8tx7)9lQ4j-&BieW=TM!>)2715oXhQKyfCM-N}^^An7z zRjV1KR=D0!{2F^`bck`|shlWkm(n-$?lMP{f3WMV-O(>1vSBRW7(x6!JD{{mIBUqu zIU`1PsXUwuyZK!m6vULHjH|9TY!Hoz2HbM02#R%0$;34iE7ls6sD}u8P4azr;K)20 zc|aNol$lw86*|%w1&#x?G}JlK{zf#hKT?R5X^C%sI+dX;_n9eU8s6{P6EjXJfa%Yl zNKU0|t-Yge37f7}jMWk@9%APcb=%lqVC;s}?4EC>r04VxG3C7Rial#JDz>s=L`98= zEX|CVdqfDzd2g?`>7)<*He2c4m@8I>HvisPn_zaQ;y#IU`9?)x%_f^x5e?OsBhv^a$!Wa@nEoB zDiH#muW^*TDvAv*zHAr5s$`B)9f)W5(H3i8;Vz){een5j7R9#<4pH-?62dl<MjOSdl*c(U8aah>2UOgp z9+<5HX>R(aD?PI69uCCfOx}s(87(5{jLMi@Z@jF6UR#EGpXipy_}%Ww%io8Gy}16E znU+gzx8+&io+GqcAcfHyPbM5bQ=v$3E#l@{pnv|RNckE6H4PD#RnSe}ZC1H^YR|Dq zq|H!L@e%DN-SjlAo|PJ?Ll7%yoM(OU)L6C!kVVD}nbBx}L7zN~8JewWt6jinMG;uR zT!2aoFi}3J9^lkz=jz>07)dtHKloS_)>z(Coi)+$HG=9*0=#Xc9#!z}WQpAnE7MU^ z+3ozz14mxIxENBYyjT9;F8%|#3P808=D%|Oc5FG}d^Kk)ULe2QE4R7m5#yudcXC8b zPKCHX-YZz?ZK;p3==sPNpQQU-SrrZZ+$YJT8W`2>?vJ3m1Fr=uflaEwhk5$SfJVvU?z#P%DZR<|ix76hRne5TKm zcj8uO4N;*j|Hf?0=FFp1(}{!xRHNniY)rdt53vW6Ui@6$U_ImVmh}K8*#r8Cc|yJ$ z>Y9qUS@b&bfb6`Bk)lC6GgyiAMSr}R zcDyy}Ip^hsl5hV3{_YkIE4#nDU)!^xf34sTs|D}ymFo+dl_*yHTiXwuVMsO1(Z_jW#9*v zw<&Ik;bklyplUM|FEnHwem+`0A!IJSCsG?%I=>$B_CD8xp4ZB+)W13Jj-HHAQFLMW z9!d-9Y3u*lrhyogQJOt({h3Z{v0~s_?5orglLShT${$`9EyrZ#|++01>1IxH9Z(4S$`Rlifd_b@rF_ zl*8VA?0n5S6zH!g9wJH?nZ2)R#m0aAIPWN^k;j&^I3?U{EZ8q=|IrqmqsFF8UPO_+ zC|2Uz8M6dKsChN7i|j=vkn7^!?&PygceDOda;&O#sK2rT-Y|s-alEdXwuGvMDT3>h zs%Kz#OH8!X_zt-Bw{1d3>xDeqix*0$_3p~KYiDD=D&IXl;qi&*YvjF-{U^|FUvwoV zz6WsGPaRg!o%GsszKQ$j^x>(1eLTXr{U`2f^t9b<3Tq*yflkm$uJ1XM5hKa{?vhV6 z=9a(>?YZM+b%UTav@ywqTvsq;6O4rAFW~d*80@g2A{F0m4aR#F(s~}gU}!y`dlb9o z1kmmQj9}>8@A3%8HD%s)7Yrwl4(SZ{gN^$c*;PNu3q-H_6wh2-^JIzE!FWT*qe&7? znn|q>(o#}9SC+;dp{)zDNJ#rckElyk5UOG&0F>5%hsa>0p#&&50jC_sE%(1UqX zv4|oqDV=MuXPIy09iFwO5dcEu58eLtsq8eK{X#~G+S%X7(ZTfd?RUGU9!Z(BI);q9 z7UK*n4boa`V~mgLc)!%Wu>DM~aM$po(LIu9w3NFBa#10-0|-1GZrK`1nWtjk)9FkW z=0J2Yt>G)_ZhkHk$*dW_n0ADE>E1=D(RRjp%;#y#wfI8qYqw**iQrmE@p{TZTR5^5g#5DN$#>k)px_L5YSRhe$_wRcgYX=ekCY*>7+ z`&~N3H&wOb^=5KR_PixHW)1pD#xY^yHC{d%YnM8_14XG>33Qr4`kYe^bC6D3VcD3^ zRT(8VI<8%>O3_O7L$+yP5UD(`Y}C@Z1Yv?O~1+Yx?-UBVv{oZ*h98Xhw8HeEf% zIXs7q8D+bp>n@)@Zcw-`t#y!OY~-$czJ9x`BUR$Kg|yVz@bi~a_7h1~X}8j>jYc2! zn@&8s1bw7ksFaj?S?SJJl8#Z`>4fbwlU|47u}YK^L}#B@{Mk8|Vu&O+U)IC=;>5AZ z;PW`G)g+Ujbu2~f>!Nd%sYe=N9~1?xM{qWUjT<6QUzKP#h*3=3ezh4L3C{y<%`x24Bp%D| z3L-6l7N?h1jJ1a%H>^1`n2TD@;l6Wm9ng`Up66Dxg7p+E*cZHbc{S_4?sPf81+CB1 z4qfqD6c2+%yV7q9DemX;lCeOQ5n>PJ47@V7_@QK0_%H2+ePwoFBy4;U{&iXwvRPk zb0aOT-x5^61&E=c(s`FpG9=0PF$5?R;%7NoMKCV?^ii*BJsUlQ>-;2>jW;iJNC_f5 zdgpnf^+DbJc71Eu%(t)lwb>3YS}hWkvdH5?>gQ_p)xc#-7q_<2i1i8x-e!JZtrRa<5c} z#HOs2V?gBP($z$X8=2V$#$G)*LMoIRnJv?!L@Y{6Z9Xo8Xj!+%E$AwJ{LjE?)a;(2 zfWZUpdm|X#n~U#m--xPU7u!Rgw3}9Wl#e2Gx8k(TluEVmR`bc}mWpPf3DCuBNlM=P zjr#oG!Hwbg&L0|~-qBB;f=AFrVTOjl_31&a|1vV$&&9~HAHP-E3 z9$U^<4ktm_;g3|B3Gm!oegleO4Sn*HI5WEr8v7|Rb5Mr5*4!#&Q*q`bIf|$O9{F#v+U?wa+4$$H`4c`L zwnDTp#R&3-3ECSjdFgId?fOHIxTMLqC>K<4m#^M4v#7#grYq}SKVtJ}&eylhAC6k5 z8lcq1h z8fjkWT7)|0SH63{wa3+ro^RaYtKe4(eJr$*>=d(?Mlk=h~4*n@0I2KPbXz zy#(3_fpSqc><6RDiGZJ_>o#aSZ}2eE(=Tohcwa8jChsNHCfPQv=bIk2HVjn3UeJc2 zf@WkB1odX>YVtONr?!9kCJ3OWt_rSI)NPB(v|Zg$SG~GaocDA6iNBKtuED`xB`2-@rJmH=M$idxxt+pU7M$kwCu*q>FQk?j`d#1x&M|v zI+kVh(w8rl8W5-?qRL7SKDxtI)o*e5&7NGkYr)>p=U+^oA*?94m`aaH`N z5SPi!ua9?HN*&u;-Z^b+av2IuisuTgU{bVm`d0T5+!Vnc%#=bWo_*-2*U~zZPc=~E zS(8)JY7_gpqlv?%juVUJJ-ndpJl8GE?ru-)wa=LC;gzWg=?uo$vNC)QRZqI>{Y>K3 z-LpS-^J^SlswHMfMQV;>0_aQ23+9cV0_E<=2&D(ko^#Kn`*3; zSjqsumUh6lR;Zu$4fINhr4`~N;&Q?9HPWy|uoAblZ+EwQ{%yXk3jg~b7os{q;^{osc*;=M zypTA4PCW1Z(9dnrEwuu(_-57p*Rw= zqF-M;-TzuQ5lp!j_QRpYFAKbyq4g=+Js8b=l1BPikn(~0YZ)!rr>}7 zmTLY`vg;}ULs#`ElCtwFwfYhodME)5kAE2n`SA6S5t7xAu*Ahf-IUWKON*bJ6LlC_ z2m`IlfKRX*Jaq`gmS$2Ly+*C(bI|)-*7d#Mk6V+hvUOUTr8gzus73BKX znJb_A1;MsuizoBFIMB^#;`$)}6>JEaL|jS?5uxU9j?CEJuzeuA)qWZh;)bnGWa_`a zTKe(Shl|mqVRFXyG^f7Eqw6x2T9tZvoyn=I_w)6%n)3ZG{62K^vF4l9$8GcV(iC#5iS-Y;LM?&-*IPsvR&S@}*9W?kvQL;R`)I<_ zb3I!5Ap2}EY6+-RP`&?|AHq*%a#))3Bs`*L$KO=|Qv5ii_%FNe9x9y4cOB-wscw_< za**worD1i+kaB0}r{FHA;1PAt@23Gd&MP6bDdgj98T7)BR&7=^v#ySSD!0s5;|Ud5 zhvVuo9`$T2eO8jrwiR3sV2fABHbgyJzrr=RL%XnJ`EdveTwkmMj2S_sIpn>3m57W_ z=H>u-`>CxYziMs{kASZ@IuCJjc{i7?PCQ90aN2IT-UCv@JmCNDAlr5;Ug$+&LgV5{ zy+>iB&v|k>9m^te?*5?@4?`UByBlxA_SzniSZrr* zXrpQglr=MxL5z6eU>G_($zOYO*iw#JNsV96FqMxZ=4^wKHdvdG%+j)Y= zl+PD4kAb5XdH>%9M8Gp3n{GSr_sQG*h6z(*bRvBSD=x7orut~k!XS<>o|ek}#kTO5 z^4&cHVSc3!cSV@4@w^XRCMLGR_B2n>FfG$xYmgoCfsZ(SHJptB!F<&(pp2@Wr)(67A`&}>s@$=XKv`|Uh_0w zgtVE#!KwqLMv&n*w@xH*XZ>9BHM?WF_fF#8h{#aa7WdY#H`UiDjB-HWD@+QY1^AALC@RU%e6a4pb`37#pg>@MK zhMIrpcZjZQZP_U~(zgfdmr{$dCcoZ1T&dOn)lb4z4JGUeCJk~$;6~Q`mc~l$)j2(U z=$B60D4j+p+Ml)6ZSF^WsDBJQ}lR2UZhTLn12aVT^B^0bW(O+y%Q8}C6 zPA}|s612IFC+UChH--lG?+!+h+Tt^@N(v|DpWjG-{1{1^;oPFGl)jeQh^f|IVy~fD zF`}yRkx!f9*Ut~$38o-NcjiWk@1KRZcS7=KV3?~{t@1{8^1O^#ozYA84*Bx1r*(R1 z8$yuk{X127e`Gf5ex|gM>((=D%~#3?Sw7Do*2fF{iA!u_W_jZG&9ghpyHk9BD5nDo zcWU>5<q!ds zwrVnvULKi8%n*Tz@Lw$XZIj=44L`GgeS|S~T~KqUWqz68#^+gon0KjBL~=^byo5SB z29MVn>rd2N+)}xqu&x8PUFGY2$xO5-xx-e> zaH+d~iO*b(zBvJ4Mwi^AoFVJf`81D1z=nN!3y+hU8sSAW!!O*87UVLfv%MG_{cxoc zW}i_c+=R(7qoJ*uf8Io-a<0<|x1#FZ`$nP)d`p30h#*yuQwk^V4)jO1Lf1ZgROaGD zKTe~H`fRPmWLfiA$HXsVOw9Oy;A%Q${mSimrurZkXL8xG{+g`P{r=a2?DV8p=gCuF zJ}9@MlHF2HJ$TjsI`XP%O0wMv?B^{GC#E;OF}4a)_mfW8Vp(GzQGn?(14Sw?R(z4f z1^i|dcK&aCf(+%9?Pg86X|~k-tw3Pm3#*%^DCf>e3NN(zec-?{nkTJZsP@r^tLl)+ zZ=JyA!#&(vl>^M+1r~u+PT%36Du7ivPtRqS7qI(jV`*6gYvAGJ}B&j=I-v4lQ>RnB<|aSGC*{V3^Y456jR-CcJyws|IIt=#vEZw^a;>;71= zQcQqF;F8{-qcki~Guwm88r7>;U}gLL`(o*c$reK+|GfwZ%J_P z;!;pmYL_n)1ddI?M!4Z9!qx#bB?|?~#KmMwkG>0GJ7ZRU^UqAzl5y@%^OIFhnblh> zNT4~yL#&k(B~BdOc9V=XnM4+!e%4BBda*wy=bAxIdXQz! zx+Lh7s(4>a;pd*B#i`HY>|5m?*Zz}}GW_AyaY@^I!cLq5#UEv>Y4k4MDdy3VX=uQR zTSzhD;OdvH-FrS4o@2aZMiE&ioEet#Zj9D=*7S%W0o%3*?2!sVXB0^-neM9S=gx^| zc<+D4nT(-De@FJ6a=OPt<;GQ0@;Ud<1>0FNJfkx3JZH4SE_*lQH`~=B68rT9HhmK7 zI0*Y8a|pHn^EB8d%Rt~qYG#R*J5*q%xMsVr6sytap_3r<)G2)}Q))s_VA0`hMun`F z1Cre=l$P&rS@?qt1LeL?a|iS(F8Wmc+<6#oG30r=K|AhLzZ}PM?Tl{s6d7cQL8-?IxazZLNzTgN6HOW+KZMzw=}jBLO6~dfQ00v0>kFYm@e=+c7hEJ) zE=mQqE(G`que?lMQj)Hc-0yWy_d!D5lcKcn4!EwQP*thc^T|T{z1OvjaG&$>Fxr&3 z2YmGT_3>_-N7^_Mtx#(A=Ii!20_<1Q&&yif39QA&xsywQ$yE}026TFV%L5J2nM;k} zQU^zBKmf~@p8C39wheYaOdC;MhYhOVcP&1!PzKYtT+G8WX-#WhLc)?_#gmdS){cdGN+i$`6p!k!7$&f-Wl=xzLk5S><7hVQz?5{qB#l!WPz&HAg) ze{SlmDQDn##6mRXge63@1vO&n8Q&ys=MBw~rCE=D_VaWEcBTi{RxK^QxeFM*5G5}V z-m&cmyjiELpUzg<-}^aXgQgym-!_P75 zOu2j&n?wR*pJ=6)HvnYn+;@%ap?1VQcgtTJV`*Ynr4D~%utin*=v_oIsC3_dXT z0PIl*Q8g`jbaZVpIoPetK3{!Ye?jNJ5Gl^shi;z!9krd!tnkjzpDf=U=J%`1-AR_U zpE-=fYgCzXq2b1RJ%bazs^|UV-;xFoyt!&yxNk1*c{!b4otWn!S*shr7u8s1wF)U_ zZdmI6_cqEm-sgPe(<+r;W-m=wG9i+TSiyC%fRYBxWyw+z$ifLURWN6-2C$>7z^CtP zcLOJtl$i8cbMw)12&>e1Gdp|V@gPgP@kcJ0pm)_hCg4fe5V7B#wcz+sK<&8YeZxkJ zU)~q(=~I#{5zC~?>IovS~&oMP>tXEp_)hmk3Wc)r{ zzlDJ%a>+b_2>6qdQZ-tiG9+FBVK)^E)IdF+nQ$bcy*lX6~SMpDb1D2=7WQq1MnGAt;MmJ`d|+rs;5WkYdq1&|P(x{f|+|^RKAk+qT%2e9@_u+h?ChGFKKJqDR%DQ;A^)xgSb$rwEyN+M zPT+$Ls8X~l8358XHz}`%wahT=SZ1gbb8mJ5z8?wRwU;EoSNc{WU9&`g8MWPUG9)VXa z3w1X(SsJ#Urq%Nq++4Y@$6RsKs=*$`2`dfH!K#9ux_kreFIJ+}gAldPzYJGfCweWh zcYJ-X#Q;|bmtkU9gxI0B7*T$wj1_=aXY&&A!S*YAb$Q=NLmuL`%smD3k(RWf>LMt` zS8j<;>o?%Pf6#lch?>uN>xN+eji#K8`iZq27ds>HzXY{MqKlXz#Lc#<#9OLbv+=jo zw2s#kGlJ6|lnAHZs*}CR=AA<^OseaUZn9cVz-XsFqP2+hP@Q-{E{BR~4$naiCz4vL!3R zs;r(kpBk+BN6a)%H!cIgVdP zY+G{H#1|%=fH@Nff;&q~?fZr$uih0S-2$hpJ(DN}=u7&R_Z!w^j7=M!eJ2k@8~!nYcMN6|{0>U&=sbQ2`Jde~;_l^}9+_6vGw6RP zOB(?qK+8l^{(&%fkd^xKW617*^H}~w8;D1C9C~t9hqKJTjr@Q$izQN z?#Qj$ilf+4L7tP#VSYp#e`N%pn92#x;mC9OSJTb`31}kJD@X;R`nuR$ZVV0}F07&c z&1?_5(86)X8z77v|_y!7c;frmp}vlg4al6t5O+oC*qJ z28g>;exjogt9#Ed9*T{}))dOsf)w>GA$QD@oyOn$u4JzdqUNt_svb_(Ug*~ft}6n3 zh;?N?d5!g9N5_t-y1dL=RwoZ1Hn)abP&r6P;8uo)6l25%ZX2Iro^G(z;rI-hd^#7K z9PDplZhrU`c^KYGwDoOz`NtnbB?A8hn9T!LU7)p8)=A%@W_!c}b}H0f#OIZ9+`R9a ztJm^Rsq>tT`Fsul^2%_HI{PaIqfoN`!I0BYxPQ{Db16)8-pe&_#?|T_S%4Bnp!+gk zdoTV$05LU2VNc?cza;1Ho0FUv@q=b*sFG7`+W78{lF{7PIrk?wWU8P9YSQu8f?lHf zx`y(1@n8cF3fot;mJxyqIz1cfzw0lesk!U4;m3hr3Zdj!0Fk}D4U$(spBxlx)Zse` z5&7;~@SSM7c~|oE=sW@~a~cZcjLNTQ)2b;U@3U!RP890YRD9G}P?WuuS1%3;$0v>D z$c9m0d}wVVa`wv|*plln5LEjE5vMFzQnZnS#Brld7(s7}B_ezHF8<*9q-?4Hv>fV< zM^`iwhuoU&WIpx)=dBr;e-^N~{6x;o<~qLnPeV?Asd5a<{IUph=bQW18@ScR!=Y_U zgvM+h=Hls0TTUM}uLUFJ8yEuu}}tXP|g7U6>xxcR&mHzPZDalewdCQaZ{JMS&H zivGMXV7p!OwDCYO=PoIpZ&6Q`R(j120HK?m{$1n~;CiZkN$mg!d*-~H;;~<;$uEVH zbMn`2*WEyh4o45NN4I9wdn8Zq%&DVC8PhCF0jkmdz?E7w8{06gNpZ@|;agvtF;3h? zR=Ka+a77cnW6HpW%0T0mE3tonU^kbA1*fJ*b)=1U=pULzXVVjebzen3-Eu0Hxogb_ z^!bBo+6QU+u(!C_QQG8X=2d)-71WeLbBf-0csiJXEI7YJMmd;p!b8yVyoynf7-ybLNkm106xZdi(O7BcB zv8T{MZ?*p!KngB8?E(-+b~P#yz7hf2D<^2D6|87=Jd^f z9nYd~`UL{zc>tF9R$mMd;vApK$=<4rjN)9eeMpF5Wd)na9JsR%I)2hVx(E>v(HWtkHfy4noYT65OUaGW!z>mb#ei$tk&A zf1{JYS)+w8ctARZpDc*$u6{k-sy<;{l0v#-o6^ zBXJ8p&JcK$zNIW59Xqwv?Vj;V7ePmQqz*02!vRuFzIYG>2o#W! zP*r{Y!W`}@GRJ#*OoUmq)w2yu$<(DLGAt;hFc|D_sJBvkH=|E46MS^8XIH6; zU4udO;2ts)11lo4Amzv%ei#?^Hn0a@VA<^ZvnNB;^77C|i=pp}M~SAq6;RTJmdPIL zvv!#|r-u6929kDG`jlRatlQ-e{wRuEES-Mo9Vj{1>F8cxVJOl#m#b!DSH(VSLY#S8?E2g5sANfvz^#(t`9fR6XINJXLf@A(dW&5Tg+H@B_{$q zo)}YS_i;mURhD<(vy@k}k8?JE!*B3=(s=W>yg+oOdOb|Ov`4<*m=z?r*BH6?(*O*N z;MCGVHN&ti05)Vkc1drkAB z4XEt@@#s`F+k%sV6 zpZt8E7xQq(6pt}&4;ahxb(6sY_EWHVRhw4AGS zz`REhjK)WToW}iAU$JqY5N(s=avoLE_j|ys68OCQDsNV2Q@$szjZAkh&lbEl0xk+%_8h)2gQ&d-=^V(DUxcAu*RF@n<_p7 zOg33w^x(5$Zwn4)&6TcRr~KPe4Y_w-@dDz_J||*`0WU+HE_EqcdR=kQ;zm?ovTU+n zJEnRJz2p+$H%I#XZRwgj)4c)=W_>Wlqd!i#*^Yak2>^nu_X;PA+VdE z@8oeK=ewEva8D?$e0~CYeBocu*M4WK-77}oXz$~QhYn<1SJ&neaS?w%)|^OHdb38~ zKKS{@elWw3PUot6>m5znxCE3EiweJ93~QG9R>&_cdPC@mi7K;A+J4nueoMCoUbQYs z%c4a`$#O<|Edv>qS#2o!)Jj;Vc%pl4&Q)_+o^GlRE(_ErYkEVt4VLha=FC&-Q!`9P4b&%cP=BPYqRA0_0F_+pE5(;_)c$ z#dNt$` z7;I#zsEUa9TQfH{L%VCovZ0*=N?~E*_bzkObAyF8^E>oAX0o=QaQa6aBY!)n`sMAA z77Frl#Rhb16S=Mz*Owy0L@KR!GFj@)$G}5jt<|;Lpj(fy)ek#+jK)VBNt7) zQA^HWc4o3Wu0j0`s#;2|wGee{fJ6AI%XZ>eY782DBV$*A9y3`y?ajchn3%iz_D$6v zKW+D8g=Yx)o3FjkC>jAAD%3jaJWeqy+26gwPng;(lVetPGg;gL9| z|3~*_xe%Wh*{kaZ-dn2o;%57b7uSer(msT=!@SmsVPWbiXK`yI1;fvhuX@O5ciXLP zz>L#KF-A_Nav%9i26nWf?}iOj)j=C9$H2*D6NMI-^a#bkH^)7!cV(0%Mo>;szv_;~ z@CNfCxw-2)5Jsg^4`JXJK=b$24f^2r{&kR_uFW~})pk=alIddq0@+&=N1&b=2Gk#@ zf3Wi}=3T$3RtoJ=JM74`tVmyc`y=;MSvYdG%v565`GTXjxc<-A?BOm}?V%p|bfbgG z5|mh^eSY;o7JA(5bk(>|q6d?*U~eaF+s;-sY|&HrOxrX%%!US2k1`gu?jtRTGy4M)AJZagXG`gJE80YoT`>;0 zOZp+c{BIE&M5s#4S|wWGDgrNDKCF;2>P21=t! zhf!9>mI!Owf!?UhaL3MwsiK{hN*7KmCq~fa(rdm~`?`B#I9{jamz4XiFeN>?=D4Yc z43lJPYA=R?$q-HX!NQQyGKc*PFaLjAd{K!cFR*h$VfJdlp?pUw{)>M>G?0I(Qq%l4 zsnEzh{fqyaHHwH4;*tC#wkQk8_wTfhtK5+}%yC)Srkvot_CQQe!AgbRg9lALu<)h# zUh!C2ErZ_AzjE^KARQ+y5?ww#`4)<+Fzd*(;MHE3eb+a^1GQpQa8@VEmqELW$1|g_ z7LvZBmaEybHY7E%+L)SrGL9FR$oZ<3!zV#ztVA8Yhmb1x2zN zaie_M+1UX`_d3hzF2>cP+7{ixQR8Nuf8a5AFLdKoDIBI)P#J_?fKG=W-(S`%d|MTD zjes4oIhbf(Gc$DI%d3C;jvCc$eJJ51^68#ah%xGBrv)~!!TVt!W2|TI#%T|_?-=2z zi?M0epr}%m*;ipk#)V|{Wob+*wk2$YfQNk>Lw@-4ZE13tSUii!lCSA&vB}Z|^V^xFgFLHm{`&uFN^+qj*o%IS1h2XD0V8?9Ovc&tH+KKr{zDxln2!#SRQvS z@14s~aVrF1o@pTzE?rIi3>z@+Fbb;A}P*X?5o=ANV zIZ_5)LXbRjxF+wEPuC+@4pU}IrhM?T5b}f1168>x^PE(6V-3$k1#4ka^a!#)OamIVuh5if)t%sRddpm*4U98{8c0Lo2BuDZ)2SFI@m~p4tyzmv1BmM zvR%hk)=VD2-eFpX0Z6-)FAVU{1FIq6!8kRtDbi)IA@F6Yunq-COlTAckzjxf=92KpA#x(>8RL0iD@a zRfsC@e1k7ff%5C;YYX-Dw>D?!Py}ehzET^Nc9G65JA)}KhLM$fkYMkuF}#P18)-tU zfQegz-Z<>%S3Gmet|aKp#;jd1bb(*RmtFX}-v2W0v(Br`r*p;n5+Prfp-23r2k{o& zl#4qn`@btkQ` z?LaL-%(b}H?tz)H7ZHtTC9;?jXlM{&;o8nmo@>BbBUvcjB>wQ@#Ct z{|8llu7>-i1zrZ8Ku!wBcxcuYu>A}E8^)56&r%mhOnoV}7m?$tPk&91bVG;1ptzZl zjP&;yV+*8CqQBix6%Yax%fOZ?=T{5Drw}@=vnx`hHM=RXe$iS8!FxJ=^DcV>He7qm zq_#+-@y}N&*SEGW06@(8(${F#>V<`~vZfMN04Ar<#;78j2-(_*@g-%(mIC}9bwt~v z1S#0DG!4G+2r+%@{e=@`=|K3(APh>_Gmyian$PfD5HY7LATWE1O$?Bky=7g z^XHoySKIq6)ALH%tUz4Xh1=&K7sw}Z$cTuxtDo-6T(1FdSL~lmbXeYWmKOMajIP0S zl4q(D+oBZaC`B9F(K)pYTKyRABL?C5WlYK7GLv9^&S32_X&~A^E9PA4gQ(ewRaGar zkuCVuDgWp%iJt1%I1HmS#%RoG=xIl%`Bp@Wc6T%FExz5$^&;Nu)GKQBbQ;CdMdqSC z;%L@F^-2J4+;^A}ePp)9t%hY=82izT14_b|{j5gJPyr)1tkJD|1u+RPQ0@nu_H)`k_@W~;RjFpT+eN!wveX4zO!7G7VD$?BS;)h)XRyt&FO?zHQ<+j+-R#Q_rh7t2I z`C1smEDv69K7DIHx}Tb4KU&tRhT(?4w)pUW_#S_SLxnVZd92RN(t3%^bOwl*EvslDM^pZw36PF` zj(SQf^7dw&eK1vkoAd9RFVIrDpd#<*rF|3Q_?+6Z4}ZS;tihi7 zIw3&C?gpjj(asA|!RdP)7^G&rO}0jeNu*b!B@ElT<3rmk`QRMW1ddC0Ub9`(DsI$_ za~U<0{BQo^NH}CUEp0T}er*Fz{zPuR(GvZSq#r6tn7E}a>nmwj(>ALD z!jcKiYyJdljcNoGzi-y45YAATd}R7V4fLH(odw^Fu>{O41M(q9cAX&M3vW;-+bP6* zyqF|7it{M!BUqX-`RUTNQ^2k4)dZd8)^GnMf-P*BEvS>#rN&tgM44hfDqq?)2)o#e z6`_t@zBdhR(lOhW6HwMGU&i3ysRj`(ifYD;J|iMD%iBR-f~XAu%Mh^0U^IfW%tFYf z^M3GPAe49f*|uCW<>JBb^zOk{csTKZ3F_p==hG^)!B4aB!^|{ycjF#w_jt`w>(<$7i97|5af3^+_JyHVU;2^;f16#^7_r8ev9 zoN!3d%lYq#o>ynod?mHNy)>SO~2qVEMse}K-Y<~}OTA((gz|wpZ_Nq>OCN8dwuZEpfdvh2!jk^5i zTJmM_IGs-Po_(Y(FPR_=?p^BeB`i+T@aB?oFDhk%J;y~Y#&N?> zilg}Ki77noMGW#2;LR!^(@m)c~7t8-0p}H+clE^3W`9Q?Xl&gB@p+dfz4>Z zpfQZhIpbuk9ZG}G;rT0x{j!~(`i$9xIEz6~QXXj|?;Eniz$dl1u!ft{ylc73jSB9A0GsJ8+?345%Uz z_p7hz2`#D_eLsQW$Dn5GP3dOGAh)m%k(dh~P1ZrW-FKh{BEfbc5>A|nKw^J_tC|A zM7vku`fPTmKfEnMaoZ7c3l&oIy`{>WfDN zgxNa%tA110CH;%|z(qt`m}euk#c-)HtH7R+i5!Fr$bGqbnob8c^5ycJea zdV;Cz>Qh%KL9Ffz`Mrb}GJnHR03;MDBe52C#f4uBnbUK}Z@kCdyz;+21o^3#AD)wL zU;nDiX(BiOTIUTMk$~0!pkR8Csv_8DlhZ3YcZ0d4-cT+~_Ux9#yo5vcZyOtxuSZof z+vLlZU!|msMrE8(LhVigWqPu6B9Ic;uI_OKYI?s2EFf`_&8SBQJ0bv1{A@ZemD+8M z6)J2qB)ITCh@eaU{gv4bhqlIa397SeoH6Wsue^uU0E(M*i>unha^rVT*V~=~Od^nM zL`>}(6CL@L(^Vh7Dad83Q~79wM+EGyxIfuDw*WFa2%~j}w03aXYKj*}Lw~V&ksxY7 z?$!R>Ndt3v7pA0?> zM0l3knKrJE_dD{vI*Cy9=x+YgyBg=q)-DJGSg+sAy08A`$m0r-x+#8{_8-z0v+jeZ)ucPoojNa8GXb6QnGf4z_+Whn5DiO$O~T62}Q$WpCy zX`J_ekMR@wq5`1|7I*1+`5eJ=#r75d%Ow>wgJRfV2aK8fuA|3X_^k<1Nbx&6*hb*C zQ^H%NTAuL`Glvo9I<4j_mi0qxKc8;5EHSYi)yVq1a9LDK}2L2Yn_fM-DqX`l@C$T{OZ`VWOs zDU5$Q&;IsyM++5$0E-VhFO?c8*iUESMhp%GOb!d4f2H33JwrP(&&tjH ztp8NcFzs}8RI0P-Z%z2mZRexkWQnYgcq+yGEV=GYV->YcC3FJap|aJhD3t_9Tv5(f4u27m6sv3l1< zVxk!LLHsl+1Zw&gl@B8_9T7Vf@Er(H{~*1O<|JG=wedW7BHsuh(g0CKi|Y|?HdTGF zKP}*rV5auFEMX}8Fzj2(Z7(D1g1Hevb=`p;3Y*e|r4{W9DOfdsz5g|`F}KRYNA;=J zc1b%00td0X=!5HDCGFrLqbgGHzVRVr<+L2alIlPlQRe{PyC=w8Pa7=~5N{+XhBaS% zbKG!g`A?c`88LhmQRS&NF=D+LeZ!lU>DJ>wB4pU_EdrUyyXjZwjZjsaGfgM}i{ok6 z6ot2hg=6T02C;adnoT@)$xoe1K4mxLxjkwY_LDMf+UB-U$F$uw!0~k8h0hwBZ0**# z8v7^~@1_rWQ$x9e_9kHU^q8^7YanjPRzc2Yl2?Z9_5Y(wS*swH@W0U9=Z*=Xf15t& zAWt0u{yf9pN9iY+PMj~8C-1j%dJ?!p#C#l)XVWQ;GS9pKP;{M^`esGL{*RReZ~tmz zPOivc$YseS>t;f%_x%+f7wdw|_do)eipngI$SgS$ji{WY*!0jbh>w%PXCl9Xlvx#?rbym<40_ zg1`PEkcx^ZdQ%tW^IYuBL=gKsLbF=y^CKz7_2uOD7-(K@b&&)ghLQdwU|)#ZU0rt8 zq}iA#w&NZA+-iqWwnYTadrJ3jzKsajUiyQtugQDPj8h@pw)58CiH>zc6vTU~Z^ z+t}J-6Xyk5#bmG+*`T@Bq3<48~sU2^S#8e8?3K19QBLww-HWssjbgM6a| zKV<7v{ULgrvv*`V$@6c5ot`Z(^|d805>4T6&)14;L9z9HxDcBAO8*H&7QB7TmFhgvndDm8}9k^)kRpA5a-=aH*&EI zb(t@$YIZehgt{(i##(+tY}6~sq9H2KTp)^9I&kHP^?M*Z3pFIl;Rd$8 zyGNV;+#e7yh`8Uc_lpX$C0?q6ubvG}M4Mdvq_K4-S3*|4Q`vuwtysSCjnSeGA|{g7 z&6WdxY?F%jSmNj)aVgGO=HVH8Q2BRq+?lBUav>z}dzrraNss%g=Ce#!&7GD)%A|^l zjiEe7s#uI**LKFj=3SGhh=@Jeo$nL&ncew*Zej2+21!~Ff4^xM|E4pX|77PXFeWBZ)-qRHThI}`() zw>eR8OhsWD&TVlCz<>{4QN#Yo?gd$#EXtu;qb1l_6U9yDSE1sUuiHVXy*m0m6jf35 z*BQ}}oo-tGvGa>I*yI{1Htna6aqIP?2$}hkJo?_YoIiZ<5!N;T%tr#9`@ku0@7F%} zGr~OVys!9NPRcA;HG07w%Fxx$&A*#HcTk{M8$QNf`KY`UWha-{1HRbaEV1M`T8`58 zz5AQ@{<%Z9UvC**PgH`THjrL6gHqaTn$g;W)gxE@@^`5y;)i%+Pz7=zBb^_a!qZw^86^16nr?V(KaVcrN^(|nLYpGaAglpJvBLw63vDFK zTKVAQ=x88#O~@yEeOAnU(U`@{w7F0Rk3UWq9rOu#Xt!?rx9$IZ`wi`m|3Q51&UAN6 zDbLde0;;QT#Obysjv#H;EJ7l(Zz$|zd}Jtp3|mqkH(6g$?Oza!NyJu+_Oka8{e%%! zM(}vW9e3iu`2z{-UlID-j-2!(fnF96e?##Iu{zYnaxC6E#9$Yo_HDY;H(B9{AiRFO zf2a%H$w`jBU(Lf`dTIowaj=RZc1`hLzj5=MzL!XV71)n6ymM10!xvh4tkR*9+WzsW zFwp6E|K57K(pIS%vI)H89evSM_N(usZcZrq42v zp1kElpu)6PfToJkM5}{^DrOK?Alya73}Z)tP9(-Sxl}tfg*NH;sM!1;yac?c^(1QV zFAo)zL22?Joy~`u`wY9tkE!ltVMm#~NVHe}yy>v4aI^10aG~sI#49-ViN4DaP~)24 zWTLaoNRPelf3E3A6z5O5fq>sn>VE8PV1`<~ukn*Cu}KhjWqDvxQITTjK)8ciUE)&P zMvJ&4>HZUyMOdxWMK+c;#{~*00px!nL*;VTh+v@d;$x!~tdSuzVFdKyP9tjTGR|E1 z;Jdqu&-!F`Z^5+7$HMr_Oo`%9()%d;5h*UQNIZX^U{G+Xm~9^=Fz3oyEe`G{V0V~C zph8w2cY{pk%W`sc;>JoEYQgzZ#J%yKx;2_CN^ATn{WPlEl25K*r?oI5?m>qgFMt^# zVQzol4#UEA^9e<#r@3LE1Hr3BB6KF_?pBwion zpA~}*)D^uo7N897+QaO1=SB!72ad0nRAZ*v@R!Fp3zG}?p2rVS-o{QA{fsxi%d%h} zfBxO1yGPR|8NH=DqQ7P}#R|e%n01t0QkDxF?zmhBoqV?nP_x_f$1k z9mNk?IRwTOAu9abw^twiwXjToU}m~!3ivtE30ES`EixaNet1B&1NZi!TVlKt*<+PZ?bzW@PfkpMO%g_K#O-?mz2EJRu<0q|yp%*!kM3s<|c? zL2VVVU~EfAagumqZjClqx*t@g+pYdGInbb(@8V4O+A&4xHYn8jhatdLiTpN7cNcLi z#?fG_96&60o?j2~Lv???Qp{iRp|Mxqa6wIXY04!S3dZK5cebtwu8&xuZMiXNquJ_6 z9&4?Z=w*Y#9Qc^u!FYWuz3D75q%rMjSylWb5{K2X1ZVQPct)lOJ0ZpUf25(oVxB?oD`HXpEll|up#&8Obf9Oux?cP?W#NYo-_ns5M3 z(fj~vt$Aa{>$kSI{y|kelAA=-3Anh9az;DVTEKgE_gUYpZ}OE^2lGA6wSN@<-;$5HBmvJu5B9=EvEcb^_9FOk)n0cC6lv;ak-2a4a3xjgG;$EA@#Zg&xp|rc4 zzORBb7}p63@mwFwyewJpn=Mr6l%B7S`W2~0IveYvfLDmXllgW)4bLI4(kEj!kI5tf zmk(8;NIR;KfmPkR?jO_4pk|F|XhdW;+cyBD^*3#8c{+oWACz?W}2 z%2Hi^^6*qm3^$Jj<#~+k)8$l#pKP3J;i6a+UJRLz)1wt#$MwUQDw7Tp3>WGlX)w{o zj2-GMDK*}w(vOpLUf~7Z^I#fL!2|6k_f*;Eql6_?uQ4Y{g>fv3SBXA3%CZ%_2~umi z`di4Su$X}t7yS&7QR>KqCumLcFL;d!q(-)#s(YTwSFi=@W8ZwWthO&ffftNTw*n^T zJ^lt;kZ$~d%x%XtvUHP*N zkmN|fx8c#fc@~70A|2rnMbNK94r%27%|BIN%GJ__P?mk~j?xyat5`~qv^3M_V;1(R z)}ya&ES`5!V43m$K$GgeuXBACC~goyaF*L7U0hu;U{lDkAYAb*tvffChddeb?HtMU zgRp`d=_-DZy(1<#RoRgn1-Bj!t=6UQww`kHZl_0xSUm&$lX5b4H-zO_SYQF$(aILu>haLmS2g z&v@?j$-V<6Co7af#O^TBM>jb6C}uelJDHYOG(?F!WV?0pW2P4@DiedmAcu<7#rp+W zc`O?*ePlIdhTIuxfo77uOv#8UESwIo4WUM3k>2L)NsM%KZCyg#BXbAv`3UGNG{cFP zQrOq> zQl20a9Neh-!b~>6FcQ74z-G^#rjip=w&^lUMb(G!f3ulpS?q^g-mkLb#~hAIwVt1^)`d`8GKzzDu~tKGe0NOkQzm1AK`w7gU-f%43% zl(4&)o}i!>nJJd{Qg)Sxp*@w63tK7M1?bUq!!F8MsgYR33e?c)z4iKA_a3*(0mg(oH6xHq|&H^HNN64@*#}K?I+AS@bI&Q2yh12TV$zB z64G4MX-=VlmDjvodD%r?;=LkSJz*}H={>%;I5=ASmkjQlt@<)cQvV zcTN8~W&H9%{vXc)fzV~&h4lw9psd((?z7p zMJ-Zp3zg!htK1ven*(1^AtGnrZV{CZR*tmT1D4j59V7qd(^^Ev?IA#fE1DMBg&Y2a zBB|+OH_MtxPg?xyaB&tWY=1ApUc%Tay9UF2RX>0adLG_o{FZLPoYXRIw;v^Ui=UHr zK|}hvL`&9CCB2eCvFqVD!+2yqayR@x{dKB*?H@Zztu5($R^fgSnGl8EbWxGE#lbVc zeBrgN#q(|A4}uRy4&)&L97@npdQG`Mdb9{}QG*{n#w7}>D;C_oozU_ zPbd%LZOgX5tNV zM1or_j#Yz}PJg^jrCE5>ngItRR~oNc2=2FD1;GrOdQQ^YaR<>BLG0A58z0e`HQ6iF zqLO{Jqeg5RBgmzt#*r@~Gung|f=~_+^rk)-5LWcg%i7}Kf&~)_E5H3=cRXU_`L+ZM z@zMn4&>#E?YI6_^@3&P#2c8Qa*C!%XWe{PJ!JGF8Lf$ct?fO_PgIpsYk{aH)=}j7r-R>*F2WJG5n)a+?+IeLVFVo<{KDh2= z4$CNJ2bn>sQH&b0uGJcHoZ$U<@y_Fh-&2&r;RP^0`FXexl$<2$blUf&kRWRIDIdSK zyoe=qTa#1oP^~r}lmb~hx*PyV7*N68XN6$d zk6NHoG0@5xvd!NIJj}hQC0wc(!Yg$_ZX%Aqzqy~b4T);Gycpa)`Z9n?C9RS3DEmN@ z*EYK6EpBwqD26dWdv-fQTp7~JMFikj9ikLf8gw8UnLREKZguuwbcD|pi>?r`#H@fTgC?0Y)&TyGm51IX36 zc($XYC33&6Gz4>d)MHyX}% zMN}Vv)9f1|MN|jAL(%P+jKhS`#BCaj9<>7YE%@$Nb5B>SW8{NVth7Jq%G$@)bDxG@ znhaDLJ~2dxq*2Z$AFeb?louWagPWUY3I=eo|MvG{K4evON2FFk$w1EPB@s{0jb^#F zO06^frC@g#$+vA@^Ki8VPNgAB5&xgR|k{n60MmEVRaybs7{p_sSB+N`PugL3iVI&oF-hRGfFAp5K%UfmY?3a+2wdp|D z?Nu)+Cg-2zCA}-EG?AT^eq+s^$TvcE+w(~3t4Sm?8$qK6rqV>HHLSV3Hr5WURmIX70(<4TM21zzxjB$AAv!0_3xf3=hD zmxzL9UshNiZF;t6x@K=vxOOJj$U8aY)<0Z<#Mf(CV$dN0@?VT%rOgE=MO<)J{Kj(s zZ#H=_%nIe_F1p2JBwXU)=Tm-v*Ub)&7hXxSfMZJbKAhm7V$A8$HHMp5a17o0pTa{47nfj z-gZ&k8?q+&wp+Mm*KH(CJ-h>Ae@Y?(v66-^%_klV9$4=31JgVI#PI+)=wJ{v!29vSC32+ON1; zTU-#jZAPwi;T|MgJg&{0jjI>0eG~yC7fQ7km{L&5Pa4YpKV`XW}}HIiFx^n15^1|K^=dkCn6Yl-zQRO$!eY<)N zeOe;rT~Re3z1`t~Lt3m4$M#H0oWz)IFSL7SR|9rsB(gwxxopxm<~P8%c>11`{R+rv z=*v%Irqt=xL1;BmHTp<75ksfFpt9kCp#2|l(od@h8*5e|_3 zsM`W?yJN%|6g8r#Cv0Z8{i!hm7 zx@CjV!shMEsKT6&B{fGP5Ch_j$`y`FHW0b5#Hpuxxw|K|>pMgrPd8HL<~nc8I(Q9K z_}bC(h!IY}pfCtLkp^F(xLQAMpr(32IJ0iMtbriPWhaX)zn(dWqIQ9qC4p5m*&P&_ z=?M$tJROwU(+G3lPT5Qmdf387jNO*+N1LSq|6j!@aMu*l9g&$5xSef+-ehe0S7u;u z%{gkBIKYG9k%jpHw&csc6R1y3!q$MHWCtaV?RUY_(q0fzj_vGBqB1Dbmq>q=F@R-@ z?9Jh|#gudQlIbtKi?oTNqoOB+FC77(CYmHLhA#!{#>t=4;=hmyXge0t+%z4(*mupJ z)h1yVPqO$F(UvI9LCO{x6(zy&z@1N8C2v|Kf5tKRhN^1785r%6qQ@=wMrno8{aNFN z)dP*&z+jVv*nuBR)t=#jKS5VSFSYM4O8{JFygY$J6w+=O2JKku z9NHdI&8v>9i>y9-sT&fK&)7y}hOWKQ&t+f4FQ340@9FH0PE7u=DeRcqQ{eR&MK<{H zXuc|@C=}Th)Jo|klXMflvMpI1({mXZK9XQvq*{f9cSpll=_W2_?@D`g(-X7HGEDlx zD^&}#B9vnxU~?!XAgb2u!QetCVX{ZFh+DJFC094^UN{k%ByW?2a=&KbDGyI|L>4(# zdj9Jz85*_(El|@l(>b+x7HDX|h6)OD6O3$LrGO26KCb9QBePd)zUa0J0_NB?fN;pd zUI1_%0z$7^mp^L0_^#m!^n)3`-sFI)w8Q$*oV5gg2iEfgVe7p3q+ zn{PktB|v^?B)i7hT+V++nWgf*LbOxM2`qnu1I{pLm!4lr9SnNuPem|R9Qza0#c#+q zx^EMG-sX>H-BsC?&rM-7`yzcTnZ2F>sPzflRxudgQWLelSkN(Uhxc&CQ$64_}4v@z3C#JSB(sXt3_J1VqO3NTto=st~2$d zmzSpM3|{M+K7R3C4?6b?P92%Jq9UTQ+>{r|_6heeZ`FFUo#Brc*c2rObWod|CI^s+ zx#}jf8MZ#k+#?`AsMN-T;3N$Y_uSnVRe@x6w1a!SPQINcs`xWQ-yfe&)&g)t0>^HPtUFheD)E9fWcvqv?5a*I_h z329y_@-d%0Z&!mo3!i9yDo-fMX;eb@qy$=_HN*`by&)d8fEzHEBL;Kv5D|gvutxgO z#9G*8(m6&(Y2zQ916p`vG2BX;7Xzbh%@U^k8MBzNf z`MA9|Y<{K_<=t~($?})g-<*z0sF}<7$zFNc(rz+{&XNg6qJAU^AGhw)eFuGuUiWYd z&`JH^ovnC36DK1*wax}F${ONQ^Pt2L2q1JyDOYD2j`#->bejN`Kw3now9x_uoO6Xk zau@@2X@q7rAN|M+;IlqvBg+e0hp_JoO&c+ffchZcvPB~{q=l%MarY`un;hVtcXAFN zI!R#rhtMb{iSkCuW*InQVGiSHQ@hod$YflV^dI`sN}UF>3y16IRqQz09*XM^A>4$}6$m;n`pgvXj0`?wqb^)QGTw8hAfXzSjxh-mVh*^Uc0pW|dJrxED3;^tXl_`EPUD z#@|E)Xs`tVxFX|q@db0c><+I4t}tZ-*}^pZU{I7l_m>zm3dGdZSflsm>Nw zMDl!5#gTV@7FU=PVA`?*>4c ze|3fdLSG;lg9>FH1}7$Lyqe<3*-q|#aOhE`Yv_`dx=fpVWKUcmt^(V(QBD+Im+z!_ zjX!Xb>c|=x8k6+Nz14B`*T`>i9)L-W6PHZh{b8Lx3h z4%Q&?30y^NQ*`sBp0RCyQ0OrxWlKL@tiYBDvjmjHlu`A<11_Pyy)4oK9s~jQs8qCf z3!{5Zmr1vm7>?|wEMlMd$q7RgVZg;TzsMLIdgXIhoU^MU|sL%*RMspeDl zUFALBK_>t0j$gepGGh|%x&oUBn&FRzUikp=WGV_(5fa2Y;K0wr(vV_jjS8h<3YNqA zKqhbzB)`lSKO)m+5z_`y*1Da7G;c&Bf!s8^mgH`V7m4QSf#i}4Jy`YISy?mtwgr&F zz53~Zv1007fI8g18o-<1LZ4Kh;s#S^373NjKvd&Y5qA{(@oyQ;cVZk5mlV7W-=*a9 z@Mqf?5F2iLu0AW){?RXi^q82U8)pept-4oi`P+3b5;-V$U7UqcXXdsm&qOT#&~qV>V|U(rCt6W|~Ekv}`mn+YDso-sZF55EJ2UmT|U{~#GlS*U$Im2xl8FkP2_XxAMfWoO2)z)13bK^W88_RsN_MWh}hUU(0I{n2&n z%p7Ggf18Oqf9EgU{`R~oM)0%mA=7BZ%+PA>rvZi|b<7zVI=3ao6p|6-fx@GA2)Zex zMMuJ<4m+kY?mP2r8Y*o#!du~PxM=`9gFF|1%P!3e&C7^KUrKc_R+Y>-MuWm<1pK3atGq+vE4dI% zAVw@BYd&M9dHcI2a4M$LPl5gPqEusmD2vD*dJ3iP{rejlRb~4SUMcapw4?x@^}B5`CCil ztqK+U{rf2&q@<#=t7G7s&@p(hP0g2m#G-^dt>dH*6Vdad(1mML*r}=zH|m1gi^WWMx;}0$g&Tkv;JMl-D2Z{ zy%~q{c`5E-1wkFIw6IMPWkO5-oP@bIN;g?a#0;KguAR`(BKKtRk}GBVRJQB0kv~1) zb1>Cc{B)*StC)g^yE`i{KVAA7WB%z;x@&RxD@;Kuq&!9E?a4q3{Wllo6s6&|X(IsS zO^2%Q)ik8)DP^aq&X6CNQ&X!MW&36D7-^QylC2SvvPxNAK*B83aHU+UpN>V;;i8tQ z%P-QtI-K+y+R#rwo6!>HQJD;nLv$XpmSPH;$dvm`o%wl+`w`@JzOs3h@pjYsd`)wg zF5`^$|JT;F$3vO!@fRDlQPM?y6m@b*x$G)@tT2^Jnnf{D%ODn?p&@H2<7l+iMn$1% zid^=rdogY^7`b$EniY~k z7K5Rh(T?wWH-muie`VkGyAK+C_jRPYm-<9XU~np~Hmk#I&rhkHA1i&8sz_wUG1Z@w z$<=#Bnjm6J5#%&HB<~40GGD{M3JVK{hzALjqoeB`1goN{QbC<>DthiU6bEVB_%n^J zX0(|Ln6XLw)3a4FeVALf@onG@6w|^aB*g+LyzB##IiDk<$XZ z{1P6164A#6HBm<5a5@;(lsGyrlAhP~0M~jbB(tJ(Frmb^&ok(PXMDxvx4W%-dF}6f z^3o3L?+|3O{>+TY`JP+rlZaF#Kd%OBZSq5P#Ibwot&5Wul}HtIp04e?!)=)MeDN{Q z`PU*%ul3To2_Ta4_W8j*5Hu7Fv>sYoRK|02xz&`Q*~o$dTgJ>Qth3^HutC}Qt5eS2 z_$ABMNQ&#}topJ-=O2#KA6joD#&av1&iW~RbhY32))LqY>iJ_rDzFdHQ~6k-8$80@ zf)p>uvH8LJOOzML(=oNS3Ge4wMaD|Lrg|>IabVjW{CTk1Sb6pI+2<&t3 z&Ug8;DJp}QdJ)ZTws4ZlXLP|9q#AGIJAj@#$4a4msAbuh_=iO|G~>{anzor?;4#hSf^&Bp0lPF2AsKha*}Xr&<~*Tzx>dJ*a=SHd z*SAZbaC#xlp>$H zOTfG@IADZMe@{HpKRGCFd$5u(mkE^m1A zZPN}OIOa)g1^QO;eDG%1DNL~wEWtTP(Oux$Vm9BHgrTH7wg@#m7j;L%rs z{H&cVS6&e|>o8ScPHJemdjt${nVyy$rZ1ZqwM?+HY#3d>k7Mu{5n8XqvCLLAfm>SQ z-4l(3xY7>YB*mB|Li)af`fV23UbHNqN?drXCVt?7gS#?sWY~h2<0QN>yI_)tED#Ih zJF$!29ztrh7ioAbuwGO>ulCL6ZiJ~^{$$05p8PNF!kyT*(|)|Zw$ea$gw}CadZ7w2 z3Z9OJ^OS$_7*HOGR?M%_p&0`u6LL=RcsnjFv8MCVT!8hks9txdKOW?7aHB`chI%3= zHMYCYADgBRm>cT$hI4Elj$g9Tn@_dbNMzl|ZfM0n2PwU!ItvdjNCyF^RKyKIM1*B; za8l__WY8SIbN=qK_#0LTv!mE{WpsV9DpBPOMf%IdHInb z!b!LUPz;xIl8}iz$Rs_u%9qcZ$9FQaOj!9aouz1`xVkNY86t(@Z4H(Bw`s@CR+KZD z+q4k@OGfEE<5-+`vxgb*R@t|H=U5(mT$pnPuV!E);vFPje0+Y=YC%I0WTUDM+hxW! zhID=Q^xZfNk}7XeV@z`rb~2I)pB;pq;aZWi{k+%S)7M$tL96Zd1jfQ$=?}1X~l6LBJ@p#9PC^)8W?^#v*)x8F@sB0;Ee#^ z=?1qTlW>VZhp1&yIMh+Sk0<6!HJgE@3OdnOU-#7kO0QH9a6syfa3xMcaq5f1vxbT` zS4d-ZrPC-bbjzx64Y&seFHnVglKQ3mL9vb_&iQDLHzdjR=@gv>n#(1C67ntzI;kbs z$6Sf2@%J9nLD1&~5{R=fP;(552iPkDC#XtJq?0g8fWp0;ZVrYIZV=kD9xQC=E2}Qd zkYtY3h~^Wtn^Y2*a+bZM25{>(kQb#c9n+9;1fc_dF)C}#mnSTMK>2#Df|SN0jaDI{ zHpDqLC@n4_t)l(t?;T(w<~l(PG0K#;kt|z0NDj1vF!8IQw~Cavffj~01ty?Bq4RO5 zyWjx%dkkID3`yYxT}-a6a9K7EQidfclt3)f{6l=o1LR*Xkrx!-4Sh?{s|HP0A6Z%> z=|5RhAP&$Bkk!!|ki5-D=cV$+(iMVAvu-mcer>EQWEI0XNxvfy>Pn?>2LLWnxQnRt z+-Q<%(ynlz{`YH8E_<-QMX1_bh)tKXQ5 zs^HT9PZRdDOcioZT(gxu+vxh!U9K-d^~Jq1L=tL1CHC*7!z)z;Uag7$I!RhWcBxqd zM7Xj~=r0L+_9$n`*Ayz+^4BgpB)~(s>vA

<%/* Testimonial videos */%> -
+
@@ -48,7 +48,37 @@
- +
+
+
+ +
+
+
<%/* Endpoint ops block */%>
From 569259814319e1244668a9bf4b0fcee5cf196f18 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 15 Apr 2024 19:51:50 -0500 Subject: [PATCH 13/21] Website: update guaranteed locals in custom hook (#18296) Closes: #18295 Changes: - Updated the custom hook to set `res.locals` for HEAD requests to prevent 500 errors when a request is sent to a page that references `res.locals.me` --- website/api/hooks/custom/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js index 8b579bd690..9b2a129c82 100644 --- a/website/api/hooks/custom/index.js +++ b/website/api/hooks/custom/index.js @@ -123,9 +123,9 @@ will be disabled and/or hidden in the UI. var url = require('url'); - // First, if this is a GET request (and thus potentially a view), + // First, if this is a GET request (and thus potentially a view) or a HEAD request, // attach a couple of guaranteed locals. - if (req.method === 'GET') { + if (req.method === 'GET' || req.method === 'HEAD') { // The `_environment` local lets us do a little workaround to make Vue.js // run in "production mode" without unnecessarily involving complexities From b45079e261eb37378870c3990914fac6f88840bc Mon Sep 17 00:00:00 2001 From: Drew Baker <89049099+Drew-P-drawers@users.noreply.github.com> Date: Mon, 15 Apr 2024 21:11:30 -0400 Subject: [PATCH 14/21] Add lead follow up to communications (#17237) --- handbook/company/communications.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 0285a14c3a..5df2104ffa 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -71,6 +71,13 @@ Fleet uses advertising to spread awareness through a broader audience and foster ### Events It's important for Fleet to engage at [events](https://docs.google.com/spreadsheets/d/1YQXAX2Q_WnGkAwMYjMbQpV3nbCj7gOBbv7Y0u4twxzQ/edit#gid=1931288160). This provides an opportunity to directly engage with potential users and contributors, build relationships, gather feedback, and create a stronger sense of community and trust. +#### Event lead follow-up +Eventgoers are expecting a timely follow-up from Fleet based on the conversations that they had at the event. It is up to Head of Demand to make sure this process is followed. + +1. Once a list of badge scans is available, Fleeties that attended the event are to add any follow up notes that note buying situation, amount of endpoints, level of interest, and general talking points. +2. Within 3 business days of returning from the event, attendees will set up a debrief meeting with the demand team to discuss follow-up. +3. Demand will determine appropriate follow-up to each potential lead, and sales will be notified of actions needed immediately following. + ### Podcast Fleet has created the [ExpedITioners podcast](https://expeditioners.podbean.com/) to open discussions and help IT and security professionals get ahead of the curve and prepare themselves and their organizations for what lies ahead. From e7f61305a9a1122af1960965cf9a01921c864f3e Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Tue, 16 Apr 2024 06:37:58 -0300 Subject: [PATCH 15/21] New APIs to add/remove manual labels to/from a host (#18283) #16767 To create a manual label: ```sh cat labels.yml --- apiVersion: v1 kind: label spec: name: Manually Managed Example label_membership_type: manual hosts: - lucass-macbook-pro.local ``` To add/delete a manual label to/from a host: ``` curl -k -v -X POST -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/hosts/1/labels -d '{"labels": ["Manually Managed Example"]}' curl -k -v -X DELETE -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/hosts/1/labels -d '{"labels": ["Manually Managed Example"]}' ``` API draft changes: https://github.com/fleetdm/fleet/pull/16979/files Figma with error strings: https://www.figma.com/file/JiWoAiuHlkt76s3o3Uyz6h/%2316767-API-endpoint-for-updating-a-host's-manual-labels?type=design&node-id=2-130&mode=design&t=pxRPhrn6E1bOCrEd-0 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ~- [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.~ - [X] Added/updated tests - ~[ ] If database migrations are included, checked table schema to confirm autoupdate~ - ~For database migrations:~ - ~[ ] Checked schema for all modified table for columns that will auto-update timestamps during migration.~ - ~[ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.~ - ~[ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).~ - [x] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ --- changes/16767-updating-host-labels | 1 + docs/Using Fleet/manage-access.md | 2 + server/authz/policy.rego | 15 + server/datastore/mysql/labels.go | 33 ++ server/datastore/mysql/labels_test.go | 92 ++++++ server/fleet/authz.go | 2 + server/fleet/datastore.go | 7 + server/fleet/service.go | 15 + server/mock/datastore_mock.go | 24 ++ server/service/handler.go | 2 + server/service/hosts.go | 172 ++++++++++ server/service/integration_core_test.go | 300 ++++++++++++++++++ server/service/integration_enterprise_test.go | 45 +++ server/test/new_objects.go | 2 +- 14 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 changes/16767-updating-host-labels diff --git a/changes/16767-updating-host-labels b/changes/16767-updating-host-labels new file mode 100644 index 0000000000..32c1e635cc --- /dev/null +++ b/changes/16767-updating-host-labels @@ -0,0 +1 @@ +* Added endpoints to add/remove manual labels to/from a host. `POST /api/v1/fleet/hosts/:id/labels` and `DELETE /api/v1/fleet/hosts/:id/labels`. diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 802c7c36f5..fe6af40380 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -40,6 +40,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View a host by identifier | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | ✅ | | | Target hosts using labels | ✅ | ✅ | ✅ | ✅ | | +| Add/remove manual labels to/from hosts | | | ✅ | ✅ | ✅ | | Add and delete hosts | | | ✅ | ✅ | | | Transfer hosts between teams\* | | | ✅ | ✅ | ✅ | | Create, edit, and delete labels | | | ✅ | ✅ | ✅ | @@ -124,6 +125,7 @@ Users with access to multiple teams can be assigned different roles for each tea | View a host by identifier | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | ✅ | | | Target hosts using labels | ✅ | ✅ | ✅ | ✅ | | +| Add/remove manual labels to/from hosts | | | ✅ | ✅ | ✅ | | Add and delete hosts | | | ✅ | ✅ | | | Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | ✅ | | | Filter hosts by software | ✅ | ✅ | ✅ | ✅ | | diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 04860deb5c..c5d742fba5 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -13,6 +13,7 @@ import input.subject read := "read" list := "list" write := "write" +write_host_label := "write_host_label" # User specific actions write_role := "write_role" @@ -272,6 +273,13 @@ allow { action == write } +# Global admin, mantainers and gitops can write labels to hosts. +allow { + object.type == "host" + subject.global_role == [admin, maintainer, gitops][_] + action == write_host_label +} + # Allow read for global observer and observer_plus, selective_read for gitops. allow { object.type == "host" @@ -295,6 +303,13 @@ allow { action == write } +# Team admins, maintainers and gitops can write labels to hosts of their own team. +allow { + object.type == "host" + team_role(subject, object.team_id) == [admin, maintainer, gitops][_] + action == write_host_label +} + # Allow read for host health for global admin/maintainer, team admins, observer. allow { object.type == "host_health" diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 95cea4bc7d..d07290e23c 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -1015,3 +1015,36 @@ func (ds *Datastore) HostMemberOfAllLabels(ctx context.Context, hostID uint, lab return ok, nil } + +func (ds *Datastore) AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error { + if len(labelIDs) == 0 { + return nil + } + sql := `INSERT INTO label_membership (host_id, label_id) VALUES ` + sql += strings.Repeat(`(?, ?),`, len(labelIDs)) + sql = strings.TrimSuffix(sql, ",") + sql += ` ON DUPLICATE KEY UPDATE updated_at = NOW()` + args := make([]interface{}, 0, len(labelIDs)*2) + for _, labelID := range labelIDs { + args = append(args, hostID, labelID) + } + if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert into label_membership") + } + return nil +} + +func (ds *Datastore) RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error { + if len(labelIDs) == 0 { + return nil + } + sql := `DELETE FROM label_membership WHERE host_id = ? AND label_id IN (?)` + sql, args, err := sqlx.In(sql, hostID, labelIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build label_membership IN query") + } + if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete from label_membership") + } + return nil +} diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index ea9d455c9a..f5cb921593 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -68,6 +68,7 @@ func TestLabels(t *testing.T) { {"ListHostsInLabelDiskEncryptionStatus", testListHostsInLabelDiskEncryptionStatus}, {"HostMemberOfAllLabels", testHostMemberOfAllLabels}, {"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings}, + {"AddDeleteLabelsToFromHost", testAddDeleteLabelsToFromHost}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1429,3 +1430,94 @@ func testLabelsListHostsInLabelOSSettings(t *testing.T, db *Datastore) { checkHosts(t, hosts, []uint{h2.ID}) }) } + +func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) { + ctx := context.Background() + host1, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + Platform: "darwin", + }) + require.NoError(t, err) + host2, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("2"), + NodeKey: ptr.String("2"), + UUID: "2", + Hostname: "bar.local", + Platform: "windows", + }) + require.NoError(t, err) + + err = ds.AddLabelsToHost(ctx, host1.ID, nil) + require.NoError(t, err) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, nil) + require.NoError(t, err) + + label1, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label1", + Query: "SELECT 1;", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + label2, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label2", + Query: "SELECT 2;", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + + // Removing a label and multiple labels that the host is not a member of. + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + + // Adding and removing labels. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + getLabelUpdatedAt := func(updatedAt *time.Time) func(q sqlx.ExtContext) error { + return func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, updatedAt, `SELECT updated_at FROM label_membership WHERE host_id = ? AND label_id = ?`, host1.ID, label1.ID) + } + } + var labelUpdatedAt1 time.Time + ExecAdhocSQL(t, ds, getLabelUpdatedAt(&labelUpdatedAt1)) + time.Sleep(1 * time.Second) + // Add a label that the host is already member of. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID}) + require.NoError(t, err) + var labelUpdatedAt2 time.Time + ExecAdhocSQL(t, ds, getLabelUpdatedAt(&labelUpdatedAt2)) + require.True(t, labelUpdatedAt2.After(labelUpdatedAt1)) + labels, err := ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Len(t, labels, 1) + require.Equal(t, "label1", labels[0].Name) + labels2, err := ds.ListLabelsForHost(ctx, host2.ID) + require.NoError(t, err) + require.Empty(t, labels2) + + // Removing a label that the host is a member of + // and one that the host is not a member of. + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Empty(t, labels) + + // Add and remove multiple labels. + err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Len(t, labels, 2) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host1.ID) + require.NoError(t, err) + require.Empty(t, labels) +} diff --git a/server/fleet/authz.go b/server/fleet/authz.go index e94f902202..811aad2366 100644 --- a/server/fleet/authz.go +++ b/server/fleet/authz.go @@ -7,6 +7,8 @@ const ( ActionList = "list" // ActionWrite refers to writing (CRUD operations) an entity. ActionWrite = "write" + // ActionWriteHostLabel refers to writing labels on hosts. + ActionWriteHostLabel = "write_host_label" // // User specific actions diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0b6d83f228..61a41a4d63 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -176,6 +176,13 @@ type Datastore interface { // GetLabelSpec returns the spec for the named label. GetLabelSpec(ctx context.Context, name string) (*LabelSpec, error) + // AddLabelsToHost adds the given label IDs membership to the host. + // If a host is already a member of the label then this will update the row's updated_at. + AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error + // RemoveLabelsFromHost removes the given label IDs membership from the host. + // If a host is already not a member of a label then such label will be ignored. + RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error + NewLabel(ctx context.Context, Label *Label, opts ...OptionalArg) (*Label, error) SaveLabel(ctx context.Context, label *Label) (*Label, error) DeleteLabel(ctx context.Context, name string) error diff --git a/server/fleet/service.go b/server/fleet/service.go index e05fa0b376..ad87a4f786 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -388,6 +388,21 @@ type Service interface { HostEncryptionKey(ctx context.Context, id uint) (*HostDiskEncryptionKey, error) + // AddLabelsToHost adds the given label names to the host's label membership. + // + // If a host is already a member of one of the labels then this operation will only + // update the membership row update time. + // + // Returns an error if any of the labels does not exist or if any of the labels + // are not manual. + AddLabelsToHost(ctx context.Context, id uint, labels []string) error + // RemoveLabelsFromHost removes the given label names from the host's label membership. + // Labels that the host are already not a member of are ignored. + // + // Returns an error if any of the labels does not exist or if any of the labels + // are not manual. + RemoveLabelsFromHost(ctx context.Context, id uint, labels []string) error + // OSVersions returns a list of operating systems and associated host counts, which may be // filtered using the following optional criteria: team id, platform, or name and version. // Name cannot be used without version, and conversely, version cannot be used without name. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 2fb532210e..9b5bc54a79 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -129,6 +129,10 @@ type GetLabelSpecsFunc func(ctx context.Context) ([]*fleet.LabelSpec, error) type GetLabelSpecFunc func(ctx context.Context, name string) (*fleet.LabelSpec, error) +type AddLabelsToHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) error + +type RemoveLabelsFromHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) error + type NewLabelFunc func(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, error) @@ -1071,6 +1075,12 @@ type DataStore struct { GetLabelSpecFunc GetLabelSpecFunc GetLabelSpecFuncInvoked bool + AddLabelsToHostFunc AddLabelsToHostFunc + AddLabelsToHostFuncInvoked bool + + RemoveLabelsFromHostFunc RemoveLabelsFromHostFunc + RemoveLabelsFromHostFuncInvoked bool + NewLabelFunc NewLabelFunc NewLabelFuncInvoked bool @@ -2623,6 +2633,20 @@ func (s *DataStore) GetLabelSpec(ctx context.Context, name string) (*fleet.Label return s.GetLabelSpecFunc(ctx, name) } +func (s *DataStore) AddLabelsToHost(ctx context.Context, hostID uint, labelIDs []uint) error { + s.mu.Lock() + s.AddLabelsToHostFuncInvoked = true + s.mu.Unlock() + return s.AddLabelsToHostFunc(ctx, hostID, labelIDs) +} + +func (s *DataStore) RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error { + s.mu.Lock() + s.RemoveLabelsFromHostFuncInvoked = true + s.mu.Unlock() + return s.RemoveLabelsFromHostFunc(ctx, hostID, labelIDs) +} + func (s *DataStore) NewLabel(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { s.mu.Lock() s.NewLabelFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index ecd586e472..ddcb585839 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -396,6 +396,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/os_versions/{id:[0-9]+}", getOSVersionEndpoint, getOSVersionRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{query_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{}) + ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 14b7bb7119..90480ce322 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -2261,3 +2262,174 @@ func hostListOptionsFromFilters(filter *map[string]interface{}) (*fleet.HostList return &opt, labelID, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Host Labels +//////////////////////////////////////////////////////////////////////////////// + +type addLabelsToHostRequest struct { + ID uint `url:"id"` + Labels []string `json:"labels"` +} + +type addLabelsToHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r addLabelsToHostResponse) error() error { return r.Err } + +func addLabelsToHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*addLabelsToHostRequest) + if err := svc.AddLabelsToHost(ctx, req.ID, req.Labels); err != nil { + return addLabelsToHostResponse{Err: err}, nil + } + return addLabelsToHostResponse{}, nil +} + +func (svc *Service) AddLabelsToHost(ctx context.Context, id uint, labelNames []string) error { + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return ctxerr.Wrap(ctx, err, "load host") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil { + return ctxerr.Wrap(ctx, err) + } + + labelIDs, err := svc.validateLabelNames(ctx, "add", labelNames) + if err != nil { + return err + } + if len(labelIDs) == 0 { + return nil + } + + if err := svc.ds.AddLabelsToHost(ctx, host.ID, labelIDs); err != nil { + return ctxerr.Wrap(ctx, err, "add labels to host") + } + + return nil +} + +type removeLabelsFromHostRequest struct { + ID uint `url:"id"` + Labels []string `json:"labels"` +} + +type removeLabelsFromHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r removeLabelsFromHostResponse) error() error { return r.Err } + +func removeLabelsFromHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*removeLabelsFromHostRequest) + if err := svc.RemoveLabelsFromHost(ctx, req.ID, req.Labels); err != nil { + return removeLabelsFromHostResponse{Err: err}, nil + } + return removeLabelsFromHostResponse{}, nil +} + +func (svc *Service) RemoveLabelsFromHost(ctx context.Context, id uint, labelNames []string) error { + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return ctxerr.Wrap(ctx, err, "load host") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil { + return ctxerr.Wrap(ctx, err) + } + + labelIDs, err := svc.validateLabelNames(ctx, "remove", labelNames) + if err != nil { + return err + } + if len(labelIDs) == 0 { + return nil + } + + if err := svc.ds.RemoveLabelsFromHost(ctx, host.ID, labelIDs); err != nil { + return ctxerr.Wrap(ctx, err, "remove labels from host") + } + + return nil +} + +func (svc *Service) validateLabelNames(ctx context.Context, action string, labelNames []string) ([]uint, error) { + if len(labelNames) == 0 { + return nil, nil + } + + labelNames = server.RemoveDuplicatesFromSlice(labelNames) + + // Filter out empty label string. + for i, labelName := range labelNames { + if labelName == "" { + labelNames = append(labelNames[:i], labelNames[i+1:]...) + break + } + } + if len(labelNames) == 0 { + return nil, nil + } + + labels, err := svc.ds.LabelIDsByName(ctx, labelNames) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name") + } + + var labelsNotFound []string + for _, labelName := range labelNames { + if _, ok := labels[labelName]; !ok { + labelsNotFound = append(labelsNotFound, "\""+labelName+"\"") + } + } + + if len(labelsNotFound) > 0 { + sort.Slice(labelsNotFound, func(i, j int) bool { + // Ignore quotes to sort. + return labelsNotFound[i][1:len(labelsNotFound[i])-1] < labelsNotFound[j][1:len(labelsNotFound[j])-1] + }) + return nil, &fleet.BadRequestError{ + Message: fmt.Sprintf( + "Couldn't %s labels. Labels not found: %s. All labels must exist.", + action, + strings.Join(labelsNotFound, ", "), + ), + } + } + + var dynamicLabels []string + for labelName, labelID := range labels { + label, err := svc.ds.Label(ctx, labelID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "load label from id") + } + if label.LabelMembershipType != fleet.LabelMembershipTypeManual { + dynamicLabels = append(dynamicLabels, "\""+labelName+"\"") + } + } + + if len(dynamicLabels) > 0 { + sort.Slice(dynamicLabels, func(i, j int) bool { + // Ignore quotes to sort. + return dynamicLabels[i][1:len(dynamicLabels[i])-1] < dynamicLabels[j][1:len(dynamicLabels[j])-1] + }) + return nil, &fleet.BadRequestError{ + Message: fmt.Sprintf( + "Couldn't %s labels. Labels are dynamic: %s. Dynamic labels can not be assigned to hosts manually.", + action, + strings.Join(dynamicLabels, ", "), + ), + } + } + + labelIDs := make([]uint, 0, len(labels)) + for _, labelID := range labels { + labelIDs = append(labelIDs, labelID) + } + + return labelIDs, nil +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index d3d1e99db8..a5d7a6af93 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11010,3 +11010,303 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { require.Empty(t, listResp.Activities) require.Equal(t, &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, listResp.Meta) } + +func (s *integrationTestSuite) TestAddingRemovingManualLabels() { + t := s.T() + ctx := context.Background() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + newGlobalUserFunc := func(email string, globalRole string) *fleet.User { + user := &fleet.User{ + Name: email, + Email: email, + GlobalRole: &globalRole, + } + err = user.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + user, err = s.ds.NewUser(context.Background(), user) + require.NoError(t, err) + return user + } + newTeamUserFunc := func(email string, team *fleet.Team, teamRole string) *fleet.User { + user := &fleet.User{ + Name: email, + Email: email, + Teams: []fleet.UserTeam{ + { + Team: *team, + Role: teamRole, + }, + }, + } + err = user.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + user, err = s.ds.NewUser(context.Background(), user) + require.NoError(t, err) + return user + } + globalObserver := newGlobalUserFunc("global.observer@example.com", fleet.RoleObserver) + teamAdmin := newTeamUserFunc("team.admin@example.com", team1, fleet.RoleAdmin) + teamObserver := newTeamUserFunc("team.observer@example.com", team1, fleet.RoleObserver) + + newHostFunc := func(name string, teamID *uint) *fleet.Host { + host, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(name), + UUID: name, + Hostname: "foo.local." + name, + TeamID: teamID, + }) + require.NoError(t, err) + require.NotNil(t, host) + return host + } + host1 := newHostFunc("host1", nil) + host2 := newHostFunc("host2", nil) + teamHost2 := newHostFunc("teamHost2", &team1.ID) + + ls, err := s.ds.LabelIDsByName(ctx, []string{"All Hosts"}) + require.NoError(t, err) + require.Len(t, ls, 1) + allHostsLabelID, ok := ls["All Hosts"] + require.True(t, ok) + require.NotZero(t, allHostsLabelID) + + dynamicLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "dynamicLabel1", + Query: "SELECT 1;", + LabelMembershipType: fleet.LabelMembershipTypeDynamic, + }) + require.NoError(t, err) + manualLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel1", + Query: "SELECT 2;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + manualLabel2, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel2", + Query: "SELECT 3;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + + err = s.ds.RecordLabelQueryExecutions(context.Background(), host1, map[uint]*bool{allHostsLabelID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + getHostLabels := func(host *fleet.Host) []string { + var hostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + var labels []string + for _, label := range hostResp.Host.Labels { + labels = append(labels, label.Name) + } + return labels + } + + hostLabels1 := getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // No labels or empty labels is a no-op. + var addLabelsToHostResp addLabelsToHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), + json.RawMessage(`{}`), http.StatusOK, &addLabelsToHostResp, + ) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{}, + }, http.StatusOK, &addLabelsToHostResp) + var removeLabelsFromHostResp removeLabelsFromHostResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{}, + }, http.StatusOK, &removeLabelsFromHostResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{""}, + }, http.StatusOK, &addLabelsToHostResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"", ""}, + }, http.StatusOK, &addLabelsToHostResp) + + // A dynamic buitin label should fail to be added. + res := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"All Hosts"}, + }, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"All Hosts\". Dynamic labels can not be assigned to hosts manually.") + // An inexistent label should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"manualLabel2", "does not exist"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels not found: \"does not exist\". All labels must exist.") + // Multiple inexistent labels should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"manualLabel2", "does not exist", "does not exist 2"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels not found: \"does not exist\", \"does not exist 2\". All labels must exist.") + // A dynamic non-builtin label should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{dynamicLabel1.Name}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + // Multiple dynamic labels should fail to be added. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{"All Hosts", dynamicLabel1.Name}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't add labels. Labels are dynamic: \"All Hosts\", \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + + // A dynamic builtin label should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{"All Hosts"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels are dynamic: \"All Hosts\". Dynamic labels can not be assigned to hosts manually.") + // An inexistent label should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, "does not exist"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels not found: \"does not exist\". All labels must exist.") + // Multiple inexistent labels should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, "does not exist", "does not exist 2"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels not found: \"does not exist\", \"does not exist 2\". All labels must exist.") + // Multiple dynamic labels should fail to be deleted. + res = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel2.Name, dynamicLabel1.Name, "All Hosts"}, + }, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't remove labels. Labels are dynamic: \"All Hosts\", \"dynamicLabel1\". Dynamic labels can not be assigned to hosts manually.") + + // Add two manual labels to a host. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + // Add the same manual labels to a host should succeed. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 3) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + require.Equal(t, manualLabel2.Name, hostLabels1[2]) + hostLabels2 := getHostLabels(host2) + require.Empty(t, hostLabels2) + + // Remove the two manual labels from the host. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + // Remove the same manual labels from the host again. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // Add same label, should deduplicate. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 2) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + + // Adding an already added label should work. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 3) + require.Equal(t, "All Hosts", hostLabels1[0]) + require.Equal(t, manualLabel1.Name, hostLabels1[1]) + require.Equal(t, manualLabel2.Name, hostLabels1[2]) + + // Delete same label, should deduplicate. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + // Deleting a non-member label (manualLabel1) should work. + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + hostLabels1 = getHostLabels(host1) + require.Len(t, hostLabels1, 1) + require.Equal(t, "All Hosts", hostLabels1[0]) + + // Add to non-existent host + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", 999), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusNotFound, &addLabelsToHostResp) + // Delete from non-existent host + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", 999), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name, manualLabel2.Name}, + }, http.StatusNotFound, &removeLabelsFromHostResp) + + // Add labels to team host. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + + // A global observer should not be allowed to add/remove a label. + oldToken := s.token + s.token = s.getTestToken(globalObserver.Email, test.GoodPassword) + t.Cleanup(func() { + s.token = oldToken + }) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team observer should not be allowed to add/remove a label. + s.token = s.getTestToken(teamObserver.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team admin should not be allowed to add/remove a label for a global host. + s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", host1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + + // A team admin should be allowed to add/remove a label for a team host. + s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + teamHost2Labels := getHostLabels(teamHost2) + require.Len(t, teamHost2Labels, 1) + require.Equal(t, manualLabel1.Name, teamHost2Labels[0]) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", teamHost2.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + teamHost2Labels = getHostLabels(teamHost2) + require.Empty(t, teamHost2Labels) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 56272fa1b6..39d15e08cc 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4222,6 +4222,19 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { Name: "Zoo", }) require.NoError(t, err) + team1Host, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(t.Name() + "2"), + UUID: t.Name() + "2", + Hostname: strings.Replace(t.Name()+"zoo.local", "/", "_", -1), + TeamID: &t1.ID, + }) + require.NoError(t, err) + globalHost, err := s.ds.NewHost(ctx, &fleet.Host{ + NodeKey: ptr.String(t.Name() + "3"), + UUID: t.Name() + "3", + Hostname: strings.Replace(t.Name()+"global.local", "/", "_", -1), + }) + require.NoError(t, err) acr := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "webhook_settings": { @@ -4328,6 +4341,12 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { require.NoError(t, u3.SetPassword(test.GoodPassword, 10, 10)) _, err = s.ds.NewUser(context.Background(), u3) require.NoError(t, err) + manualLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{ + Name: "manualLabel1", + Query: "SELECT 2;", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) // // Start running permission tests with user gitops1. @@ -4407,6 +4426,16 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { require.True(t, acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.Enable) require.Equal(t, "https://foobar.example.com", acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.DestinationURL) + // Attempt to add/remove manual labels to/from a host. + var addLabelsToHostResp addLabelsToHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + var removeLabelsFromHostResp removeLabelsFromHostResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + // Attempt to run live queries synchronously, should fail. s.DoJSON("GET", "/api/latest/fleet/queries/run", runLiveQueryRequest{ HostIDs: []uint{h1.ID}, @@ -4779,6 +4808,22 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { // Attempt to remove a query from the team's schedule, should allow. s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", t1.ID, ttsqr.Scheduled.ID), deleteTeamScheduleRequest{}, http.StatusOK, &deleteTeamScheduleResponse{}) + // Attempt to add/remove a manual label from a team host, should allow. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusOK, &removeLabelsFromHostResp) + + // Attempt to add/remove a manual label from a global host, should not allow. + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), addLabelsToHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &addLabelsToHostResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), removeLabelsFromHostRequest{ + Labels: []string{manualLabel1.Name}, + }, http.StatusForbidden, &removeLabelsFromHostResp) + // Attempt to read the global schedule, should fail. s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusForbidden, &getGlobalScheduleResponse{}) diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 29963e0773..6bf4953320 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -112,7 +112,7 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) { Name: "All Hosts", Query: "select 1", LabelType: fleet.LabelTypeBuiltIn, - LabelMembershipType: fleet.LabelMembershipTypeManual, + LabelMembershipType: fleet.LabelMembershipTypeDynamic, }, { Name: "macOS", From a86da9f74b0bc78676ac29ae96c5375cd5a1324f Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Tue, 16 Apr 2024 08:39:34 -0300 Subject: [PATCH 16/21] Fix enroll request retry in osquery-perf (#18227) This was discovered by @xpkoala while performing a load test for the calendar backoff feature. Some enroll requests were failing due to enrolling hosts too fast (`-var loadtest_containers` from `0` to `40` at once), and osquery-perf had a bug in the enroll request where the `bytes.Buffer` was being incorrectly reused thus sending an empty body on the enroll retries, getting 400s from Fleet due to `Expected JSON Body`: ``` 2024/04/11 18:57:49 request failed: 400 ``` --- cmd/osquery-perf/agent.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 6a7af0964c..24227b84b0 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -1275,13 +1275,11 @@ func (a *agent) enroll(i int, onlyAlreadyEnrolled bool) error { return errors.New("not enrolled") } - var body bytes.Buffer - if err := a.templates.ExecuteTemplate(&body, "enroll", a); err != nil { - log.Println("execute template:", err) - return err - } - response := a.waitingDo(func() *http.Request { + var body bytes.Buffer + if err := a.templates.ExecuteTemplate(&body, "enroll", a); err != nil { + panic(err) + } request, err := http.NewRequest("POST", a.serverAddress+"/api/osquery/enroll", &body) if err != nil { panic(err) From f58947012b97d0932dea5a6ba5612a1e075ca88b Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Tue, 16 Apr 2024 07:52:03 -0500 Subject: [PATCH 17/21] In fleetctl debug db-locks and fleetctl debug db-innodb-status, fixed 500 errors (#18285) --- changes/17733-innodb-lock-waits | 1 + server/datastore/mysql/errors.go | 39 ++++++++++++ server/datastore/mysql/locks.go | 82 +++++++++++++++++++++---- server/datastore/mysql/mysql.go | 10 ++- server/service/client_debug.go | 4 ++ server/service/debug_handler.go | 17 ++++- server/service/integration_core_test.go | 11 ++++ server/service/testing_utils.go | 2 + 8 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 changes/17733-innodb-lock-waits diff --git a/changes/17733-innodb-lock-waits b/changes/17733-innodb-lock-waits new file mode 100644 index 0000000000..fc81532772 --- /dev/null +++ b/changes/17733-innodb-lock-waits @@ -0,0 +1 @@ +In fleetctl debug db-locks (GET debug/db/locks) and fleetctl debug db-innodb-status (GET debug/db/innodb-status), fixed 500 error in MySQL 8 and when DB user has insufficient privileges. diff --git a/server/datastore/mysql/errors.go b/server/datastore/mysql/errors.go index 24d7c371cc..d802f57c75 100644 --- a/server/datastore/mysql/errors.go +++ b/server/datastore/mysql/errors.go @@ -2,7 +2,9 @@ package mysql import ( "database/sql" + "errors" "fmt" + "net/http" "strconv" "github.com/VividCortex/mysqlerr" @@ -151,3 +153,40 @@ func isMySQLForeignKey(err error) bool { } return false } + +// accessDeniedError is an error that implements StatusCode and Internal +type accessDeniedError struct { + Message string + InternalErr error + Code int +} + +// Error returns the error message. +func (e *accessDeniedError) Error() string { + return e.Message +} + +func (e accessDeniedError) Internal() string { + if e.InternalErr == nil { + return "" + } + return e.InternalErr.Error() +} + +func (e *accessDeniedError) StatusCode() int { + if e.Code == 0 { + return http.StatusUnprocessableEntity + } + return e.Code +} + +func isMySQLAccessDenied(err error) bool { + err = ctxerr.Cause(err) + var mySQLErr *mysql.MySQLError + if errors.As( + err, &mySQLErr, + ) && (mySQLErr.Number == mysqlerr.ER_SPECIFIC_ACCESS_DENIED_ERROR || mySQLErr.Number == mysqlerr.ER_TABLEACCESS_DENIED_ERROR) { + return true + } + return false +} diff --git a/server/datastore/mysql/locks.go b/server/datastore/mysql/locks.go index 7014d555cf..2677d05c93 100644 --- a/server/datastore/mysql/locks.go +++ b/server/datastore/mysql/locks.go @@ -3,12 +3,15 @@ package mysql import ( "context" "database/sql" + "sync/atomic" "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) +var innodbLockWaitsTableExists atomic.Int64 // Initializes to 0. 0 means we haven't checked yet. + func (ds *Datastore) Lock(ctx context.Context, name string, owner string, expiration time.Duration) (bool, error) { lockObtainers := []func(context.Context, string, string, time.Duration) (sql.Result, error){ ds.extendLockIfAlreadyAcquired, @@ -60,24 +63,77 @@ func (ds *Datastore) Unlock(ctx context.Context, name string, owner string) erro } func (ds *Datastore) DBLocks(ctx context.Context) ([]*fleet.DBLock, error) { - stmt := ` - SELECT - r.trx_id waiting_trx_id, - r.trx_mysql_thread_id waiting_thread, - r.trx_query waiting_query, - b.trx_id blocking_trx_id, - b.trx_mysql_thread_id blocking_thread, - b.trx_query blocking_query - FROM information_schema.innodb_lock_waits w - INNER JOIN information_schema.innodb_trx b - ON b.trx_id = w.blocking_trx_id - INNER JOIN information_schema.innodb_trx r - ON r.trx_id = w.requesting_trx_id` + // information_schema.innodb_lock_waits has been deprecated in MySQL 8, so we need to check if it exists. + // We only need to check once. + localInnodbLockWaitsTableExists := innodbLockWaitsTableExists.Load() + if localInnodbLockWaitsTableExists == 0 { + var exists bool + existsStmt := ` + SELECT EXISTS (SELECT * + FROM information_schema.tables + WHERE table_schema = 'information_schema' + AND table_name = 'innodb_lock_waits')` + if err := ds.writer(ctx).GetContext(ctx, &exists, existsStmt); err != nil { + return nil, ctxerr.Wrap(ctx, err, "check for existence of innodb_lock_waits table") + } + if exists { + localInnodbLockWaitsTableExists = 1 + } else { + localInnodbLockWaitsTableExists = -1 + } + innodbLockWaitsTableExists.Store(localInnodbLockWaitsTableExists) + } + var stmt string + if localInnodbLockWaitsTableExists == 1 { + stmt = ` + SELECT + r.trx_id waiting_trx_id, + r.trx_mysql_thread_id waiting_thread, + r.trx_query waiting_query, + b.trx_id blocking_trx_id, + b.trx_mysql_thread_id blocking_thread, + b.trx_query blocking_query + FROM information_schema.innodb_lock_waits w + INNER JOIN information_schema.innodb_trx b + ON b.trx_id = w.blocking_trx_id + INNER JOIN information_schema.innodb_trx r + ON r.trx_id = w.requesting_trx_id` + } else { + // Mapping from information_schema.innodb_lock_waits to performance_schema.data_lock_waits columns: + // + // INNODB_LOCK_WAITS data_lock_waits + // ----------------- ---------------- + // REQUESTING_TRX_ID REQUESTING_ENGINE_TRANSACTION_ID + // REQUESTED_LOCK_ID REQUESTING_ENGINE_LOCK_ID + // BLOCKING_TRX_ID BLOCKING_ENGINE_TRANSACTION_ID + // BLOCKING_LOCK_ID BLOCKING_ENGINE_LOCK_ID + stmt = ` + SELECT + r.trx_id waiting_trx_id, + r.trx_mysql_thread_id waiting_thread, + r.trx_query waiting_query, + b.trx_id blocking_trx_id, + b.trx_mysql_thread_id blocking_thread, + b.trx_query blocking_query + FROM performance_schema.data_lock_waits w + INNER JOIN information_schema.innodb_trx b + ON b.trx_id = w.blocking_engine_transaction_id + INNER JOIN information_schema.innodb_trx r + ON r.trx_id = w.requesting_engine_transaction_id` + } var locks []*fleet.DBLock // Even though this is a Read, use the writer as we want the db locks from // the primary database (the read replica should have little to no trx locks). if err := ds.writer(ctx).SelectContext(ctx, &locks, stmt); err != nil { + // To read innodb tables, DB user must have PROCESS privilege + // This can be set by DB admin like: GRANT PROCESS,SELECT ON *.* TO 'fleet'@'%'; + if isMySQLAccessDenied(err) { + return nil, &accessDeniedError{ + Message: "select locking information: DB user must have global PROCESS and SELECT privilege", + InternalErr: err, + } + } return nil, ctxerr.Wrap(ctx, err, "select locking information") } return locks, nil diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 866f9d6d1d..39ca1f7082 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1177,7 +1177,15 @@ func (ds *Datastore) InnoDBStatus(ctx context.Context) (string, error) { // using the writer even when doing a read to get the data from the main db node err := ds.writer(ctx).GetContext(ctx, &status, "show engine innodb status") if err != nil { - return "", ctxerr.Wrap(ctx, err, "Getting innodb status") + // To read innodb tables, DB user must have PROCESS privilege + // This can be set by DB admin like: GRANT PROCESS,SELECT ON *.* TO 'fleet'@'%'; + if isMySQLAccessDenied(err) { + return "", &accessDeniedError{ + Message: "getting innodb status: DB user must have global PROCESS and SELECT privilege", + InternalErr: err, + } + } + return "", ctxerr.Wrap(ctx, err, "getting innodb status") } return status.Status, nil } diff --git a/server/service/client_debug.go b/server/service/client_debug.go index 882e472d98..37751d9ca0 100644 --- a/server/service/client_debug.go +++ b/server/service/client_debug.go @@ -16,6 +16,10 @@ func (c *Client) getRawBody(endpoint string) ([]byte, error) { defer response.Body.Close() if response.StatusCode != http.StatusOK { + body, err := io.ReadAll(response.Body) + if err == nil && len(body) > 0 { + return nil, fmt.Errorf("get %s received status %d: %s", endpoint, response.StatusCode, body) + } return nil, fmt.Errorf("get %s received status %d", endpoint, response.StatusCode) } diff --git a/server/service/debug_handler.go b/server/service/debug_handler.go index 68e93c3f09..6ee4644dda 100644 --- a/server/service/debug_handler.go +++ b/server/service/debug_handler.go @@ -3,16 +3,19 @@ package service import ( "context" "encoding/json" + "errors" "net/http" "net/http/pprof" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/token" "github.com/fleetdm/fleet/v4/server/errorstore" "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" + kithttp "github.com/go-kit/kit/transport/http" "github.com/gorilla/mux" ) @@ -49,9 +52,19 @@ func jsonHandler( jsonGenerator func(ctx context.Context) (interface{}, error), ) func(rw http.ResponseWriter, r *http.Request) { return func(rw http.ResponseWriter, r *http.Request) { - jsonData, err := jsonGenerator(r.Context()) + lc := &logging.LoggingContext{SkipUser: true} // The debug handler does not save the logged-in user. + ctx := logging.NewContext(kithttp.PopulateRequestContext(r.Context(), r), lc) + ctx = logging.WithStartTime(ctx) + jsonData, err := jsonGenerator(ctx) if err != nil { - level.Error(logger).Log("err", err) + lc.SetErrs(err) + lc.Log(ctx, logger) + var sce kithttp.StatusCoder + if errors.As(err, &sce) { + rw.WriteHeader(sce.StatusCode()) + _, _ = rw.Write([]byte(err.Error())) + return + } rw.WriteHeader(http.StatusInternalServerError) return } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a5d7a6af93..004a4ead8b 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11310,3 +11310,14 @@ func (s *integrationTestSuite) TestAddingRemovingManualLabels() { teamHost2Labels = getHostLabels(teamHost2) require.Empty(t, teamHost2Labels) } + +func (s *integrationTestSuite) TestDebugDB() { + t := s.T() + var response map[string]string + s.DoJSON("GET", "/debug/db/locks", nil, http.StatusOK, &response) + assert.Empty(t, response) + + var responseString string + s.DoJSON("GET", "/debug/db/innodb-status", nil, http.StatusOK, &responseString) + assert.Contains(t, responseString, "INNODB MONITOR OUTPUT") +} diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index f6895dc4de..1da7d58f40 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -350,6 +350,8 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ apiHandler := MakeHandler(svc, cfg, logger, limitStore, WithLoginRateLimit(throttled.PerMin(1000))) rootMux.Handle("/api/", apiHandler) + debugHandler := MakeDebugHandler(svc, cfg, logger, nil, ds) + rootMux.Handle("/debug/", debugHandler) server := httptest.NewUnstartedServer(rootMux) server.Config = cfg.Server.DefaultHTTPServer(ctx, rootMux) From 41ef4e3ac0d86a1166ba3309ff24c4372e1ff3c0 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:43:06 -0400 Subject: [PATCH 18/21] [Small released styling bugs] Fleet UI: Fix last activity's styling (#18279) --- changes/18060-host-activity-styling-bugs | 1 + .../cards/ActivityFeed/ActivityItem/_styles.scss | 2 +- .../details/cards/Activity/HostActivityItem/_styles.scss | 5 ++--- .../Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 changes/18060-host-activity-styling-bugs diff --git a/changes/18060-host-activity-styling-bugs b/changes/18060-host-activity-styling-bugs new file mode 100644 index 0000000000..fb157bbbaf --- /dev/null +++ b/changes/18060-host-activity-styling-bugs @@ -0,0 +1 @@ +- Styling bug fixes of host details page activities (Remove trailing dash line from last activity, Re-instate padding below last activity) diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/_styles.scss index 57438f0c7e..d701c91ad5 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/_styles.scss +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/_styles.scss @@ -57,7 +57,7 @@ .activity-item__dash { border-right: none; } - .activity-item__details { + .activity-details { padding-bottom: $pad-xxlarge; } } diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss index cfb2fcd282..d6048006a1 100644 --- a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss @@ -54,13 +54,12 @@ } &:last-child { - .past-activity__dash { + .host-activity-item__dash { border-right: none; } - .past-activity__details { + .host-activity-item__details-wrapper { padding-bottom: $pad-xxlarge; } } - } diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx index 94f1d547cd..c73b4b28e2 100644 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { IActivity, IActivityDetails } from "interfaces/activity"; +import { IActivity } from "interfaces/activity"; import { IActivitiesResponse } from "services/entities/activities"; // @ts-ignore From 6b3b15982721e51b4a7b11b90b63ba4c742943db Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:48:40 -0400 Subject: [PATCH 19/21] [Unreleased bug and Unit tests] Test all disabled dropdown options on host actions dropdown (#18231) --- .../HostActionsDropdown.tests.tsx | 348 +++++++++++++++++- .../HostActionsDropdown/helpers.tsx | 18 + .../PolicyForm/PolicyForm.tests.tsx | 26 +- 3 files changed, 381 insertions(+), 11 deletions(-) diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index cea478dbd7..0c41f4fcda 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -1,6 +1,6 @@ import React from "react"; import { noop } from "lodash"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { createCustomRenderer } from "test/test-utils"; import createMockUser from "__mocks__/userMock"; @@ -64,6 +64,126 @@ describe("Host Actions Dropdown", () => { expect(screen.getByText("Transfer")).toBeInTheDocument(); }); }); + describe("Query action", () => { + it("renders the Query action when the user is a global admin and the host is online", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.getByText("Query")).toBeInTheDocument(); + }); + + it("renders the Query action as disabled with a tooltip when a host is offline", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect( + screen.getByText("Query").parentElement?.parentElement?.parentElement + ).toHaveClass("is-disabled"); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Query")); + }); + + expect( + screen.getByText(/You can't query an offline host./i) + ).toBeInTheDocument(); + }); + }); + + it("renders the Query action as disabled when a host is locked", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + expect( + screen.getByText("Query").parentElement?.parentElement?.parentElement + ).toHaveClass("is-disabled"); + }); + + it("renders the Query action as disabled when a host is updating", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.getByText("Query").parentElement).toHaveClass( + "is-disabled" + ); + }); + }); it("renders the Show Disk Encryption Key action when on premium tier and we store the disk encryption key", async () => { const render = createCustomRenderer({ @@ -267,7 +387,7 @@ describe("Host Actions Dropdown", () => { debug(); - expect(screen.getByText("Turn off MDM").parentNode).toHaveClass( + expect(screen.getByText("Turn off MDM").parentElement).toHaveClass( "is-disabled" ); }); @@ -441,6 +561,48 @@ describe("Host Actions Dropdown", () => { expect(screen.getByText("Lock")).toBeInTheDocument(); }); + it("renders as disabled with a tooltip when scripts_enabled is set to false for windows/linux", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect( + screen.getByText("Lock").parentElement?.parentElement?.parentElement + ).toHaveClass("is-disabled"); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Lock")); + }); + + expect( + screen.getByText(/fleetd agent with --enable-scripts/i) + ).toBeInTheDocument(); + }); + }); + it("does not render when the host is not enrolled in mdm", async () => { const render = createCustomRenderer({ context: { @@ -653,6 +815,48 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); }); + + it("renders as disabled with a tooltip when scripts_enabled is set to false for windows/linux", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect( + screen.getByText("Unlock").parentElement?.parentElement?.parentElement + ).toHaveClass("is-disabled"); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Unlock")); + }); + + expect( + screen.getByText(/fleetd agent with --enable-scripts/i) + ).toBeInTheDocument(); + }); + }); }); describe("Wipe action", () => { @@ -747,5 +951,145 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); }); + + it("renders as disabled with a tooltip when scripts_enabled is set to false for linux", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect( + screen.getByText("Wipe").parentElement?.parentElement?.parentElement + ).toHaveClass("is-disabled"); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Wipe")); + }); + + expect( + screen.getByText(/fleetd agent with --enable-scripts/i) + ).toBeInTheDocument(); + }); + }); + }); + + describe("Run script action", () => { + it("renders the Run script action when scripts_enabled is set to true", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.getByText("Run script")).toBeInTheDocument(); + }); + + it("renders the Run script action as disabled with a tooltip when scripts_enabled is set to false", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect( + screen.getByText("Run script").parentElement?.parentElement + ?.parentElement + ).toHaveClass("is-disabled"); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Run script")); + }); + + expect( + screen.getByText(/fleetd agent with --enable-scripts/i) + ).toBeInTheDocument(); + }); + }); + + it("does not render the Run script action for ChromeOS", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Run script")).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 313e3e8032..2eb35cdf26 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -290,6 +290,7 @@ const setOptionsAsDisabled = ( isSandboxMode, hostMdmDeviceStatus, hostScriptsEnabled, + hostPlatform, }: IHostActionConfigOptions ) => { // Available tooltips for disabled options @@ -339,6 +340,23 @@ const setOptionsAsDisabled = ( optionsToDisable = optionsToDisable.concat( options.filter((option) => option.value === "runScript") ); + if (isLinuxLike(hostPlatform)) { + optionsToDisable = optionsToDisable.concat( + options.filter( + (option) => + option.value === "lock" || + option.value === "unlock" || + option.value === "wipe" + ) + ); + } + if (hostPlatform === "windows") { + optionsToDisable = optionsToDisable.concat( + options.filter( + (option) => option.value === "lock" || option.value === "unlock" + ) + ); + } } if (isSandboxMode) { optionsToDisable = optionsToDisable.concat( diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx index 145908bb0c..1970c20fae 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { createCustomRenderer } from "test/test-utils"; import createMockPolicy from "__mocks__/policyMock"; @@ -123,11 +123,15 @@ describe("PolicyForm - component", () => { expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Run" })).toBeDisabled(); - await user.hover(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByRole("button", { name: "Save" })); + }); - expect(container.querySelector("#save-policy-button")).toHaveTextContent( - /to save or run the policy/i - ); + expect(container.querySelector("#save-policy-button")).toHaveTextContent( + /to save or run the policy/i + ); + }); }); it("disables run button with tooltip when live queries are globally disabled", async () => { @@ -190,11 +194,15 @@ describe("PolicyForm - component", () => { expect(screen.getByRole("button", { name: "Run" })).toBeDisabled(); - await user.hover(screen.getByRole("button", { name: "Run" })); + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByRole("button", { name: "Run" })); + }); - expect(container.querySelector("#run-policy-button")).toHaveTextContent( - /live queries are disabled/i - ); + expect( + screen.getByText(/live queries are disabled/i) + ).toBeInTheDocument(); + }); }); // TODO: Consider testing save button is disabled for a sql error From 48036577eb033fcdb019aaba97cb0234378781ee Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:53:50 -0400 Subject: [PATCH 20/21] Interpret windows exit codes as a signed integer (#18282) #17695 The windows exit code is a 32-bit unsigned integer, but the command interpreter treats it like a signed integer. When a process is killed, it returns 0xFFFFFFFF (interpreted as -1). We convert the integer to an signed 32-bit integer to flip it to a -1 to match our expectations, and fit in our db column. https://en.wikipedia.org/wiki/Exit_status#Windows FIxed on both the client and server side. --- changes/18282-signed-windows-exit-code | 2 ++ orbit/changes/18282-signed-windows-exit-code | 1 + orbit/pkg/scripts/exec_windows.go | 9 ++++++++- server/datastore/mysql/scripts.go | 7 ++++++- server/datastore/mysql/scripts_test.go | 21 ++++++++++++++++++++ 5 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 changes/18282-signed-windows-exit-code create mode 100644 orbit/changes/18282-signed-windows-exit-code diff --git a/changes/18282-signed-windows-exit-code b/changes/18282-signed-windows-exit-code new file mode 100644 index 0000000000..b321bf4a03 --- /dev/null +++ b/changes/18282-signed-windows-exit-code @@ -0,0 +1,2 @@ +* Cast windows exit codes to signed integers to match windows interpreter +* Fix bug where some scripts got stuck in "upcoming" activity permanently diff --git a/orbit/changes/18282-signed-windows-exit-code b/orbit/changes/18282-signed-windows-exit-code new file mode 100644 index 0000000000..a0488663d4 --- /dev/null +++ b/orbit/changes/18282-signed-windows-exit-code @@ -0,0 +1 @@ +* Cast windows exit codes to signed integers to match windows interpreter diff --git a/orbit/pkg/scripts/exec_windows.go b/orbit/pkg/scripts/exec_windows.go index 08bcf358e5..a2619a7e6f 100644 --- a/orbit/pkg/scripts/exec_windows.go +++ b/orbit/pkg/scripts/exec_windows.go @@ -17,7 +17,14 @@ func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode in cmd.Dir = filepath.Dir(scriptPath) output, err = cmd.CombinedOutput() if cmd.ProcessState != nil { - exitCode = cmd.ProcessState.ExitCode() + // The windows exit code is a 32-bit unsigned integer, but the + // interpreter treats it like a signed integer. When a process + // is killed, it returns 0xFFFFFFFF (interpreted as -1). We + // convert the integer to an signed 32-bit integer to flip it + // to a -1 to match our expectations, and fit in our db column. + // + // https://en.wikipedia.org/wiki/Exit_status#Windows + exitCode = int(int32(cmd.ProcessState.ExitCode())) } return output, exitCode, err } diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index 49bf64232c..24d2730fb9 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -128,7 +128,12 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f res, err := tx.ExecContext(ctx, updStmt, output, result.Runtime, - result.ExitCode, + // Windows error codes are signed 32-bit integers, but are + // returned as unsigned integers by the windows API. The + // software that receives them is responsible for casting + // it to a 32-bit signed integer. + // See /orbit/pkg/scripts/exec_windows.go + int32(result.ExitCode), result.HostID, result.ExecutionID, ) diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 3ccf9f15d6..13c69f77ae 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -4,6 +4,7 @@ import ( "context" _ "embed" "fmt" + "math" "strings" "testing" "time" @@ -220,6 +221,26 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { pending, err = ds.ListPendingHostScriptExecutions(ctx, 1) require.NoError(t, err) require.Empty(t, pending, 0) + + // check that scripts with large unsigned error codes get + // converted to signed error codes + createdUnsignedScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ + HostID: 1, + ScriptContents: "echo", + UserID: &u.ID, + SyncRequest: true, + }) + require.NoError(t, err) + + unsignedScriptResult, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: 1, + ExecutionID: createdUnsignedScript.ExecutionID, + Output: "foo", + Runtime: 1, + ExitCode: math.MaxUint32, + }) + require.NoError(t, err) + require.EqualValues(t, -1, *unsignedScriptResult.ExitCode) } func testScripts(t *testing.T, ds *Datastore) { From ba6315f27ae64b75ec78555b2a1ba913a3d95cfd Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Tue, 16 Apr 2024 10:19:58 -0500 Subject: [PATCH 21/21] Setting DOGFOOD_WORKSTATIONS_CANARY_CALENDAR_WEBHOOK_URL (#18298) To fix failing gitops flow. Related to https://github.com/fleetdm/confidential/issues/6015 Needs DOGFOOD_WORKSTATIONS_CANARY_CALENDAR_WEBHOOK_URL GitHub secret if not set already. --- .github/workflows/dogfood-gitops.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index 61921b9a7e..3353e27d51 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -60,6 +60,7 @@ jobs: DOGFOOD_FAILING_POLICIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_FAILING_POLICIES_WEBHOOK_URL }} DOGFOOD_VULNERABILITIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_VULNERABILITIES_WEBHOOK_URL }} DOGFOOD_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_ENROLL_SECRET }} + DOGFOOD_WORKSTATIONS_CANARY_CALENDAR_WEBHOOK_URL: ${{ secrets.DOGFOOD_WORKSTATIONS_CANARY_CALENDAR_WEBHOOK_URL }} DOGFOOD_WORKSTATIONS_CANARY_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_CANARY_ENROLL_SECRET }} DOGFOOD_SERVERS_ENROLL_SECRET: ${{ secrets.DOGFOOD_SERVERS_ENROLL_SECRET }} DOGFOOD_SERVERS_CANARY_ENROLL_SECRET: ${{ secrets.DOGFOOD_SERVERS_CANARY_ENROLL_SECRET }}