Move NewActivity to activity bounded context (#39521)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #38536 

This PR moves all logic to create new activities to activity bounded
context.
The old service and ActivityModule methods are not facades that route to
the new activity bounded context. The facades will be removed in a
subsequent PR.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added webhook support for activity events with configurable endpoint
and enable/disable settings.
* Enhanced automation-initiated activity creation without requiring a
user context.
* Improved activity service architecture with centralized creation and
management.

* **Improvements**
* Refactored activity creation to use a dedicated service layer for
better separation of concerns.
* Added support for host-specific and automation-originated activities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky 2026-02-25 14:11:03 -06:00 committed by GitHub
parent 7817d93da1
commit 913a5904c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 1684 additions and 1287 deletions

View file

@ -0,0 +1 @@
* Refactored NewActivity functionality by moving it to the new activity bounded context.

View file

@ -1896,6 +1896,7 @@ func newAndroidMDMDeviceReconcilerSchedule(
ds fleet.Datastore,
logger *logging.Logger,
licenseKey string,
newActivityFn fleet.NewActivityFunc,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronMDMAndroidDeviceReconciler)
@ -1907,7 +1908,7 @@ func newAndroidMDMDeviceReconcilerSchedule(
ctx, name, instanceID, defaultInterval, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("reconcile_android_devices", func(ctx context.Context) error {
return android_svc.ReconcileAndroidDevices(ctx, ds, logger.SlogLogger(), licenseKey)
return android_svc.ReconcileAndroidDevices(ctx, ds, logger.SlogLogger(), licenseKey, newActivityFn)
}),
)

View file

@ -875,7 +875,7 @@ the way that the Fleet server works.
digiCertService := digicert.NewService(digicert.WithLogger(logger))
ctx = ctxerr.NewContext(ctx, eh)
activitiesModule := activities.NewActivityModule(ds, logger)
activitiesModule := activities.NewActivityModule()
config.MDM.AndroidAgent.Validate(initFatal)
androidSvc, err := android_service.NewService(
ctx,
@ -1040,6 +1040,9 @@ the way that the Fleet server works.
// Bootstrap activity bounded context (needed for cron schedules and HTTP routes)
activitySvc, activityRoutes := createActivityBoundedContext(svc, dbConns, logger.SlogLogger())
// Inject the activity bounded context into the main service and activity module
svc.SetActivityService(activitySvc)
activitiesModule.SetService(activitySvc)
// Perform a cleanup of cron_stats outside of the cronSchedules because the
// schedule package uses cron_stats entries to decide whether a schedule will
@ -1232,6 +1235,7 @@ the way that the Fleet server works.
ds,
logger,
config.License.Key,
svc.NewActivity,
)
}); err != nil {
initFatal(err, "failed to register mdm_android_device_reconciler schedule")
@ -1462,9 +1466,10 @@ the way that the Fleet server works.
license.IsPremium(),
logger,
redis_key_value.New(redisPool),
svc.NewActivity,
)
mdmCheckinAndCommandService.RegisterResultsHandler("InstalledApplicationList", service.NewInstalledApplicationListResultsHandler(ds, commander, logger, config.Server.VPPVerifyTimeout, config.Server.VPPVerifyRequestDelay))
mdmCheckinAndCommandService.RegisterResultsHandler("InstalledApplicationList", service.NewInstalledApplicationListResultsHandler(ds, commander, logger, config.Server.VPPVerifyTimeout, config.Server.VPPVerifyRequestDelay, svc.NewActivity))
mdmCheckinAndCommandService.RegisterResultsHandler(fleet.DeviceLocationCmdName, service.NewDeviceLocationResultsHandler(ds, commander, logger))
hasSCEPChallenge, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge})
@ -1808,6 +1813,7 @@ func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBCon
dbConns,
activityAuthorizer,
activityACLAdapter,
server.PostJSONWithTimeout,
logger,
)
// Create auth middleware for activity bounded context

View file

@ -271,12 +271,6 @@ func TestApplyTeamSpecs(t *testing.T) {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
require.Len(t, names, 1)
switch names[0] {
@ -698,12 +692,6 @@ func TestApplyAppConfig(t *testing.T) {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
if email == "admin1@example.com" {
return userRoleSpecList[0], nil
@ -920,12 +908,6 @@ func TestApplyAppConfigDryRunIssue(t *testing.T) {
return userRoleSpecList[1], nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
currentAppConfig := &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{OrgName: "Fleet"}, ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"},
}
@ -1295,12 +1277,6 @@ func TestApplyPolicies(t *testing.T) {
}
return nil, errors.New("unexpected team name!")
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
name := writeTmpYml(t, policySpec)
assert.Equal(t, "[+] applied 3 policies\n", RunAppForTest(t, []string{"apply", "-f", name}))
@ -1389,12 +1365,6 @@ func TestApplyAsGitOps(t *testing.T) {
ds.UserByIDFunc = func(ctx context.Context, id uint) (*fleet.User, error) {
return gitOps, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
currentAppConfig := &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{
OrgName: "Fleet",
@ -1982,12 +1952,6 @@ func TestApplyPacks(t *testing.T) {
ds.ListPacksFunc = func(ctx context.Context, opt fleet.PackListOptions) ([]*fleet.Pack, error) {
return nil, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
var appliedPacks []*fleet.PackSpec
ds.ApplyPackSpecsFunc = func(ctx context.Context, specs []*fleet.PackSpec) error {
appliedPacks = specs
@ -2034,12 +1998,6 @@ func TestApplyQueries(t *testing.T) {
appliedQueries = queries
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
name := writeTmpYml(t, queriesSpec)
assert.Equal(t, "[+] applied 1 query\n", RunAppForTest(t, []string{"apply", "-f", name}))
@ -2166,11 +2124,6 @@ func TestApplyMacosSetup(t *testing.T) {
teamsByID := map[uint]*fleet.Team{
tm1.ID: tm1,
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return job, nil
}
@ -3018,13 +2971,6 @@ func TestApplySpecs(t *testing.T) {
return nil
}
// activities
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
// app config
ds.ListUsersFunc = func(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) {
return userRoleSpecList, nil
@ -4318,10 +4264,6 @@ func TestApplyWindowsUpdates(t *testing.T) {
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
t.Run("with values", func(t *testing.T) {
// Reset call trackers
setOrUpdateCalls = nil

View file

@ -3,7 +3,6 @@ package fleetctl
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -57,12 +56,6 @@ func TestDeletePack(t *testing.T) {
Disabled: false,
}, true, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
name := writeTmpYml(t, `---
apiVersion: v1
kind: pack
@ -102,12 +95,6 @@ func TestDeleteQuery(t *testing.T) {
ObserverCanRun: false,
}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
name := writeTmpYml(t, `---
apiVersion: v1
kind: query

View file

@ -2576,11 +2576,6 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
if name == "team1" {
return team1, nil

View file

@ -102,11 +102,6 @@ func TestGitOpsBasicGlobalFree(t *testing.T) {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
return nil, 0, 0, nil, nil
@ -288,11 +283,6 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
return nil, 0, 0, nil, nil
@ -641,11 +631,6 @@ func TestGitOpsBasicTeam(t *testing.T) {
ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
@ -893,11 +878,6 @@ func TestGitOpsFullGlobal(t *testing.T) {
return scriptResponses, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
@ -1195,11 +1175,6 @@ func TestGitOpsFullTeam(t *testing.T) {
return scriptResponses, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
@ -1666,11 +1641,6 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) {
// Mock DefaultTeamConfig functions for No Team webhook settings
setupDefaultTeamConfigMocks(ds)
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
job.ID = 1
return job, nil
@ -2067,11 +2037,6 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) {
}
testing_utils.AddLabelMocks(ds)
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
job.ID = 1
return job, nil
@ -4545,9 +4510,6 @@ func TestGitOpsWindowsUpdates(t *testing.T) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}
@ -4951,9 +4913,6 @@ func TestGitOpsAppStoreAppAutoUpdate(t *testing.T) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}
@ -5281,9 +5240,6 @@ func TestGitOpsAppleOSUpdates(t *testing.T) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}
@ -5641,9 +5597,6 @@ func TestGitOpsWindowsOSUpdates(t *testing.T) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}

View file

@ -3,10 +3,11 @@ package fleetctl
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -25,7 +26,8 @@ func TestHostTransferFlagChecks(t *testing.T) {
}
func TestHostsTransferByHosts(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
opts := &service.TestServerOpts{}
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
@ -57,9 +59,7 @@ func TestHostsTransferByHosts(t *testing.T) {
return &fleet.TeamLite{ID: tid, Name: "team1"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
require.IsType(t, fleet.ActivityTypeTransferredHostsToTeam{}, activity)
return nil
}
@ -73,7 +73,7 @@ func TestHostsTransferByHosts(t *testing.T) {
assert.Equal(t, "", RunAppForTest(t, []string{"hosts", "transfer", "--team", "team1", "--hosts", "host1"}))
assert.True(t, ds.AddHostsToTeamFuncInvoked)
assert.True(t, ds.NewActivityFuncInvoked)
assert.True(t, opts.ActivityMock.NewActivityFuncInvoked)
// Now, transfer out of the team.
ds.AddHostsToTeamFunc = func(ctx context.Context, params *fleet.AddHostsToTeamParams) error {
@ -81,15 +81,16 @@ func TestHostsTransferByHosts(t *testing.T) {
assert.Equal(t, []uint{42}, params.HostIDs)
return nil
}
ds.NewActivityFuncInvoked = false
opts.ActivityMock.NewActivityFuncInvoked = false
ds.AddHostsToTeamFuncInvoked = false
assert.Equal(t, "", RunAppForTest(t, []string{"hosts", "transfer", "--team", "", "--hosts", "host1"}))
assert.True(t, ds.AddHostsToTeamFuncInvoked)
assert.True(t, ds.NewActivityFuncInvoked)
assert.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestHostsTransferByLabel(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
opts := &service.TestServerOpts{}
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
@ -132,9 +133,7 @@ func TestHostsTransferByLabel(t *testing.T) {
return &fleet.TeamLite{ID: tid, Name: "team1"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
require.IsType(t, fleet.ActivityTypeTransferredHostsToTeam{}, activity)
return nil
}
@ -144,7 +143,7 @@ func TestHostsTransferByLabel(t *testing.T) {
}
assert.Equal(t, "", RunAppForTest(t, []string{"hosts", "transfer", "--team", "team1", "--label", "label1"}))
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
assert.True(t, ds.AddHostsToTeamFuncInvoked)
// Now, transfer out of the team.
@ -153,15 +152,16 @@ func TestHostsTransferByLabel(t *testing.T) {
require.Equal(t, []uint{32, 12}, params.HostIDs)
return nil
}
ds.NewActivityFuncInvoked = false
opts.ActivityMock.NewActivityFuncInvoked = false
ds.AddHostsToTeamFuncInvoked = false
assert.Equal(t, "", RunAppForTest(t, []string{"hosts", "transfer", "--team", "", "--label", "label1"}))
assert.True(t, ds.AddHostsToTeamFuncInvoked)
assert.True(t, ds.NewActivityFuncInvoked)
assert.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestHostsTransferByStatus(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
opts := &service.TestServerOpts{}
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
@ -203,9 +203,7 @@ func TestHostsTransferByStatus(t *testing.T) {
return &fleet.TeamLite{ID: tid, Name: "team1"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
require.IsType(t, fleet.ActivityTypeTransferredHostsToTeam{}, activity)
return nil
}
@ -216,11 +214,12 @@ func TestHostsTransferByStatus(t *testing.T) {
assert.Equal(t, "", RunAppForTest(t,
[]string{"hosts", "transfer", "--team", "team1", "--status", "online"}))
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
opts := &service.TestServerOpts{}
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
@ -263,9 +262,7 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
return &fleet.TeamLite{ID: tid, Name: "team1"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
require.IsType(t, fleet.ActivityTypeTransferredHostsToTeam{}, activity)
return nil
}
@ -276,5 +273,5 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
assert.Equal(t, "", RunAppForTest(t,
[]string{"hosts", "transfer", "--team", "team1", "--status", "online", "--search_query", "somequery"}))
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}

View file

@ -1412,11 +1412,6 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[
return h.mdmInfo, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}

View file

@ -100,9 +100,6 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ID: 1,
}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) { return nil, nil }
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
@ -380,11 +377,6 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
return nil, 0, 0, nil, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) {
return nil, nil
}

View file

@ -10,17 +10,19 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserDelete(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
opts := &service.TestServerOpts{}
_, ds := testing_utils.RunServerWithMockedDS(t, opts)
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return &fleet.User{
@ -41,9 +43,7 @@ func TestUserDelete(t *testing.T) {
deletedUser = id
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
assert.Equal(t, fleet.ActivityTypeDeletedUser{}.ActivityName(), activity.ActivityName())
return nil
}
@ -63,11 +63,6 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
return nil, &notFoundError{}
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
if email == "bar@example.com" {
apiOnlyUser := &fleet.User{
@ -160,11 +155,6 @@ func TestCreateBulkUsers(t *testing.T) {
ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
return nil, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) {
team1 := &fleet.TeamSummary{
ID: 1,
@ -193,11 +183,6 @@ func TestCreateBulkUsers(t *testing.T) {
func TestDeleteBulkUsers(t *testing.T) {
_, ds := testing_utils.RunServerWithMockedDS(t)
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
csvFilePath := writeTmpCsv(t,
`Email
user11@example.com

View file

@ -8,7 +8,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/WatchBeam/clock"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
@ -104,6 +103,10 @@ func setupMockDatastorePremiumService(t testing.TB) (*mock.Store, *eeservice.Ser
if err != nil {
panic(err)
}
// Using a noop activity service since this test does not currently verify activity creation.
freeSvc.SetActivityService(&mock.MockNewActivityService{
NewActivityFunc: mock.NoopNewActivityFunc,
})
svc, err := eeservice.NewService(
freeSvc,
ds,
@ -187,9 +190,6 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
ds.NewActivityFunc = func(ctx context.Context, u *fleet.User, a fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
for _, team := range teamStore {
if team.Name == name {

View file

@ -16,18 +16,20 @@ import (
// FleetServiceAdapter provides access to Fleet service methods
// for data that the activity bounded context doesn't own.
type FleetServiceAdapter struct {
svc fleet.LookupService
svc fleet.ActivityLookupService
}
// NewFleetServiceAdapter creates a new adapter for the Fleet service.
func NewFleetServiceAdapter(svc fleet.LookupService) *FleetServiceAdapter {
func NewFleetServiceAdapter(svc fleet.ActivityLookupService) *FleetServiceAdapter {
return &FleetServiceAdapter{svc: svc}
}
// Ensure FleetServiceAdapter implements activity.UserProvider and activity.HostProvider
// Ensure FleetServiceAdapter implements the required interfaces
var (
_ activity.UserProvider = (*FleetServiceAdapter)(nil)
_ activity.HostProvider = (*FleetServiceAdapter)(nil)
_ activity.UserProvider = (*FleetServiceAdapter)(nil)
_ activity.HostProvider = (*FleetServiceAdapter)(nil)
_ activity.AppConfigProvider = (*FleetServiceAdapter)(nil)
_ activity.UpcomingActivityActivator = (*FleetServiceAdapter)(nil)
)
// UsersByIDs fetches users by their IDs from the Fleet service.
@ -94,3 +96,20 @@ func convertUser(u *fleet.UserSummary) *activity.User {
APIOnly: u.APIOnly,
}
}
// GetActivitiesWebhookConfig returns the webhook configuration for activities.
func (a *FleetServiceAdapter) GetActivitiesWebhookConfig(ctx context.Context) (*activity.ActivitiesWebhookSettings, error) {
settings, err := a.svc.GetActivitiesWebhookSettings(ctx)
if err != nil {
return nil, err
}
return &activity.ActivitiesWebhookSettings{
Enable: settings.Enable,
DestinationURL: settings.DestinationURL,
}, nil
}
// ActivateNextUpcomingActivity activates the next upcoming activity in the queue.
func (a *FleetServiceAdapter) ActivateNextUpcomingActivity(ctx context.Context, hostID uint, fromCompletedExecID string) error {
return a.svc.ActivateNextUpcomingActivityForHost(ctx, hostID, fromCompletedExecID)
}

View file

@ -0,0 +1,25 @@
package api
import (
"context"
)
// User represents user information for activity recording.
type User struct {
ID uint
Name string
Email string
Deleted bool
}
// ActivityDetails defines the interface for activity detail types.
type ActivityDetails interface {
ActivityName() string
}
// NewActivityService is for creating activities.
type NewActivityService interface {
// NewActivity creates a new activity record and fires the webhook if configured.
// user can be nil for automation-initiated activities.
NewActivity(ctx context.Context, user *User, activity ActivityDetails) error
}

View file

@ -8,4 +8,5 @@ type Service interface {
ListActivitiesService
ListHostPastActivitiesService
StreamActivitiesService
NewActivityService
}

View file

@ -20,10 +20,11 @@ func New(
dbConns *platform_mysql.DBConnections,
authorizer platform_authz.Authorizer,
providers activity.DataProviders,
webhookSendFn activity.WebhookSendFunc,
logger *slog.Logger,
) (api.Service, func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc) {
ds := mysql.NewDatastore(dbConns, logger)
svc := service.NewService(authorizer, ds, providers, logger)
svc := service.NewService(authorizer, ds, providers, webhookSendFn, logger)
routesFn := func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return service.GetRoutes(svc, authMiddleware)

View file

@ -0,0 +1,48 @@
package bootstrap
import (
"context"
"log/slog"
"time"
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/service"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
)
// NewForUnitTests creates an activity NewActivityService backed by a noop store (no database required).
func NewForUnitTests(
providers activity.DataProviders,
webhookSendFn activity.WebhookSendFunc,
logger *slog.Logger,
) api.NewActivityService {
return service.NewService(&noopAuthorizer{}, &noopStore{}, providers, webhookSendFn, logger)
}
// noopAuthorizer allows all actions (appropriate for unit tests).
type noopAuthorizer struct{}
func (a *noopAuthorizer) Authorize(_ context.Context, _ platform_authz.AuthzTyper, _ platform_authz.Action) error {
return nil
}
// noopStore is a datastore that does nothing (appropriate for unit tests that only need webhook behavior).
type noopStore struct{}
func (s *noopStore) ListActivities(_ context.Context, _ types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
return nil, nil, nil
}
func (s *noopStore) ListHostPastActivities(_ context.Context, _ uint, _ types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
return nil, nil, nil
}
func (s *noopStore) MarkActivitiesAsStreamed(_ context.Context, _ []uint) error {
return nil
}
func (s *noopStore) NewActivity(_ context.Context, _ *api.User, _ api.ActivityDetails, _ []byte, _ time.Time) error {
return nil
}

14
server/activity/config.go Normal file
View file

@ -0,0 +1,14 @@
package activity
import "context"
// ActivitiesWebhookSettings contains webhook settings for activities.
type ActivitiesWebhookSettings struct {
Enable bool
DestinationURL string
}
// AppConfigProvider provides access to app configuration needed by the activity bounded context.
type AppConfigProvider interface {
GetActivitiesWebhookConfig(ctx context.Context) (*ActivitiesWebhookSettings, error)
}

View file

@ -36,7 +36,6 @@ func TestListActivities(t *testing.T) {
{"MatchQuery", testListActivitiesMatchQuery},
{"Ordering", testListActivitiesOrdering},
{"CursorPagination", testListActivitiesCursorPagination},
{"HostOnlyExcluded", testListActivitiesHostOnlyExcluded},
{"HostPastActivities", testListHostPastActivities},
{"MarkActivitiesAsStreamed", testMarkActivitiesAsStreamed},
}
@ -309,25 +308,6 @@ func testListActivitiesCursorPagination(t *testing.T, env *testEnv) {
}
}
func testListActivitiesHostOnlyExcluded(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "testuser", "test@example.com")
env.InsertActivity(t, ptr.Uint(userID), "regular_activity", map[string]any{})
// Create host-only activity directly (should be excluded)
_, err := env.DB.ExecContext(ctx, `
INSERT INTO activities (user_id, user_name, user_email, activity_type, details, created_at, host_only, streamed)
VALUES (?, 'testuser', 'test@example.com', 'host_only_activity', '{}', NOW(), true, false)
`, userID)
require.NoError(t, err)
activities, _, err := env.ds.ListActivities(ctx, listOpts())
require.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "regular_activity", activities[0].Type)
}
// Test helpers for building ListOptions
type listOptsFunc func(*types.ListOptions)

View file

@ -0,0 +1,105 @@
package mysql
import (
"context"
"fmt"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
)
// NewActivity stores an activity record in the database.
// The webhook context key must be set in the context before calling this method.
func (ds *Datastore) NewActivity(
ctx context.Context, user *api.User, activity api.ActivityDetails, details []byte, createdAt time.Time,
) error {
ctx, span := tracer.Start(ctx, "activity.mysql.NewActivity")
defer span.End()
// Sanity check to ensure we processed activity webhook before storing the activity
processed, _ := ctx.Value(types.ActivityWebhookContextKey).(bool)
if !processed {
return ctxerr.New(
ctx, "activity webhook not processed. Please use svc.NewActivity instead of ds.NewActivity. This is a Fleet server bug.",
)
}
var userID *uint
var userName *string
var userEmail *string
var fleetInitiated bool
var hostOnly bool
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
// the installation.
if user.ID != 0 && !user.Deleted {
userID = &user.ID
}
userName = &user.Name
userEmail = &user.Email
}
if automatableActivity, ok := activity.(types.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
automationAuthor := types.ActivityAutomationAuthor
userName = &automationAuthor
fleetInitiated = true
}
if hostOnlyActivity, ok := activity.(types.ActivityHostOnly); ok && hostOnlyActivity.HostOnly() {
hostOnly = true
}
cols := []string{"fleet_initiated", "user_id", "user_name", "activity_type", "details", "created_at", "host_only"}
args := []any{
fleetInitiated,
userID,
userName,
activity.ActivityName(),
details,
createdAt,
hostOnly,
}
// For system/automated activities (user == nil), user_email defaults to empty (not null).
if userEmail != nil {
args = append(args, userEmail)
cols = append(cols, "user_email")
}
return platform_mysql.WithRetryTxx(ctx, ds.primary, func(tx sqlx.ExtContext) error {
const insertActStmt = `INSERT INTO activities (%s) VALUES (%s)`
sqlStmt := fmt.Sprintf(insertActStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?")
res, err := tx.ExecContext(ctx, sqlStmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "new activity")
}
// Insert into host_activities table if the activity is associated with hosts.
// This supposes a reasonable amount of hosts per activity, to revisit if we
// get in the 10K+.
if ah, ok := activity.(types.ActivityHosts); ok {
const insertActHostStmt = `INSERT INTO host_activities (host_id, activity_id) VALUES `
var sb strings.Builder
if hostIDs := ah.HostIDs(); len(hostIDs) > 0 {
sb.WriteString(insertActHostStmt)
actID, _ := res.LastInsertId()
for _, hid := range hostIDs {
sb.WriteString(fmt.Sprintf("(%d, %d),", hid, actID))
}
stmt := strings.TrimSuffix(sb.String(), ",")
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return ctxerr.Wrap(ctx, err, "insert host activity")
}
}
}
return nil
}, ds.logger)
}

View file

@ -0,0 +1,246 @@
package mysql
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/testutils"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewActivity(t *testing.T) {
tdb := testutils.SetupTestDB(t, "activity_new")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"WebhookContextKeyRequired", testNewActivityWebhookContextKeyRequired},
{"BasicWithUser", testNewActivityBasicWithUser},
{"NilUser", testNewActivityNilUser},
{"AutomationActivity", testNewActivityAutomation},
{"HostAssociation", testNewActivityHostAssociation},
{"HostOnly", testNewActivityHostOnly},
{"DeletedUser", testNewActivityDeletedUser},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
// dummyActivity is a minimal ActivityDetails implementation for testing.
type dummyActivity struct {
name string
details map[string]any
}
func (d dummyActivity) MarshalJSON() ([]byte, error) {
return json.Marshal(d.details)
}
func (d dummyActivity) ActivityName() string {
return d.name
}
// automatableActivity is a test activity that satisfies types.AutomatableActivity.
type automatableActivity struct {
dummyActivity
}
func (a automatableActivity) WasFromAutomation() bool {
return true
}
// hostActivity is a test activity that satisfies types.ActivityHosts.
type hostActivity struct {
dummyActivity
hostIDs []uint
}
func (h hostActivity) HostIDs() []uint {
return h.hostIDs
}
// hostOnlyActivity is a test activity that satisfies types.ActivityHostOnly.
type hostOnlyActivity struct {
dummyActivity
}
func (h hostOnlyActivity) HostOnly() bool {
return true
}
// webhookCtx returns a context with the webhook key set, as required by NewActivity.
func webhookCtx(t *testing.T) context.Context {
return context.WithValue(t.Context(), types.ActivityWebhookContextKey, true)
}
func testNewActivityWebhookContextKeyRequired(t *testing.T, env *testEnv) {
ctx := t.Context()
userID := env.InsertUser(t, "test", "test@example.com")
user := &api.User{ID: userID, Name: "test", Email: "test@example.com"}
activity := dummyActivity{name: "test", details: map[string]any{"key": "val"}}
detailsJSON, err := json.Marshal(activity)
require.NoError(t, err)
// No webhook context key set; should fail
assert.Error(t, env.ds.NewActivity(ctx, user, activity, detailsJSON, time.Now()))
// Wrong context value type; should fail
badCtx := context.WithValue(ctx, types.ActivityWebhookContextKey, "wrong")
assert.Error(t, env.ds.NewActivity(badCtx, user, activity, detailsJSON, time.Now()))
// Correct context key; should succeed
assert.NoError(t, env.ds.NewActivity(webhookCtx(t), user, activity, detailsJSON, time.Now()))
}
func testNewActivityBasicWithUser(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
userID := env.InsertUser(t, "fullname", "email@example.com")
user := &api.User{ID: userID, Name: "fullname", Email: "email@example.com"}
details := map[string]any{"detail": 1, "sometext": "aaa"}
detailsJSON, err := json.Marshal(details)
require.NoError(t, err)
require.NoError(t, env.ds.NewActivity(ctx, user, dummyActivity{name: "test_one", details: details}, detailsJSON, time.Now()))
require.NoError(t, env.ds.NewActivity(ctx, user, dummyActivity{name: "test_two", details: map[string]any{"detail": 2}}, mustJSON(t, map[string]any{"detail": 2}), time.Now()))
// Verify via listing (explicit ascending order for deterministic results)
activities, _, err := env.ds.ListActivities(t.Context(), listOpts(withPerPage(1), withOrder("id", api.OrderAscending)))
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Equal(t, "email@example.com", *activities[0].ActorEmail)
assert.Equal(t, "test_one", activities[0].Type)
// Second page
activities, _, err = env.ds.ListActivities(t.Context(), listOpts(withPerPage(1), withPage(1), withOrder("id", api.OrderAscending)))
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Equal(t, "test_two", activities[0].Type)
// All results
activities, _, err = env.ds.ListActivities(t.Context(), listOpts(withOrder("id", api.OrderAscending)))
require.NoError(t, err)
assert.Len(t, activities, 2)
}
func testNewActivityNilUser(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
details := map[string]any{"detail": 1}
detailsJSON := mustJSON(t, details)
require.NoError(t, env.ds.NewActivity(ctx, nil, dummyActivity{name: "system_task", details: details}, detailsJSON, time.Now()))
activities, _, err := env.ds.ListActivities(t.Context(), listOpts())
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Nil(t, activities[0].ActorID)
assert.Nil(t, activities[0].ActorFullName)
// user_email defaults to empty string (NOT NULL DEFAULT '') when no user is provided
require.NotNil(t, activities[0].ActorEmail)
assert.Empty(t, *activities[0].ActorEmail)
assert.Equal(t, "system_task", activities[0].Type)
}
func testNewActivityAutomation(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
activity := automatableActivity{
dummyActivity: dummyActivity{name: "auto_task", details: map[string]any{"automated": true}},
}
detailsJSON := mustJSON(t, activity.details)
require.NoError(t, env.ds.NewActivity(ctx, nil, activity, detailsJSON, time.Now()))
activities, _, err := env.ds.ListActivities(t.Context(), listOpts())
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Nil(t, activities[0].ActorID)
require.NotNil(t, activities[0].ActorFullName)
assert.Equal(t, types.ActivityAutomationAuthor, *activities[0].ActorFullName)
assert.True(t, activities[0].FleetInitiated)
}
func testNewActivityHostAssociation(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
userID := env.InsertUser(t, "testuser", "test@example.com")
user := &api.User{ID: userID, Name: "testuser", Email: "test@example.com"}
hostID := env.InsertHost(t, "h1.local", nil)
activity := hostActivity{
dummyActivity: dummyActivity{name: "ran_script", details: map[string]any{"host_id": float64(hostID)}},
hostIDs: []uint{hostID},
}
detailsJSON := mustJSON(t, activity.details)
require.NoError(t, env.ds.NewActivity(ctx, user, activity, detailsJSON, time.Now()))
// Verify the activity is linked to the host via host_activities
acts, _, err := env.ds.ListHostPastActivities(t.Context(), hostID, listOpts())
require.NoError(t, err)
require.Len(t, acts, 1)
assert.Equal(t, "ran_script", acts[0].Type)
require.NotNil(t, acts[0].ActorFullName)
assert.Equal(t, "testuser", *acts[0].ActorFullName)
require.NotNil(t, acts[0].ActorEmail)
assert.Equal(t, "test@example.com", *acts[0].ActorEmail)
}
func testNewActivityHostOnly(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
userID := env.InsertUser(t, "testuser", "test@example.com")
user := &api.User{ID: userID, Name: "testuser", Email: "test@example.com"}
// Create a regular activity and a host-only activity
regularDetails := mustJSON(t, map[string]any{"regular": true})
require.NoError(t, env.ds.NewActivity(ctx, user, dummyActivity{name: "regular", details: map[string]any{"regular": true}}, regularDetails, time.Now()))
hostOnlyDetails := mustJSON(t, map[string]any{"host_only": true})
require.NoError(t, env.ds.NewActivity(ctx, user, hostOnlyActivity{
dummyActivity: dummyActivity{name: "host_scoped", details: map[string]any{"host_only": true}},
}, hostOnlyDetails, time.Now()))
// ListActivities excludes host-only activities
activities, _, err := env.ds.ListActivities(t.Context(), listOpts())
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Equal(t, "regular", activities[0].Type)
}
func testNewActivityDeletedUser(t *testing.T, env *testEnv) {
ctx := webhookCtx(t)
// User with Deleted=true should have their name/email preserved but user_id set to NULL
user := &api.User{ID: 42, Name: "deleted_user", Email: "deleted@example.com", Deleted: true}
details := mustJSON(t, map[string]any{"detail": 1})
require.NoError(t, env.ds.NewActivity(ctx, user, dummyActivity{name: "post_delete", details: map[string]any{"detail": 1}}, details, time.Now()))
activities, _, err := env.ds.ListActivities(t.Context(), listOpts())
require.NoError(t, err)
require.Len(t, activities, 1)
// user_id should be NULL (deleted user), but name is preserved
assert.Nil(t, activities[0].ActorID)
require.NotNil(t, activities[0].ActorFullName)
assert.Equal(t, "deleted_user", *activities[0].ActorFullName)
require.NotNil(t, activities[0].ActorEmail)
assert.Equal(t, "deleted@example.com", *activities[0].ActorEmail)
}
// mustJSON marshals v and fails the test on error.
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return b
}

View file

@ -137,3 +137,7 @@ func (m *mockService) ListHostPastActivities(_ context.Context, _ uint, _ api.Li
func (m *mockService) StreamActivities(_ context.Context, _ api.JSONLogger) error {
panic("mockService.StreamActivities should not be called in validation tests")
}
func (m *mockService) NewActivity(_ context.Context, _ *api.User, _ api.ActivityDetails) error {
panic("mockService.NewActivity should not be called in validation tests")
}

View file

@ -0,0 +1,150 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platformhttp "github.com/fleetdm/fleet/v4/server/platform/http"
kithttp "github.com/go-kit/kit/transport/http"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// webhookPayload is the payload sent to the activities webhook.
type webhookPayload struct {
Timestamp time.Time `json:"timestamp"`
ActorFullName *string `json:"actor_full_name"`
ActorID *uint `json:"actor_id"`
ActorEmail *string `json:"actor_email"`
Type string `json:"type"`
Details *json.RawMessage `json:"details"`
}
// NewActivity creates a new activity record and fires the webhook if configured.
func (s *Service) NewActivity(ctx context.Context, user *api.User, activity api.ActivityDetails) error {
detailsBytes, err := json.Marshal(activity)
if err != nil {
return ctxerr.Wrap(ctx, err, "marshaling activity details")
}
// Duplicate JSON keys so that stored activity details include both the
// old and new field names (e.g. team_id and fleet_id).
if rules := eu.ExtractAliasRules(activity); len(rules) > 0 {
detailsBytes = eu.DuplicateJSONKeys(detailsBytes, rules, eu.DuplicateJSONKeysOpts{Compact: true})
}
timestamp := time.Now()
// Fire webhook if enabled
webhookConfig, err := s.providers.GetActivitiesWebhookConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get activities webhook config")
}
if webhookConfig != nil && webhookConfig.Enable {
s.fireActivityWebhook(ctx, user, activity, detailsBytes, timestamp, webhookConfig.DestinationURL)
}
// Activate the next upcoming activity if requested by the activity type.
// This is done before storing to avoid holding a DB transaction open during
// potentially slow operations.
if aa, ok := activity.(types.ActivityActivator); ok && aa.MustActivateNextUpcomingActivity() {
hostID, cmdUUID := aa.ActivateNextUpcomingActivityArgs()
if err := s.providers.ActivateNextUpcomingActivity(ctx, hostID, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "activate next upcoming activity")
}
}
// Mark context as webhook processed
ctx = context.WithValue(ctx, types.ActivityWebhookContextKey, true)
return s.store.NewActivity(ctx, user, activity, detailsBytes, timestamp)
}
// fireActivityWebhook sends the activity to the configured webhook URL asynchronously.
// It uses exponential backoff with a max elapsed time of 30 minutes for retries.
func (s *Service) fireActivityWebhook(
ctx context.Context, user *api.User, activity api.ActivityDetails,
detailsBytes []byte, timestamp time.Time, webhookURL string,
) {
var userID *uint
var userName *string
var userEmail *string
activityType := activity.ActivityName()
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
// the installation.
if user.ID != 0 && !user.Deleted {
userID = &user.ID
}
userName = &user.Name
userEmail = &user.Email
} else if automatableActivity, ok := activity.(types.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
automationAuthor := types.ActivityAutomationAuthor
userName = &automationAuthor
}
// Capture the parent span for linking before launching the goroutine.
parentSpanCtx := trace.SpanContextFromContext(ctx)
go func() {
// Create a root span for this async webhook delivery, linked back to the
// originating request so traces can be correlated.
var linkOpts []trace.SpanStartOption
if parentSpanCtx.IsValid() {
linkOpts = append(linkOpts, trace.WithLinks(trace.Link{SpanContext: parentSpanCtx}))
}
spanCtx, span := tracer.Start(
context.Background(), "activity.webhook",
append(linkOpts,
trace.WithNewRoot(),
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(attribute.String("activity.type", activityType)),
)...,
)
defer span.End()
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 30 * time.Minute
err := backoff.Retry(
func() error {
if err := s.webhookSendFn(
spanCtx, webhookURL, &webhookPayload{
Timestamp: timestamp,
ActorFullName: userName,
ActorID: userID,
ActorEmail: userEmail,
Type: activityType,
Details: (*json.RawMessage)(&detailsBytes),
},
); err != nil {
var statusCoder kithttp.StatusCoder
if errors.As(err, &statusCoder) && statusCoder.StatusCode() == http.StatusTooManyRequests {
s.logger.DebugContext(spanCtx, "fire activity webhook", slog.String("err", err.Error()))
return err
}
return backoff.Permanent(err)
}
return nil
}, retryStrategy,
)
if err != nil {
maskedErr := platformhttp.MaskURLError(err)
span.RecordError(maskedErr)
s.logger.ErrorContext(spanCtx,
fmt.Sprintf("fire activity webhook to %s", platformhttp.MaskSecretURLParams(webhookURL)),
slog.String("err", maskedErr.Error()),
)
}
}()
}

View file

@ -0,0 +1,215 @@
package service
import (
"context"
"encoding/json"
"log/slog"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newActivityMockDatastore captures calls to NewActivity for assertions.
type newActivityMockDatastore struct {
mockDatastore
newActivityCalled bool
lastUser *api.User
lastActivity api.ActivityDetails
lastDetails []byte
lastCreatedAt time.Time
lastCtx context.Context
newActivityErr error
}
func (m *newActivityMockDatastore) NewActivity(ctx context.Context, user *api.User, act api.ActivityDetails, details []byte, createdAt time.Time) error {
m.newActivityCalled = true
m.lastUser = user
m.lastActivity = act
m.lastDetails = details
m.lastCreatedAt = createdAt
m.lastCtx = ctx
return m.newActivityErr
}
// newActivityMockProviders extends mockDataProviders with call tracking for ActivateNextUpcomingActivity.
type newActivityMockProviders struct {
mockDataProviders
activateCalled bool
lastHostID uint
lastCmdUUID string
activateErr error
}
func (m *newActivityMockProviders) ActivateNextUpcomingActivity(ctx context.Context, hostID uint, cmdUUID string) error {
m.activateCalled = true
m.lastHostID = hostID
m.lastCmdUUID = cmdUUID
return m.activateErr
}
// Test activity types
type simpleActivity struct {
Name string `json:"name"`
}
func (a simpleActivity) ActivityName() string { return "simple_test" }
type aliasedActivity struct {
TeamID uint `json:"team_id" renameto:"fleet_id"`
}
func (a aliasedActivity) ActivityName() string { return "aliased_test" }
type activatorActivity struct {
simpleActivity
hostID uint
cmdUUID string
}
func (a activatorActivity) MustActivateNextUpcomingActivity() bool { return true }
func (a activatorActivity) ActivateNextUpcomingActivityArgs() (uint, string) {
return a.hostID, a.cmdUUID
}
func newTestService(ds types.Datastore, providers activity.DataProviders) *Service {
noopWebhookSend := func(_ context.Context, _ string, _ any) error { return nil }
return NewService(&mockAuthorizer{}, ds, providers, noopWebhookSend, slog.New(slog.DiscardHandler))
}
func TestNewActivityStoresWithWebhookContextKey(t *testing.T) {
t.Parallel()
ds := &newActivityMockDatastore{}
providers := &newActivityMockProviders{
mockDataProviders: mockDataProviders{
mockUserProvider: &mockUserProvider{},
mockHostProvider: &mockHostProvider{},
},
}
svc := newTestService(ds, providers)
user := &api.User{ID: 1, Name: "test", Email: "test@example.com"}
err := svc.NewActivity(t.Context(), user, simpleActivity{Name: "hello"})
require.NoError(t, err)
// Verify store was called
require.True(t, ds.newActivityCalled)
// Verify webhook context key was set
processed, ok := ds.lastCtx.Value(types.ActivityWebhookContextKey).(bool)
require.True(t, ok, "webhook context key should be set")
assert.True(t, processed)
// Verify user was passed through
require.NotNil(t, ds.lastUser)
assert.Equal(t, uint(1), ds.lastUser.ID)
assert.Equal(t, "test", ds.lastUser.Name)
assert.Equal(t, "test@example.com", ds.lastUser.Email)
// Verify details were marshaled
var details map[string]string
require.NoError(t, json.Unmarshal(ds.lastDetails, &details))
assert.Equal(t, "hello", details["name"])
// Verify timestamp is reasonable
assert.WithinDuration(t, time.Now(), ds.lastCreatedAt, 2*time.Second)
}
func TestNewActivityDuplicatesAliasedJSONKeys(t *testing.T) {
t.Parallel()
ds := &newActivityMockDatastore{}
providers := &newActivityMockProviders{
mockDataProviders: mockDataProviders{
mockUserProvider: &mockUserProvider{},
mockHostProvider: &mockHostProvider{},
},
}
svc := newTestService(ds, providers)
err := svc.NewActivity(t.Context(), nil, aliasedActivity{TeamID: 42})
require.NoError(t, err)
require.True(t, ds.newActivityCalled)
// Details should contain both team_id and fleet_id
var details map[string]any
require.NoError(t, json.Unmarshal(ds.lastDetails, &details))
assert.Equal(t, float64(42), details["team_id"])
assert.Equal(t, float64(42), details["fleet_id"])
}
func TestNewActivityCallsActivator(t *testing.T) {
t.Parallel()
ds := &newActivityMockDatastore{}
providers := &newActivityMockProviders{
mockDataProviders: mockDataProviders{
mockUserProvider: &mockUserProvider{},
mockHostProvider: &mockHostProvider{},
},
}
svc := newTestService(ds, providers)
act := activatorActivity{
simpleActivity: simpleActivity{Name: "install"},
hostID: 99,
cmdUUID: "cmd-abc",
}
err := svc.NewActivity(t.Context(), nil, act)
require.NoError(t, err)
// Verify activator was called with correct args
require.True(t, providers.activateCalled)
assert.Equal(t, uint(99), providers.lastHostID)
assert.Equal(t, "cmd-abc", providers.lastCmdUUID)
// Verify store was also called
require.True(t, ds.newActivityCalled)
}
func TestNewActivityActivatorErrorPreventsStore(t *testing.T) {
t.Parallel()
ds := &newActivityMockDatastore{}
providers := &newActivityMockProviders{
mockDataProviders: mockDataProviders{
mockUserProvider: &mockUserProvider{},
mockHostProvider: &mockHostProvider{},
},
activateErr: assert.AnError,
}
svc := newTestService(ds, providers)
act := activatorActivity{
simpleActivity: simpleActivity{Name: "install"},
hostID: 99,
cmdUUID: "cmd-abc",
}
err := svc.NewActivity(t.Context(), nil, act)
require.Error(t, err)
// Activator was called but store should NOT have been called
require.True(t, providers.activateCalled)
assert.False(t, ds.newActivityCalled)
}
func TestNewActivityNilUser(t *testing.T) {
t.Parallel()
ds := &newActivityMockDatastore{}
providers := &newActivityMockProviders{
mockDataProviders: mockDataProviders{
mockUserProvider: &mockUserProvider{},
mockHostProvider: &mockHostProvider{},
},
}
svc := newTestService(ds, providers)
err := svc.NewActivity(t.Context(), nil, simpleActivity{Name: "system"})
require.NoError(t, err)
require.True(t, ds.newActivityCalled)
assert.Nil(t, ds.lastUser)
}

View file

@ -16,8 +16,11 @@ import (
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/hashicorp/go-multierror"
"go.opentelemetry.io/otel"
)
var tracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/activity/internal/service")
// streamBatchSize is the number of activities to fetch per batch when streaming.
const streamBatchSize uint = 500
@ -43,19 +46,27 @@ func applyListOptionsDefaults(opt *api.ListOptions, defaultOrderKey string) {
// Service is the activity bounded context service implementation.
type Service struct {
authz platform_authz.Authorizer
store types.Datastore
providers activity.DataProviders
logger *slog.Logger
authz platform_authz.Authorizer
store types.Datastore
providers activity.DataProviders
webhookSendFn activity.WebhookSendFunc
logger *slog.Logger
}
// NewService creates a new activity service.
func NewService(authz platform_authz.Authorizer, store types.Datastore, providers activity.DataProviders, logger *slog.Logger) *Service {
func NewService(
authz platform_authz.Authorizer,
store types.Datastore,
providers activity.DataProviders,
webhookSendFn activity.WebhookSendFunc,
logger *slog.Logger,
) *Service {
return &Service{
authz: authz,
store: store,
providers: providers,
logger: logger,
authz: authz,
store: store,
providers: providers,
webhookSendFn: webhookSendFn,
logger: logger,
}
}

View file

@ -7,6 +7,7 @@ import (
"log/slog"
"strconv"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/activity"
"github.com/fleetdm/fleet/v4/server/activity/api"
@ -23,7 +24,8 @@ var (
janeUser = &activity.User{ID: 200, Name: "Jane Smith", Email: "jane@example.com", Gravatar: "gravatar2", APIOnly: true}
)
// Mock implementations
// Bare-bones mocks for testing service logic in isolation.
// For richer mocks used in integration tests, see tests/mocks_test.go.
type mockAuthorizer struct {
authErr error
@ -58,6 +60,10 @@ func (m *mockDatastore) MarkActivitiesAsStreamed(ctx context.Context, activityID
return nil
}
func (m *mockDatastore) NewActivity(ctx context.Context, user *api.User, activity api.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
type mockUserProvider struct {
users []*activity.User
listUsersErr error
@ -86,10 +92,20 @@ func (m *mockHostProvider) GetHostLite(ctx context.Context, hostID uint) (*activ
return m.host, m.err
}
// mockDataProviders combines user and host providers for testing.
// mockDataProviders combines all provider interfaces for testing.
type mockDataProviders struct {
*mockUserProvider
*mockHostProvider
webhookConfig *activity.ActivitiesWebhookSettings
webhookErr error
}
func (m *mockDataProviders) GetActivitiesWebhookConfig(ctx context.Context) (*activity.ActivitiesWebhookSettings, error) {
return m.webhookConfig, m.webhookErr
}
func (m *mockDataProviders) ActivateNextUpcomingActivity(ctx context.Context, hostID uint, fromCompletedExecID string) error {
return nil
}
// testSetup holds test dependencies with pre-configured mocks
@ -114,7 +130,8 @@ func setupTest(opts ...func(*testSetup)) *testSetup {
for _, opt := range opts {
opt(ts)
}
ts.svc = NewService(ts.authz, ts.ds, ts.providers, slog.New(slog.DiscardHandler))
noopWebhookSend := func(_ context.Context, _ string, _ any) error { return nil }
ts.svc = NewService(ts.authz, ts.ds, ts.providers, noopWebhookSend, slog.New(slog.DiscardHandler))
return ts
}
@ -219,6 +236,36 @@ func TestListActivitiesWithUserEnrichment(t *testing.T) {
assert.Nil(t, activities[2].ActorAPIOnly)
}
func TestListActivitiesDeletedUserFallsBackToStoredName(t *testing.T) {
t.Parallel()
ctx := t.Context()
storedName := "original_name"
storedEmail := "original@example.com"
deletedUserID := uint(999)
ts := setupTest(
withActivities([]*api.Activity{
{ID: 1, Type: "test_activity", ActorID: ptr.Uint(deletedUserID), ActorFullName: &storedName, ActorEmail: &storedEmail},
}),
// UsersByIDs returns nothing for the deleted user
withUsers(nil),
)
activities, _, err := ts.svc.ListActivities(ctx, api.ListOptions{})
require.NoError(t, err)
require.Len(t, activities, 1)
// Enrichment found no user, so the stored values from the DB are preserved
require.NotNil(t, activities[0].ActorFullName)
assert.Equal(t, "original_name", *activities[0].ActorFullName)
require.NotNil(t, activities[0].ActorEmail)
assert.Equal(t, "original@example.com", *activities[0].ActorEmail)
// Gravatar and API-only are not stored in the activity table, so they remain nil
assert.Nil(t, activities[0].ActorGravatar)
assert.Nil(t, activities[0].ActorAPIOnly)
}
// TestListActivitiesWithMatchQuery verifies that user search queries are properly
// translated into user ID filters for the datastore.
//
@ -467,6 +514,10 @@ func (m *mockStreamingDatastore) MarkActivitiesAsStreamed(ctx context.Context, a
return nil
}
func (m *mockStreamingDatastore) NewActivity(ctx context.Context, user *api.User, activity api.ActivityDetails, details []byte, createdAt time.Time) error {
panic("not implemented")
}
func newTestActivity(id uint, actorName string, actorID uint, actType, details string) *api.Activity {
jsonDetails := json.RawMessage(details)
return &api.Activity{
@ -481,8 +532,9 @@ func newTestActivity(id uint, actorName string, actorID uint, actType, details s
func TestStreamActivities(t *testing.T) {
t.Parallel()
noopWebhookSend := func(_ context.Context, _ string, _ any) error { return nil }
newStreamingService := func(ds *mockStreamingDatastore) *Service {
return NewService(&mockAuthorizer{}, ds, &mockDataProviders{mockUserProvider: &mockUserProvider{}, mockHostProvider: &mockHostProvider{}}, slog.New(slog.DiscardHandler))
return NewService(&mockAuthorizer{}, ds, &mockDataProviders{mockUserProvider: &mockUserProvider{}, mockHostProvider: &mockHostProvider{}}, noopWebhookSend, slog.New(slog.DiscardHandler))
}
t.Run("basic streaming", func(t *testing.T) {

View file

@ -75,7 +75,7 @@ func (m *mockHostProvider) GetHostLite(ctx context.Context, hostID uint) (*activ
return nil, platform_mysql.NotFound("Host").WithID(hostID)
}
// mockDataProviders combines user and host providers for testing.
// mockDataProviders combines all provider interfaces for testing.
type mockDataProviders struct {
*mockUserProvider
*mockHostProvider
@ -87,3 +87,11 @@ func newMockDataProviders() *mockDataProviders {
mockHostProvider: newMockHostProvider(),
}
}
func (m *mockDataProviders) GetActivitiesWebhookConfig(ctx context.Context) (*activity.ActivitiesWebhookSettings, error) {
return &activity.ActivitiesWebhookSettings{Enable: false}, nil
}
func (m *mockDataProviders) ActivateNextUpcomingActivity(ctx context.Context, hostID uint, fromCompletedExecID string) error {
return nil
}

View file

@ -1,6 +1,7 @@
package tests
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -37,7 +38,8 @@ func setupIntegrationTest(t *testing.T) *integrationTestSuite {
providers := newMockDataProviders()
// Create service
svc := service.NewService(authorizer, ds, providers, tdb.Logger)
noopWebhookSend := func(_ context.Context, _ string, _ any) error { return nil }
svc := service.NewService(authorizer, ds, providers, noopWebhookSend, tdb.Logger)
// Create router with routes
router := mux.NewRouter()

View file

@ -3,10 +3,45 @@ package types
import (
"context"
"time"
"github.com/fleetdm/fleet/v4/server/activity/api"
)
// activityWebhookContextKeyType is the context key type used to indicate that the activity webhook
// has been processed. This is a sanity check to ensure callers use the service layer
// (which handles webhooks) rather than calling the datastore directly.
type activityWebhookContextKeyType struct{}
// ActivityWebhookContextKey is used to mark that the webhook was processed before storing the activity.
var ActivityWebhookContextKey = activityWebhookContextKeyType{}
// ActivityAutomationAuthor is the name used for the actor when an activity
// is recorded as a result of an automated action (cron job, webhook, etc.)
// or policy automation (i.e. triggered by a failing policy).
const ActivityAutomationAuthor = "Fleet"
// AutomatableActivity indicates the activity was initiated by automation.
type AutomatableActivity interface {
WasFromAutomation() bool
}
// ActivityHosts indicates the activity is associated with specific hosts.
type ActivityHosts interface {
HostIDs() []uint
}
// ActivityHostOnly indicates the activity is host-scoped only.
type ActivityHostOnly interface {
HostOnly() bool
}
// ActivityActivator indicates the activity should activate the next upcoming activity.
type ActivityActivator interface {
MustActivateNextUpcomingActivity() bool
ActivateNextUpcomingActivityArgs() (hostID uint, cmdUUID string)
}
// ListOptions extends api.ListOptions with internal fields used by the datastore.
type ListOptions struct {
api.ListOptions
@ -45,4 +80,7 @@ type Datastore interface {
ListActivities(ctx context.Context, opt ListOptions) ([]*api.Activity, *api.PaginationMetadata, error)
ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*api.Activity, *api.PaginationMetadata, error)
MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error
// NewActivity stores a new activity record in the database.
// The webhook context key must be set in the context before calling this method.
NewActivity(ctx context.Context, user *api.User, activity api.ActivityDetails, details []byte, createdAt time.Time) error
}

View file

@ -1,7 +1,23 @@
package activity
// DataProviders combines providers for ACL layer.
import (
"context"
)
// UpcomingActivityActivator activates the next upcoming activity in the queue.
// This is called when an activity implements ActivityActivator.
type UpcomingActivityActivator interface {
ActivateNextUpcomingActivity(ctx context.Context, hostID uint, fromCompletedExecID string) error
}
// WebhookSendFunc is the function signature for sending a JSON payload to a URL.
type WebhookSendFunc = func(ctx context.Context, url string, payload any) error
// DataProviders combines all external dependency interfaces for the activity
// bounded context. The ACL adapter implements this single interface.
type DataProviders interface {
UserProvider
HostProvider
AppConfigProvider
UpcomingActivityActivator
}

View file

@ -140,10 +140,13 @@ func maskEmail(email string) string {
return string(parts[0][0]) + "***@" + parts[1]
}
// systemUserName is the name used for the synthetic system user. It matches
// the activity bounded context's ActivityAutomationAuthor constant ("Fleet").
const systemUserName = "Fleet"
// systemUser is a synthetic user for internal system operations.
// The Name uses ActivityAutomationAuthor to align with system-initiated activities.
var systemUser = &fleet.User{
Name: fleet.ActivityAutomationAuthor,
Name: systemUserName,
GlobalRole: ptr.String(fleet.RoleAdmin),
}

View file

@ -186,7 +186,7 @@ func TestNewSystemContext(t *testing.T) {
require.NotNil(t, v.User, "user should be present in viewer")
// Verify the system user has the expected properties
assert.Equal(t, fleet.ActivityAutomationAuthor, v.User.Name, "system user name should match ActivityAutomationAuthor")
assert.Equal(t, systemUserName, v.User.Name, "system user name should match ActivityAutomationAuthor")
require.NotNil(t, v.User.GlobalRole, "system user should have a global role")
assert.Equal(t, fleet.RoleAdmin, *v.User.GlobalRole, "system user should have admin role")
}

View file

@ -12,7 +12,6 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
)
@ -20,114 +19,6 @@ var (
deleteIDsBatchSize = 1000
)
// NewActivity stores an activity item that the user performed
func (ds *Datastore) NewActivity(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
// Sanity check to ensure we processed activity webhook before storing the activity
processed, _ := ctx.Value(fleet.ActivityWebhookContextKey).(bool)
if !processed {
return ctxerr.New(
ctx, "activity webhook not processed. Please use svc.NewActivity instead of ds.NewActivity. This is a Fleet server bug.",
)
}
var userID *uint
var userName *string
var userEmail *string
var fleetInitiated bool
var hostOnly bool
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
// the installation.
if user.ID != 0 && !user.Deleted {
userID = &user.ID
}
userName = &user.Name
userEmail = &user.Email
}
if automatableActivity, ok := activity.(fleet.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
userName = ptr.String(fleet.ActivityAutomationAuthor)
fleetInitiated = true
}
if hostOnlyActivity, ok := activity.(fleet.ActivityHostOnly); ok && hostOnlyActivity.HostOnly() {
hostOnly = true
}
cols := []string{"fleet_initiated", "user_id", "user_name", "activity_type", "details", "created_at", "host_only"}
args := []any{
fleetInitiated,
userID,
userName,
activity.ActivityName(),
details,
createdAt,
hostOnly,
}
if userEmail != nil {
args = append(args, userEmail)
cols = append(cols, "user_email")
}
if aa, ok := activity.(fleet.ActivityActivator); ok && aa.MustActivateNextUpcomingActivity() {
hostID, cmdUUID := aa.ActivateNextUpcomingActivityArgs()
// NOTE: ideally this would be called in the same transaction as storing
// the nanomdm command results, but the current design doesn't allow for
// that with the nano store being a distinct entity to our datastore (we
// should get rid of that distinction eventually, we've broken it already
// in some places and it doesn't bring much benefit anymore).
//
// Instead, this gets called from CommandAndReportResults, which is
// executed after the results have been saved in nano, but we already
// accept this non-transactional fact for many other states we manage in
// Fleet (wipe, lock results, setup experience results, etc. - see all
// critical data that gets updated in CommandAndReportResults) so there's
// no reason to treat the unified queue differently.
//
// This place here is a bit hacky but perfect for VPP/InHouse apps as the activity
// gets created only when the MDM command status is in a final state
// (success or failure), which is exactly when we want to activate the next
// activity. Though note that on success of the MDM command, we wait until the
// app gets verified (or it times out waiting for verification) to activate the
// next activity, to ensure the app is actually installed.
if _, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install")
}
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
const insertActStmt = `INSERT INTO activities (%s) VALUES (%s)`
sql := fmt.Sprintf(insertActStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?")
res, err := tx.ExecContext(ctx, sql, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "new activity")
}
// this supposes a reasonable amount of hosts per activity, to revisit if we
// get in the 10K+.
if ah, ok := activity.(fleet.ActivityHosts); ok {
const insertActHostStmt = `INSERT INTO host_activities (host_id, activity_id) VALUES `
var sb strings.Builder
if hostIDs := ah.HostIDs(); len(hostIDs) > 0 {
sb.WriteString(insertActHostStmt)
actID, _ := res.LastInsertId()
for _, hid := range hostIDs {
sb.WriteString(fmt.Sprintf("(%d, %d),", hid, actID))
}
stmt := strings.TrimSuffix(sb.String(), ",")
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return ctxerr.Wrap(ctx, err, "insert host activity")
}
}
}
return nil
})
}
// ListHostUpcomingActivities returns the list of activities pending execution
// or processing for the specific host. It is the "unified queue" of work to be
// done on the host. That queue is "virtual" in the sense that it pulls from a
@ -761,9 +652,9 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext
return nil, ctxerr.Wrap(ctx, err, "activate next upcoming activity")
}
// creating the canceled activity must be done via svc.NewActivity (not
// ds.NewActivity), so we return the ready-to-insert activity struct to the
// caller and let svc do the rest.
// creating the canceled activity must be done via svc.NewActivity, so we
// return the ready-to-insert activity struct to the caller and let svc do
// the rest.
return pastAct, nil
}
@ -1046,6 +937,19 @@ func (ds *Datastore) UnblockHostsUpcomingActivityQueue(ctx context.Context, maxH
return len(blockedHostIDs), ds.activateNextUpcomingActivityForBatchOfHosts(ctx, blockedHostIDs)
}
// ActivateNextUpcomingActivityForHost activates the next upcoming activity for the given host.
// fromCompletedExecID is the execution ID of the activity that just completed (if any).
//
// NOTE: this intentionally does not use a transaction wrapper. The original
// call site in NewActivity (now in the activity bounded context) also called
// activateNextUpcomingActivity outside a transaction. See @mna's comment in
// cab7cc15bef (2025-10-28) explaining that this non-transactional approach is
// accepted and consistent with how other critical state updates work in Fleet.
func (ds *Datastore) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
_, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, fromCompletedExecID)
return err
}
func (ds *Datastore) activateNextUpcomingActivityForBatchOfHosts(ctx context.Context, hostIDs []uint) error {
const maxHostIDsPerBatch = 500

View file

@ -31,8 +31,6 @@ func TestActivity(t *testing.T) {
fn func(t *testing.T, ds *Datastore)
}{
{"UsernameChange", testActivityUsernameChange},
{"New", testActivityNew},
{"EmptyUser", testActivityEmptyUser},
{"ListHostUpcomingActivities", testListHostUpcomingActivities},
{"CleanupActivitiesAndAssociatedData", testCleanupActivitiesAndAssociatedData},
{"CleanupActivitiesAndAssociatedDataBatch", testCleanupActivitiesAndAssociatedDataBatch},
@ -96,22 +94,22 @@ func testActivityUsernameChange(t *testing.T, ds *Datastore) {
_, err := ds.NewUser(context.Background(), u)
require.NoError(t, err)
timestamp := time.Now()
ctx := context.WithValue(context.Background(), fleet.ActivityWebhookContextKey, true)
apiUser := &activity_api.User{ID: u.ID, Name: u.Name, Email: u.Email}
ctx := context.Background()
require.NoError(
t, ds.NewActivity(
ctx, u, dummyActivity{
t, activitySvc.NewActivity(
ctx, apiUser, dummyActivity{
name: "test1",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
}, nil, timestamp,
},
),
)
require.NoError(
t, ds.NewActivity(
ctx, u, dummyActivity{
t, activitySvc.NewActivity(
ctx, apiUser, dummyActivity{
name: "test2",
details: map[string]interface{}{"detail": 2},
}, nil, timestamp,
},
),
)
@ -139,108 +137,6 @@ func testActivityUsernameChange(t *testing.T, ds *Datastore) {
assert.Nil(t, activities[0].ActorGravatar)
}
func testActivityNew(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
u := &fleet.User{
Password: []byte("asd"),
Name: "fullname",
Email: "email@asd.com",
GlobalRole: ptr.String(fleet.RoleObserver),
}
_, err := ds.NewUser(context.Background(), u)
require.Nil(t, err)
timestamp := time.Now()
activity := dummyActivity{
name: "test0",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
}
// If we don't set the ActivityWebhookContextKey context value, the activity will not be created
assert.Error(t, ds.NewActivity(context.Background(), u, activity, nil, timestamp))
// If we set the context value to the wrong thing, the activity will not be created
ctx := context.WithValue(context.Background(), fleet.ActivityWebhookContextKey, "bozo")
assert.Error(t, ds.NewActivity(ctx, u, activity, nil, timestamp))
ctx = context.WithValue(context.Background(), fleet.ActivityWebhookContextKey, true)
require.NoError(
t, ds.NewActivity(
ctx, u, dummyActivity{
name: "test1",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
}, nil, timestamp,
),
)
require.NoError(
t, ds.NewActivity(
ctx, u, dummyActivity{
name: "test2",
details: map[string]interface{}{"detail": 2},
}, nil, timestamp,
),
)
opt := activity_api.ListOptions{
Page: 0,
PerPage: 1,
}
activities := ListActivitiesAPI(t, context.Background(), activitySvc, opt)
assert.Len(t, activities, 1)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Equal(t, "test1", activities[0].Type)
opt = activity_api.ListOptions{
Page: 1,
PerPage: 1,
}
activities = ListActivitiesAPI(t, context.Background(), activitySvc, opt)
assert.Len(t, activities, 1)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Equal(t, "test2", activities[0].Type)
opt = activity_api.ListOptions{
Page: 0,
PerPage: 10,
}
activities = ListActivitiesAPI(t, context.Background(), activitySvc, opt)
assert.Len(t, activities, 2)
}
func testActivityEmptyUser(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
timestamp := time.Now()
ctx := context.WithValue(context.Background(), fleet.ActivityWebhookContextKey, true)
require.NoError(
t, ds.NewActivity(
ctx, nil, dummyActivity{
name: "test1",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
}, nil, timestamp,
),
)
require.NoError(
t, ds.NewActivity(
ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: 1,
HostDisplayName: "A Host",
SoftwareTitle: "Trello",
AppStoreID: "123456",
CommandUUID: "some uuid",
Status: string(fleet.SoftwareInstalled),
SelfService: false,
PolicyID: ptr.Uint(1),
PolicyName: ptr.String("Sample Policy"),
}, nil, timestamp,
),
)
activities := ListActivitiesAPI(t, context.Background(), activitySvc, activity_api.ListOptions{})
assert.Len(t, activities, 2)
assert.Equal(t, "Fleet", *activities[1].ActorFullName)
}
func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
noUserCtx := context.Background()
@ -682,33 +578,28 @@ func testCleanupActivitiesAndAssociatedData(t *testing.T, ds *Datastore) {
Type: fleet.TargetHost,
})
require.NoError(t, err)
timestamp := time.Now()
ctx = context.WithValue(context.Background(), fleet.ActivityWebhookContextKey, true)
err = ds.NewActivity(ctx, user1, dummyActivity{
apiUser := &activity_api.User{ID: user1.ID, Name: user1.Name, Email: user1.Email}
err = activitySvc.NewActivity(ctx, apiUser, dummyActivity{
name: "other activity",
details: map[string]interface{}{"detail": 0, "foo": "zoo"},
}, nil, timestamp,
)
})
require.NoError(t, err)
err = ds.NewActivity(ctx, user1, dummyActivity{
err = activitySvc.NewActivity(ctx, apiUser, dummyActivity{
name: "live query",
details: map[string]interface{}{"detail": 1, "foo": "bar"},
}, nil, timestamp,
)
})
require.NoError(t, err)
err = ds.NewActivity(ctx, user1, dummyActivity{
err = activitySvc.NewActivity(ctx, apiUser, dummyActivity{
name: "some host activity",
details: map[string]interface{}{"detail": 0, "foo": "zoo"},
hostIDs: []uint{1},
}, nil, timestamp,
)
})
require.NoError(t, err)
err = ds.NewActivity(ctx, user1, dummyActivity{
err = activitySvc.NewActivity(ctx, apiUser, dummyActivity{
name: "some host activity 2",
details: map[string]interface{}{"detail": 0, "foo": "bar"},
hostIDs: []uint{2},
}, nil, timestamp,
)
})
require.NoError(t, err)
// Nothing is deleted, as the activities and associated data is recent.
@ -878,8 +769,8 @@ func testCleanupActivitiesAndAssociatedDataBatch(t *testing.T, ds *Datastore) {
}
func testActivateNextActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
test.CreateInsertGlobalVPPToken(t, ds)
@ -1064,11 +955,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp1.VPPAppTeam.AdamID,
CommandUUID: vpp1_1,
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
appleCmdRes, err := ds.GetMDMAppleCommandResults(ctx, vpp1_1, "")
@ -1145,11 +1036,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp2.VPPAppTeam.AdamID,
CommandUUID: vpp1_2,
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
appleCmdRes, err = ds.GetMDMAppleCommandResults(ctx, vpp1_2, "")
@ -1248,12 +1139,12 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: hIOS.ID,
AppStoreID: vppApp1IOS.VPPAppTeam.AdamID,
CommandUUID: vpp1_1_ios,
Status: "Error", // using a failure because otherwise it requires verification to activate next
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
// the in-house app is now activated
@ -1291,11 +1182,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
err = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: "Error", // using a failure because otherwise it requires verification to activate next
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
@ -1323,11 +1214,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
err = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: vpp1_1_ios,
Status: string(fleet.SoftwareInstalled),
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
// both are still upcoming...
@ -1355,11 +1246,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
err = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: string(fleet.SoftwareInstalled),
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
@ -1377,8 +1268,8 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
}
func testActivateItselfOnEmptyQueue(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
test.CreateInsertGlobalVPPToken(t, ds)
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
@ -1464,11 +1355,11 @@ func testActivateItselfOnEmptyQueue(t *testing.T, ds *Datastore) {
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp1.VPPAppTeam.AdamID,
CommandUUID: vpp1_1,
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
// the upcoming queue should be empty, each result having emptied the list
@ -1479,6 +1370,14 @@ func testActivateItselfOnEmptyQueue(t *testing.T, ds *Datastore) {
}
func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
@ -1580,7 +1479,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host)
exec3 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged", newActivityFn)
test.SetHostSoftwareInstallResult(t, ds, host, exec3, 0)
})
return []string{exec1, exec2, exec3}
@ -1609,7 +1508,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec1, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS)
exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
@ -1645,6 +1544,14 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
}
func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
@ -1707,7 +1614,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
@ -1761,7 +1668,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec1, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
@ -1773,7 +1680,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, hostIOS, exec2, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, hostIOS, exec2, adamID, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
@ -1785,7 +1692,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
t.Cleanup(func() {
test.SetHostInHouseAppInstallResult(t, ds, nanoDB, hostIOS, exec2, "Acknowledged")
test.SetHostInHouseAppInstallResult(t, ds, nanoDB, hostIOS, exec2, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
@ -1850,6 +1757,14 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
}
func testSetResultAfterCancelUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
@ -1881,7 +1796,7 @@ func testSetResultAfterCancelUpcomingActivity(t *testing.T, ds *Datastore) {
exec, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, exec)
require.NoError(t, err)
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec, adamID, "Acknowledged")
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec, adamID, "Acknowledged", newActivityFn)
}
func testGetHostUpcomingActivityMeta(t *testing.T, ds *Datastore) {

View file

@ -21,6 +21,7 @@ import (
"github.com/WatchBeam/clock"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -8698,16 +8699,13 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
HostID: host.ID,
HostDisplayName: host.DisplayName(),
}
detailsBytes, err := json.Marshal(activity)
require.NoError(t, err)
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
err = ds.NewActivity( // automatically creates the host_activities entry
activitySvc := NewTestActivityService(t, ds)
apiUser := &activity_api.User{ID: user1.ID, Name: user1.Name, Email: user1.Email}
err = activitySvc.NewActivity( // automatically creates the host_activities entry
ctx,
user1,
apiUser,
activity,
detailsBytes,
time.Now(),
)
require.NoError(t, err)

View file

@ -464,7 +464,8 @@ func createInHouseAppInstallRequest(t *testing.T, ds *Datastore, hostID uint, ap
func createInHouseAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) {
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
activitySvc := NewTestActivityService(t, ds)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
@ -480,10 +481,10 @@ func createInHouseAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host
// inserting the activity is what marks the upcoming activity as completed
// (and activates the next one).
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: host.ID,
CommandUUID: cmdUUID,
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
}
@ -1803,7 +1804,11 @@ func testInHouseAppsCancelledOnUnenroll(t *testing.T, ds *Datastore) {
require.NoError(t, err)
for i, act := range activitiesToCreate {
// caller's responsibility to create new activities
require.NoError(t, ds.NewActivity(ctx, users[i], act, nil, time.Now()))
var apiUser *activity_api.User
if users[i] != nil {
apiUser = &activity_api.User{ID: users[i].ID, Name: users[i].Name, Email: users[i].Email}
}
require.NoError(t, activitySvc.NewActivity(ctx, apiUser, act))
}
// fleet needs to receive some command result at some

View file

@ -32,16 +32,12 @@ import (
nano_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/go-sql-driver/mysql"
"github.com/hashicorp/go-multierror"
"github.com/jmoiron/sqlx"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
)
// Compile-time interface check
var _ activities.ActivityStore = (*Datastore)(nil)
const (
mySQLTimestampFormat = "2006-01-02 15:04:05" // %Y/%m/%d %H:%M:%S

View file

@ -26,6 +26,7 @@ import (
"time"
"github.com/WatchBeam/clock"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
@ -429,14 +430,7 @@ func CreateNamedMySQLDSWithConns(t *testing.T, name string) (*Datastore, *common
ds := initializeDatabase(t, name, new(testing_utils.DatastoreTestOptions))
t.Cleanup(func() { ds.Close() })
replica, ok := ds.replica.(*sqlx.DB)
require.True(t, ok, "ds.replica should be *sqlx.DB in tests")
dbConns := &common_mysql.DBConnections{
Primary: ds.primary,
Replica: replica,
}
return ds, dbConns
return ds, TestDBConnections(t, ds)
}
func ExecAdhocSQL(tb testing.TB, ds *Datastore, fn func(q sqlx.ExtContext) error) {
@ -986,7 +980,7 @@ func (t *testingAuthorizer) Authorize(_ context.Context, _ platform_authz.AuthzT
return nil
}
// testingLookupService adapts mysql.Datastore to fleet.LookupService interface.
// testingLookupService adapts mysql.Datastore to fleet.ActivityLookupService interface.
// This allows tests to use the real activityacl.FleetServiceAdapter instead of
// duplicating the conversion logic.
type testingLookupService struct {
@ -1006,22 +1000,39 @@ func (t *testingLookupService) GetHostLite(ctx context.Context, id uint) (*fleet
return t.ds.HostLite(ctx, id)
}
func (t *testingLookupService) GetActivitiesWebhookSettings(ctx context.Context) (fleet.ActivitiesWebhookSettings, error) {
appConfig, err := t.ds.AppConfig(ctx)
if err != nil {
return fleet.ActivitiesWebhookSettings{}, err
}
return appConfig.WebhookSettings.ActivitiesWebhook, nil
}
func (t *testingLookupService) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
return t.ds.ActivateNextUpcomingActivityForHost(ctx, hostID, fromCompletedExecID)
}
// TestDBConnections extracts the underlying DB connections from a test Datastore.
func TestDBConnections(t testing.TB, ds *Datastore) *common_mysql.DBConnections {
t.Helper()
replica, ok := ds.replica.(*sqlx.DB)
require.True(t, ok, "ds.replica should be *sqlx.DB in tests")
return &common_mysql.DBConnections{Primary: ds.primary, Replica: replica}
}
// NewTestActivityService creates an activity service. This allows tests to call the activity bounded context API.
// User data is fetched from the same database to support tests that verify user info in activities.
func NewTestActivityService(t testing.TB, ds *Datastore) activity_api.Service {
t.Helper()
// Extract DB connections
replica, ok := ds.replica.(*sqlx.DB)
require.True(t, ok, "ds.replica should be *sqlx.DB in tests")
dbConns := &common_mysql.DBConnections{Primary: ds.primary, Replica: replica}
dbConns := TestDBConnections(t, ds)
// Use the real ACL adapter with a testing lookup service
lookupSvc := &testingLookupService{ds: ds}
providers := activityacl.NewFleetServiceAdapter(lookupSvc)
aclAdapter := activityacl.NewFleetServiceAdapter(lookupSvc)
// Create service via bootstrap (the public API for creating the bounded context)
svc, _ := activity_bootstrap.New(dbConns, &testingAuthorizer{}, providers, slog.New(slog.DiscardHandler))
svc, _ := activity_bootstrap.New(dbConns, &testingAuthorizer{}, aclAdapter, server.PostJSONWithTimeout, slog.New(slog.DiscardHandler))
return svc
}

View file

@ -498,7 +498,8 @@ func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, a
func createVPPAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) {
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
activitySvc := NewTestActivityService(t, ds)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
@ -514,10 +515,10 @@ func createVPPAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cm
// inserting the activity is what marks the upcoming activity as completed
// (and activates the next one).
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: host.ID,
CommandUUID: cmdUUID,
}, []byte(`{}`), time.Now())
})
require.NoError(t, err)
}

View file

@ -8,6 +8,9 @@ import (
type ContextKey string
// NewActivityFunc is the function signature for creating a new activity.
type NewActivityFunc func(ctx context.Context, user *User, activity ActivityDetails) error
type ActivityWebhookPayload struct {
Timestamp time.Time `json:"timestamp"`
ActorFullName *string `json:"actor_full_name"`
@ -20,11 +23,6 @@ type ActivityWebhookPayload struct {
// ActivityWebhookContextKey is the context key to indicate that the activity webhook has been processed before saving the activity.
const ActivityWebhookContextKey = ContextKey("ActivityWebhook")
// ActivityAutomationAuthor is the name used for the actor when an activity
// is performed by Fleet automation (cron jobs, system operations, etc.)
// rather than by a human user.
const ActivityAutomationAuthor = "Fleet"
type Activity struct {
CreateTimestamp

View file

@ -785,12 +785,14 @@ type Datastore interface {
///////////////////////////////////////////////////////////////////////////////
// ActivitiesStore
NewActivity(ctx context.Context, user *User, activity ActivityDetails, details []byte, createdAt time.Time) error
ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*UpcomingActivity, *PaginationMetadata, error)
CancelHostUpcomingActivity(ctx context.Context, hostID uint, executionID string) (ActivityDetails, error)
IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) (bool, error)
GetHostUpcomingActivityMeta(ctx context.Context, hostID uint, executionID string) (*UpcomingActivityMeta, error)
UnblockHostsUpcomingActivityQueue(ctx context.Context, maxHosts int) (int, error)
// ActivateNextUpcomingActivityForHost activates the next upcoming activity for the given host.
// fromCompletedExecID is the execution ID of the activity that just completed (if any).
ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error
///////////////////////////////////////////////////////////////////////////////
// StatisticsStore

View file

@ -9,6 +9,7 @@ import (
"net/url"
"time"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/fleetdm/fleet/v4/server/websocket"
)
@ -96,10 +97,20 @@ type LookupService interface {
HostLookupService
}
// ActivityLookupService extends LookupService with methods needed by the
// activity bounded context's ACL adapter.
type ActivityLookupService interface {
LookupService
// GetActivitiesWebhookSettings returns the webhook settings for activities.
GetActivitiesWebhookSettings(ctx context.Context) (ActivitiesWebhookSettings, error)
// ActivateNextUpcomingActivityForHost activates the next upcoming activity for the given host.
ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error
}
type Service interface {
OsqueryService
UserLookupService
HostLookupService
ActivityLookupService
// GetTransparencyURL gets the URL to redirect to when an end user clicks About Fleet
GetTransparencyURL(ctx context.Context) (string, error)
@ -631,6 +642,10 @@ type Service interface {
// /////////////////////////////////////////////////////////////////////////////
// ActivitiesService
// SetActivityService sets the activity bounded context service for creating activities.
// This should be called after service creation to inject the activity service dependency.
SetActivityService(activitySvc api.NewActivityService)
// NewActivity creates the given activity on the datastore.
//
// What we call "Activities" are administrative operations,

View file

@ -6,7 +6,6 @@ import (
"net/http"
"os"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
@ -17,7 +16,6 @@ import (
ds_mock "github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -30,7 +28,7 @@ func TestEnterprisesAuth(t *testing.T) {
androidAPIClient.InitCommonMocks()
logger := logging.NewLogfmtLogger(os.Stdout)
fleetDS := InitCommonDSMocks()
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -128,7 +126,7 @@ func TestEnterpriseSignupMissingPrivateKey(t *testing.T) {
androidAPIClient.InitCommonMocks()
logger := logging.NewLogfmtLogger(os.Stdout)
fleetDS := InitCommonDSMocks()
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -212,9 +210,6 @@ func InitCommonDSMocks() *AndroidMockDS {
ds.Store.BulkSetAndroidHostsUnenrolledFunc = func(ctx context.Context) error {
return nil
}
ds.Store.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.Store.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return &fleet.Job{}, nil
}
@ -236,6 +231,13 @@ type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }
func (e *notFoundError) IsNotFound() bool { return true }
// noopActivityModule is a no-op implementation of the ActivityModule interface.
type noopActivityModule struct{}
func (n *noopActivityModule) NewActivity(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
return nil
}
func TestGetEnterprise(t *testing.T) {
logger := logging.NewLogfmtLogger(os.Stdout)
user := &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleAdmin)}
@ -246,7 +248,7 @@ func TestGetEnterprise(t *testing.T) {
androidAPIClient.InitCommonMocks()
fleetDS := InitCommonDSMocks()
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -266,7 +268,7 @@ func TestGetEnterprise(t *testing.T) {
return nil, &notFoundError{}
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -312,7 +314,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
}, nil
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -361,7 +363,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
}, nil
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -402,7 +404,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
}
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -443,7 +445,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
return []*androidmanagement.Enterprise{}, nil
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -510,7 +512,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
}, nil
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
@ -540,7 +542,7 @@ func TestVerifyExistingEnterpriseIfAny(t *testing.T) {
return nil, &notFoundError{}
}
activityModule := activities.NewActivityModule(fleetDS, logger)
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger.SlogLogger(), fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)

View file

@ -17,10 +17,8 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
"github.com/fleetdm/fleet/v4/server/platform/logging"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/api/androidmanagement/v1"
@ -34,7 +32,7 @@ func createAndroidService(t *testing.T) (android.Service, *AndroidMockDS) {
androidAPIClient.InitCommonMocks()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
mockDS := InitCommonDSMocks()
activityModule := activities.NewActivityModule(mockDS, logging.NewLogger(logger))
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := NewServiceWithClient(logger, mockDS, &androidAPIClient, "test-private-key", &mockDS.DataStore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)

View file

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -13,7 +12,7 @@ import (
// ReconcileAndroidDevices polls AMAPI for devices that Fleet still considers enrolled
// and flips them to unenrolled if Google reports them missing (404).
// This complements (does not replace) Pub/Sub DELETED handling.
func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, licenseKey string) error {
func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, licenseKey string, newActivityFn fleet.NewActivityFunc) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get app config")
@ -88,12 +87,12 @@ func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *sl
displayName = hosts[0].DisplayName()
serial = hosts[0].HardwareSerial
}
if aerr := ds.NewActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
if aerr := newActivityFn(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
HostSerial: serial,
HostDisplayName: displayName,
InstalledFromDEP: false,
Platform: "android",
}, nil, time.Now()); aerr != nil {
}); aerr != nil {
logger.DebugContext(ctx, "failed to create mdm_unenrolled activity during android reconcile", "host_id", dev.HostID, "err", aerr)
}
unenrolled++

View file

@ -8,7 +8,6 @@ import (
"os"
"sync"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
@ -23,7 +22,6 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
"github.com/jmoiron/sqlx"
@ -90,6 +88,13 @@ func (ds *AndroidDSWithMock) SetAndroidHostUnenrolled(ctx context.Context, hostI
return ds.Datastore.SetAndroidHostUnenrolled(ctx, hostID)
}
// noopActivityModule implements activities.ActivityModule with a no-op for tests.
type noopActivityModule struct{}
func (n *noopActivityModule) NewActivity(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
return nil
}
type WithServer struct {
suite.Suite
Svc android.Service
@ -114,9 +119,8 @@ func (ts *WithServer) SetupSuite(t *testing.T, dbName string) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
kitLogger := logging.NewLogger(logger)
activityModule := activities.NewActivityModule(&ts.DS.DataStore, kitLogger)
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, "test-private-key", ts.DS.Datastore, activityModule,
config.AndroidAgentConfig{})
activityModule := &noopActivityModule{} // This test does not verify activity creation.
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, "test-private-key", ts.DS.Datastore, activityModule, config.AndroidAgentConfig{})
require.NoError(t, err)
ts.Svc = svc
@ -158,9 +162,6 @@ func (ts *WithServer) CreateCommonDSMocks() {
ts.DS.BulkSetAndroidHostsUnenrolledFunc = func(ctx context.Context) error {
return nil
}
ts.DS.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
}
func (ts *WithServer) createCommonProxyMocks(t *testing.T) {

View file

@ -1393,8 +1393,8 @@ func (pb *ProfileBimap) add(wantedProfile, currentProfile *fleet.MDMAppleProfile
pb.currentState[currentProfile] = wantedProfile
}
// NewActivityFunc is the function signature for creating a new activity.
type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
// NewActivityFunc is an alias for fleet.NewActivityFunc.
type NewActivityFunc = fleet.NewActivityFunc
func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMAppleCommander, logger *slog.Logger,
newActivityFn NewActivityFunc) error {

View file

@ -59,7 +59,7 @@ type HostLifecycle struct {
// NewActivityFunc is the signature type of the service-layer function that can
// create activities and handle the webhook notification and all other
// mechanisms required when creating an activity.
type NewActivityFunc func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails, ds fleet.Datastore, logger *logging.Logger) error
type NewActivityFunc func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error
// New creates a new HostLifecycle struct
func New(ds fleet.Datastore, logger *logging.Logger, newActivityFn NewActivityFunc) *HostLifecycle {
@ -227,7 +227,7 @@ func (t *HostLifecycle) turnOnApple(ctx context.Context, opts HostOptions) error
} else {
mdmEnrolledActivity.HostSerial = ptr.String(info.HardwareSerial)
}
err = t.newActivityFunc(ctx, nil, mdmEnrolledActivity, t.ds, t.logger)
err = t.newActivityFunc(ctx, nil, mdmEnrolledActivity)
if err != nil {
return ctxerr.Wrap(ctx, err, "create mdm enrolled activity")
}
@ -385,7 +385,7 @@ func (t *HostLifecycle) createActivities(ctx context.Context, users []*fleet.Use
for i, act := range acts {
user := users[i]
if err := t.newActivityFunc(ctx, user, act, t.ds, t.logger); err != nil {
if err := t.newActivityFunc(ctx, user, act); err != nil {
return ctxerr.Wrap(ctx, err, "create activity")
}
}

View file

@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func nopNewActivity(ctx context.Context, user *fleet.User, details fleet.ActivityDetails, ds fleet.Datastore, logger *logging.Logger) error {
func nopNewActivity(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error {
return nil
}

View file

@ -0,0 +1,43 @@
package mock
import (
"context"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
)
// NewActivityFunc is the callback function type for MockNewActivityService.
type NewActivityFunc func(ctx context.Context, user *activity_api.User, activity activity_api.ActivityDetails) error
// NoopNewActivityFunc is a no-op implementation of NewActivityFunc for tests
// that don't need to intercept activity creation.
var NoopNewActivityFunc NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
return nil
}
// MockNewActivityService is a mock implementation of activity_api.NewActivityService
// for unit tests that use mock.Store instead of real MySQL connections.
// When Delegate is set, it is called before the mock's NewActivityFunc,
// allowing real behavior (e.g. webhooks) while still capturing calls.
type MockNewActivityService struct {
NewActivityFunc NewActivityFunc // defaults to NoopNewActivityFunc if nil
NewActivityFuncInvoked bool
Delegate activity_api.NewActivityService
}
// Ensure MockNewActivityService implements activity_api.NewActivityService.
var _ activity_api.NewActivityService = (*MockNewActivityService)(nil)
func (m *MockNewActivityService) NewActivity(ctx context.Context, user *activity_api.User, activity activity_api.ActivityDetails) error {
m.NewActivityFuncInvoked = true
if m.Delegate != nil {
if err := m.Delegate.NewActivity(ctx, user, activity); err != nil {
return err
}
}
fn := m.NewActivityFunc
if fn == nil {
fn = NoopNewActivityFunc
}
return fn(ctx, user, activity)
}

View file

@ -585,8 +585,6 @@ type CleanupHostOperatingSystemsFunc func(ctx context.Context) error
type MDMTurnOffFunc func(ctx context.Context, uuid string) (users []*fleet.User, activities []fleet.ActivityDetails, err error)
type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error
type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error)
type CancelHostUpcomingActivityFunc func(ctx context.Context, hostID uint, executionID string) (fleet.ActivityDetails, error)
@ -597,6 +595,8 @@ type GetHostUpcomingActivityMetaFunc func(ctx context.Context, hostID uint, exec
type UnblockHostsUpcomingActivityQueueFunc func(ctx context.Context, maxHosts int) (int, error)
type ActivateNextUpcomingActivityForHostFunc func(ctx context.Context, hostID uint, fromCompletedExecID string) error
type ShouldSendStatisticsFunc func(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error)
type RecordStatisticsSentFunc func(ctx context.Context) error
@ -2627,9 +2627,6 @@ type DataStore struct {
MDMTurnOffFunc MDMTurnOffFunc
MDMTurnOffFuncInvoked bool
NewActivityFunc NewActivityFunc
NewActivityFuncInvoked bool
ListHostUpcomingActivitiesFunc ListHostUpcomingActivitiesFunc
ListHostUpcomingActivitiesFuncInvoked bool
@ -2645,6 +2642,9 @@ type DataStore struct {
UnblockHostsUpcomingActivityQueueFunc UnblockHostsUpcomingActivityQueueFunc
UnblockHostsUpcomingActivityQueueFuncInvoked bool
ActivateNextUpcomingActivityForHostFunc ActivateNextUpcomingActivityForHostFunc
ActivateNextUpcomingActivityForHostFuncInvoked bool
ShouldSendStatisticsFunc ShouldSendStatisticsFunc
ShouldSendStatisticsFuncInvoked bool
@ -6394,13 +6394,6 @@ func (s *DataStore) MDMTurnOff(ctx context.Context, uuid string) (users []*fleet
return s.MDMTurnOffFunc(ctx, uuid)
}
func (s *DataStore) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
s.mu.Lock()
s.NewActivityFuncInvoked = true
s.mu.Unlock()
return s.NewActivityFunc(ctx, user, activity, details, createdAt)
}
func (s *DataStore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostUpcomingActivitiesFuncInvoked = true
@ -6436,6 +6429,13 @@ func (s *DataStore) UnblockHostsUpcomingActivityQueue(ctx context.Context, maxHo
return s.UnblockHostsUpcomingActivityQueueFunc(ctx, maxHosts)
}
func (s *DataStore) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
s.mu.Lock()
s.ActivateNextUpcomingActivityForHostFuncInvoked = true
s.mu.Unlock()
return s.ActivateNextUpcomingActivityForHostFunc(ctx, hostID, fromCompletedExecID)
}
func (s *DataStore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) {
s.mu.Lock()
s.ShouldSendStatisticsFuncInvoked = true

View file

@ -12,6 +12,7 @@ import (
"sync"
"time"
"github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/fleetdm/fleet/v4/server/websocket"
@ -41,6 +42,10 @@ type UsersByIDsFunc func(ctx context.Context, ids []uint) ([]*fleet.UserSummary,
type GetHostLiteFunc func(ctx context.Context, id uint) (host *fleet.Host, err error)
type GetActivitiesWebhookSettingsFunc func(ctx context.Context) (fleet.ActivitiesWebhookSettings, error)
type ActivateNextUpcomingActivityForHostFunc func(ctx context.Context, hostID uint, fromCompletedExecID string) error
type GetTransparencyURLFunc func(ctx context.Context) (string, error)
type AuthenticateOrbitHostFunc func(ctx context.Context, nodeKey string) (host *fleet.Host, debug bool, err error)
@ -385,6 +390,8 @@ type ModifyTeamEnrollSecretsFunc func(ctx context.Context, teamID uint, secrets
type ApplyTeamSpecsFunc func(ctx context.Context, specs []*fleet.TeamSpec, applyOpts fleet.ApplyTeamSpecOptions) (map[string]uint, error)
type SetActivityServiceFunc func(activitySvc api.NewActivityService)
type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.UpcomingActivity, *fleet.PaginationMetadata, error)
@ -911,6 +918,12 @@ type Service struct {
GetHostLiteFunc GetHostLiteFunc
GetHostLiteFuncInvoked bool
GetActivitiesWebhookSettingsFunc GetActivitiesWebhookSettingsFunc
GetActivitiesWebhookSettingsFuncInvoked bool
ActivateNextUpcomingActivityForHostFunc ActivateNextUpcomingActivityForHostFunc
ActivateNextUpcomingActivityForHostFuncInvoked bool
GetTransparencyURLFunc GetTransparencyURLFunc
GetTransparencyURLFuncInvoked bool
@ -1427,6 +1440,9 @@ type Service struct {
ApplyTeamSpecsFunc ApplyTeamSpecsFunc
ApplyTeamSpecsFuncInvoked bool
SetActivityServiceFunc SetActivityServiceFunc
SetActivityServiceFuncInvoked bool
NewActivityFunc NewActivityFunc
NewActivityFuncInvoked bool
@ -2245,6 +2261,20 @@ func (s *Service) GetHostLite(ctx context.Context, id uint) (host *fleet.Host, e
return s.GetHostLiteFunc(ctx, id)
}
func (s *Service) GetActivitiesWebhookSettings(ctx context.Context) (fleet.ActivitiesWebhookSettings, error) {
s.mu.Lock()
s.GetActivitiesWebhookSettingsFuncInvoked = true
s.mu.Unlock()
return s.GetActivitiesWebhookSettingsFunc(ctx)
}
func (s *Service) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
s.mu.Lock()
s.ActivateNextUpcomingActivityForHostFuncInvoked = true
s.mu.Unlock()
return s.ActivateNextUpcomingActivityForHostFunc(ctx, hostID, fromCompletedExecID)
}
func (s *Service) GetTransparencyURL(ctx context.Context) (string, error) {
s.mu.Lock()
s.GetTransparencyURLFuncInvoked = true
@ -3449,6 +3479,13 @@ func (s *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, a
return s.ApplyTeamSpecsFunc(ctx, specs, applyOpts)
}
func (s *Service) SetActivityService(activitySvc api.NewActivityService) {
s.mu.Lock()
s.SetActivityServiceFuncInvoked = true
s.mu.Unlock()
s.SetActivityServiceFunc(activitySvc)
}
func (s *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
s.mu.Lock()
s.NewActivityFuncInvoked = true

View file

@ -0,0 +1,50 @@
package http
import (
"errors"
"net/url"
"strings"
)
// MaskSecretURLParams masks URL query values if the query param name includes "secret", "token",
// "key", "password". It accepts a raw string and returns a redacted string if the raw string is
// URL-parseable. If it is not URL-parseable, the raw string is returned unchanged.
func MaskSecretURLParams(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
keywords := []string{"secret", "token", "key", "password"}
containsKeyword := func(s string) bool {
s = strings.ToLower(s)
for _, kw := range keywords {
if strings.Contains(s, kw) {
return true
}
}
return false
}
q := u.Query()
for k := range q {
if containsKeyword(k) {
q[k] = []string{"MASKED"}
}
}
u.RawQuery = q.Encode()
return u.Redacted()
}
// MaskURLError checks if the provided error is a *url.Error. If so, it applies MaskSecretURLParams
// to the URL value and returns the modified error. If not, the error is returned unchanged.
func MaskURLError(e error) error {
var ue *url.Error
ok := errors.As(e, &ue)
if !ok {
return e
}
ue.URL = MaskSecretURLParams(ue.URL)
return ue
}

View file

@ -2,23 +2,12 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/ptr"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/go-kit/log/level"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/fleetdm/fleet/v4/server/platform/logging"
)
////////////////////////////////////////////////////////////////////////////////
@ -33,83 +22,29 @@ type listActivitiesResponse struct {
func (r listActivitiesResponse) Error() error { return r.Err }
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return newActivity(ctx, user, activity, svc.ds, svc.logger)
func (svc *Service) GetActivitiesWebhookSettings(ctx context.Context) (fleet.ActivitiesWebhookSettings, error) {
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return fleet.ActivitiesWebhookSettings{}, ctxerr.Wrap(ctx, err, "get app config for activities webhook")
}
return appConfig.WebhookSettings.ActivitiesWebhook, nil
}
func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, ds fleet.Datastore, logger *logging.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get app config")
}
func (svc *Service) ActivateNextUpcomingActivityForHost(ctx context.Context, hostID uint, fromCompletedExecID string) error {
return svc.ds.ActivateNextUpcomingActivityForHost(ctx, hostID, fromCompletedExecID)
}
detailsBytes, err := json.Marshal(activity)
if err != nil {
return ctxerr.Wrap(ctx, err, "marshaling activity details")
}
// Duplicate JSON keys so that stored activity details include both the
// old and new field names (e.g. team_id and fleet_id).
if rules := endpointer.ExtractAliasRules(activity); len(rules) > 0 {
detailsBytes = endpointer.DuplicateJSONKeys(detailsBytes, rules, endpointer.DuplicateJSONKeysOpts{Compact: true})
}
timestamp := time.Now()
if appConfig.WebhookSettings.ActivitiesWebhook.Enable {
webhookURL := appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL
var userID *uint
var userName *string
var userEmail *string
activityType := activity.ActivityName()
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
// the installation.
if user.ID != 0 && !user.Deleted {
userID = &user.ID
}
userName = &user.Name
userEmail = &user.Email
} else if automatableActivity, ok := activity.(fleet.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
userName = ptr.String(fleet.ActivityAutomationAuthor)
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{
ID: user.ID,
Name: user.Name,
Email: user.Email,
Deleted: user.Deleted,
}
go func() {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 30 * time.Minute
err := backoff.Retry(
func() error {
if err := server.PostJSONWithTimeout(
context.Background(), webhookURL, &fleet.ActivityWebhookPayload{
Timestamp: timestamp,
ActorFullName: userName,
ActorID: userID,
ActorEmail: userEmail,
Type: activityType,
Details: (*json.RawMessage)(&detailsBytes),
},
); err != nil {
var statusCoder kithttp.StatusCoder
if errors.As(err, &statusCoder) && statusCoder.StatusCode() == http.StatusTooManyRequests {
level.Debug(logger).Log("msg", "fire activity webhook", "err", err)
return err
}
return backoff.Permanent(err)
}
return nil
}, retryStrategy,
)
if err != nil {
level.Error(logger).Log(
"msg", fmt.Sprintf("fire activity webhook to %s", server.MaskSecretURLParams(webhookURL)), "err",
server.MaskURLError(err).Error(),
)
}
}()
}
// We update the context to indicate that we processed the webhook.
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
return ds.NewActivity(ctx, user, activity, detailsBytes, timestamp)
return svc.activitySvc.NewActivity(ctx, apiUser, activity)
}
////////////////////////////////////////////////////////////////////////////////

View file

@ -3,11 +3,16 @@ package service
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
fleetserver "github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/activity"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
@ -16,6 +21,31 @@ import (
"github.com/stretchr/testify/require"
)
// webhookTestProviders implements activity.DataProviders for webhook tests.
type webhookTestProviders struct {
getWebhookConfig func() (*activity.ActivitiesWebhookSettings, error)
}
func (p *webhookTestProviders) GetActivitiesWebhookConfig(_ context.Context) (*activity.ActivitiesWebhookSettings, error) {
return p.getWebhookConfig()
}
func (p *webhookTestProviders) ActivateNextUpcomingActivity(_ context.Context, _ uint, _ string) error {
return nil
}
func (p *webhookTestProviders) MaskSecretURLParams(rawURL string) string { return rawURL }
func (p *webhookTestProviders) MaskURLError(err error) error { return err }
func (p *webhookTestProviders) UsersByIDs(_ context.Context, _ []uint) ([]*activity.User, error) {
return nil, nil
}
func (p *webhookTestProviders) FindUserIDs(_ context.Context, _ string) ([]uint, error) {
return nil, nil
}
func (p *webhookTestProviders) GetHostLite(_ context.Context, _ uint) (*activity.Host, error) {
return nil, nil
}
type ActivityTypeTest struct {
Name string `json:"name"`
}
@ -82,11 +112,10 @@ func Test_logRoleChangeActivities(t *testing.T) {
},
}
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
var activities []string
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
activities = append(activities, activity.ActivityName())
return nil
}
@ -122,7 +151,8 @@ func Test_logRoleChangeActivities(t *testing.T) {
func TestActivityWebhooks(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
var webhookBody = fleet.ActivityWebhookPayload{}
webhookChannel := make(chan struct{}, 1)
fail429 := false
@ -172,24 +202,23 @@ func TestActivityWebhooks(t *testing.T) {
mockUrl := startMockServer(t)
testUrl := mockUrl
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
WebhookSettings: fleet.WebhookSettings{
ActivitiesWebhook: fleet.ActivitiesWebhookSettings{
Enable: true,
DestinationURL: testUrl,
},
},
}, nil
// Wire a real activity bounded context service as delegate so that webhook
// firing (which lives in the bounded context) is exercised. The mock still
// captures invocations and the user for assertions.
providers := &webhookTestProviders{
getWebhookConfig: func() (*activity.ActivitiesWebhookSettings, error) {
return &activity.ActivitiesWebhookSettings{
Enable: true,
DestinationURL: testUrl,
}, nil
},
}
var activityUser *fleet.User
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
realActivitySvc := activity_bootstrap.NewForUnitTests(providers, fleetserver.PostJSONWithTimeout, slog.New(slog.DiscardHandler))
opts.ActivityMock.Delegate = realActivitySvc
var activityUser *activity_api.User
opts.ActivityMock.NewActivityFunc = func(_ context.Context, user *activity_api.User, _ activity_api.ActivityDetails) error {
activityUser = user
assert.NotEmpty(t, details)
assert.True(t, createdAt.After(time.Now().Add(-10*time.Second)))
assert.False(t, createdAt.After(time.Now()))
return nil
}
@ -232,11 +261,11 @@ func TestActivityWebhooks(t *testing.T) {
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
ds.NewActivityFuncInvoked = false
opts.ActivityMock.NewActivityFuncInvoked = false
testUrl = tt.url
startTime := time.Now()
activity := ActivityTypeTest{Name: tt.name}
err := svc.NewActivity(ctx, tt.user, activity)
act := ActivityTypeTest{Name: tt.name}
err := svc.NewActivity(ctx, tt.user, act)
require.NoError(t, err)
select {
case <-time.After(1 * time.Second):
@ -263,15 +292,22 @@ func TestActivityWebhooks(t *testing.T) {
require.NotNil(t, webhookBody.ActorEmail)
assert.Equal(t, tt.user.Email, *webhookBody.ActorEmail)
}
assert.Equal(t, activity.ActivityName(), webhookBody.Type)
assert.Equal(t, act.ActivityName(), webhookBody.Type)
var details map[string]string
require.NoError(t, json.Unmarshal(*webhookBody.Details, &details))
assert.Len(t, details, 1)
assert.Equal(t, tt.name, details["name"])
}
}
require.True(t, ds.NewActivityFuncInvoked)
assert.Equal(t, tt.user, activityUser)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
if tt.user == nil {
assert.Nil(t, activityUser)
} else {
require.NotNil(t, activityUser)
assert.Equal(t, tt.user.ID, activityUser.ID)
assert.Equal(t, tt.user.Name, activityUser.Name)
assert.Equal(t, tt.user.Email, activityUser.Email)
}
},
)
}
@ -279,7 +315,8 @@ func TestActivityWebhooks(t *testing.T) {
func TestActivityWebhooksDisabled(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
startMockServer := func(t *testing.T) string {
// create a test http server
srv := httptest.NewServer(
@ -304,14 +341,9 @@ func TestActivityWebhooksDisabled(t *testing.T) {
},
}, nil
}
var activityUser *fleet.User
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
var activityUser *activity_api.User
opts.ActivityMock.NewActivityFunc = func(_ context.Context, user *activity_api.User, _ activity_api.ActivityDetails) error {
activityUser = user
assert.NotEmpty(t, details)
assert.True(t, createdAt.After(time.Now().Add(-10*time.Second)))
assert.False(t, createdAt.After(time.Now()))
return nil
}
activity := ActivityTypeTest{Name: "no webhook"}
@ -321,8 +353,11 @@ func TestActivityWebhooksDisabled(t *testing.T) {
Email: "testUser@example.com",
}
require.NoError(t, svc.NewActivity(ctx, user, activity))
require.True(t, ds.NewActivityFuncInvoked)
assert.Equal(t, user, activityUser)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
require.NotNil(t, activityUser)
assert.Equal(t, user.ID, activityUser.ID)
assert.Equal(t, user.Name, activityUser.Name)
assert.Equal(t, user.Email, activityUser.Email)
}
func TestCancelHostUpcomingActivityAuth(t *testing.T) {

View file

@ -14,7 +14,6 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/config"
@ -227,9 +226,6 @@ func TestEnrollSecretAuth(t *testing.T) {
return nil, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil }
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
testCases := []struct {
name string
@ -342,9 +338,6 @@ func TestApplyEnrollSecretWithGlobalEnrollConfig(t *testing.T) {
return nil, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil }
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.IsEnrollSecretAvailableFunc = nil
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
@ -1480,11 +1473,6 @@ func TestModifyAppConfigSMTPSSOAgentOptions(t *testing.T) {
*dsAppConfig = *conf
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error {
return nil
}

View file

@ -3322,6 +3322,7 @@ type MDMAppleCheckinAndCommandService struct {
mdmLifecycle *mdmlifecycle.HostLifecycle
commandHandlers map[string][]fleet.MDMCommandResultsHandler
keyValueStore fleet.KeyValueStore
newActivityFn mdmlifecycle.NewActivityFunc
isPremium bool
}
@ -3332,8 +3333,9 @@ func NewMDMAppleCheckinAndCommandService(
isPremium bool,
logger *platformlogging.Logger,
keyValueStore fleet.KeyValueStore,
newActivityFn mdmlifecycle.NewActivityFunc,
) *MDMAppleCheckinAndCommandService {
mdmLifecycle := mdmlifecycle.New(ds, logger, newActivity)
mdmLifecycle := mdmlifecycle.New(ds, logger, newActivityFn)
return &MDMAppleCheckinAndCommandService{
ds: ds,
commander: commander,
@ -3343,6 +3345,7 @@ func NewMDMAppleCheckinAndCommandService(
isPremium: isPremium,
commandHandlers: map[string][]fleet.MDMCommandResultsHandler{},
keyValueStore: keyValueStore,
newActivityFn: newActivityFn,
}
}
@ -3548,13 +3551,13 @@ func (svc *MDMAppleCheckinAndCommandService) CheckOut(r *mdm.Request, m *mdm.Che
return err
}
return newActivity(
return svc.newActivityFn(
r.Context, nil, &fleet.ActivityTypeMDMUnenrolled{
HostSerial: info.HardwareSerial,
HostDisplayName: info.DisplayName,
InstalledFromDEP: info.InstalledFromDEP,
Platform: info.Platform,
}, svc.ds, svc.logger,
},
)
}
@ -3814,7 +3817,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
return nil, ctxerr.Wrap(r.Context, err, "fetching data for installed app store app activity")
}
act.FromSetupExperience = fromSetupExperience
if err := newActivity(r.Context, user, act, svc.ds, svc.logger); err != nil {
if err := svc.newActivityFn(r.Context, user, act); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app")
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/worker"
@ -64,6 +65,7 @@ func NewInstalledApplicationListResultsHandler(
commander *apple_mdm.MDMAppleCommander,
logger *logging.Logger,
verifyTimeout, verifyRequestDelay time.Duration,
newActivityFn mdmlifecycle.NewActivityFunc,
) fleet.MDMCommandResultsHandler {
return func(ctx context.Context, commandResults fleet.MDMCommandResults) error {
installedAppResult, ok := commandResults.(InstalledApplicationListResult)
@ -202,7 +204,7 @@ func NewInstalledApplicationListResultsHandler(
return ctxerr.Wrap(ctx, err, "fetching data for installed app store app activity")
}
if err := newActivity(ctx, user, act, ds, logger); err != nil {
if err := newActivityFn(ctx, user, act); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for installed app store app")
}

View file

@ -75,7 +75,7 @@ func (nopProfileMatcher) RetrieveProfiles(ctx context.Context, extHostID string)
return fleet.MDMApplePreassignHostProfiles{}, nil
}
func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Service, context.Context, *mock.Store) {
func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Service, context.Context, *mock.Store, *TestServerOpts) {
ds := new(mock.Store)
cfg := config.TestConfig()
testCertPEM, testKeyPEM, err := generateCertWithAPNsTopic()
@ -253,11 +253,11 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
return []*fleet.ABMToken{{ID: 1}}, nil
}
return svc, ctx, ds
return svc, ctx, ds, opts
}
func TestAppleMDMAuthorization(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
return []*fleet.EnrollSecret{
@ -580,7 +580,7 @@ func TestAppleMDMAuthorization(t *testing.T) {
}
func TestMDMAppleConfigProfileAuthz(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
profUUID := "a" + uuid.NewString()
testCases := []struct {
@ -657,9 +657,6 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) {
ds.ListMDMAppleConfigProfilesFunc = func(ctx context.Context, teamID *uint) ([]*fleet.MDMAppleConfigProfile, error) {
return nil, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.GetMDMAppleProfilesSummaryFunc = func(context.Context, *uint) (*fleet.MDMProfilesSummary, error) {
return nil, nil
}
@ -772,7 +769,7 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) {
}
func TestNewMDMAppleConfigProfile(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
identifier := "Bar.$FLEET_VAR_HOST_END_USER_EMAIL_IDP"
@ -784,9 +781,6 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
require.Equal(t, mcBytes, []byte(cp.Mobileconfig))
return &cp, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
@ -835,7 +829,7 @@ func mcBytesForTest(name, identifier, uuid string) []byte {
}
func TestBatchSetMDMAppleProfilesWithSecrets(t *testing.T) {
svc, ctx, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, _, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
// Test profile with FLEET_SECRET in PayloadDisplayName
@ -852,7 +846,7 @@ func TestBatchSetMDMAppleProfilesWithSecrets(t *testing.T) {
}
func TestNewMDMAppleDeclaration(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
// Unsupported Fleet variable
@ -868,9 +862,6 @@ func TestNewMDMAppleDeclaration(t *testing.T) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
return d, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
@ -885,7 +876,7 @@ func TestNewMDMAppleDeclaration(t *testing.T) {
// Fragile test: This test is fragile because of the large reliance on Datastore mocks. Consider refactoring test/logic or removing the test. It may be slowing us down more than helping us.
func TestHostDetailsMDMProfiles(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
expected := []fleet.HostMDMAppleProfile{
@ -1070,7 +1061,7 @@ func TestHostDetailsMDMProfiles(t *testing.T) {
}
func TestMDMCommandAuthz(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
switch hostID {
@ -1085,9 +1076,6 @@ func TestMDMCommandAuthz(t *testing.T) {
return &fleet.HostMDMCheckinInfo{Platform: "darwin"}, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.MDMTurnOffFunc = func(ctx context.Context, uuid string) ([]*fleet.User, []fleet.ActivityDetails, error) {
return nil, nil, nil
}
@ -1202,7 +1190,7 @@ func TestMDMCommandAuthz(t *testing.T) {
func TestMDMAuthenticateManualEnrollment(t *testing.T) {
ds := new(mock.Store)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error { return nil })
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
@ -1252,7 +1240,7 @@ func TestMDMAuthenticateManualEnrollment(t *testing.T) {
func TestMDMAuthenticateADE(t *testing.T) {
ds := new(mock.Store)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error { return nil })
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
@ -1302,7 +1290,11 @@ func TestMDMAuthenticateADE(t *testing.T) {
func TestMDMAuthenticateSCEPRenewal(t *testing.T) {
ds := new(mock.Store)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
var newActivityInvoked bool
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
newActivityInvoked = true
return nil
})
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
@ -1320,11 +1312,6 @@ func TestMDMAuthenticateSCEPRenewal(t *testing.T) {
}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.MDMResetEnrollmentFunc = func(ctx context.Context, hostUUID string, scepRenewalInProgress bool) error {
require.Equal(t, uuid, hostUUID)
require.True(t, scepRenewalInProgress)
@ -1351,12 +1338,12 @@ func TestMDMAuthenticateSCEPRenewal(t *testing.T) {
require.NoError(t, err)
require.False(t, ds.MDMAppleUpsertHostFuncInvoked)
require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked)
require.False(t, ds.NewActivityFuncInvoked)
require.False(t, newActivityInvoked)
require.True(t, ds.MDMResetEnrollmentFuncInvoked)
}
func TestAppleMDMUnenrollment(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleAdmin)}})
hostOne := &fleet.Host{ID: 1, UUID: "test-host-no-team-2", Platform: "ios"}
@ -1377,9 +1364,6 @@ func TestAppleMDMUnenrollment(t *testing.T) {
return &fleet.HostMDMCheckinInfo{Platform: "darwin"}, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.MDMTurnOffFunc = func(ctx context.Context, uuid string) ([]*fleet.User, []fleet.ActivityDetails, error) {
return nil, nil, nil
}
@ -1419,14 +1403,28 @@ func TestMDMTokenUpdate(t *testing.T) {
NewNanoMDMLogger(logging.NewJSONLogger(os.Stdout)),
)
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
uuid, serial, model, wantTeamID := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1", uint(12)
var newActivityFuncInvoked bool
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
newActivityFuncInvoked = true
a, ok := activity.(*fleet.ActivityTypeMDMEnrolled)
require.True(t, ok)
require.Nil(t, user)
require.Equal(t, "mdm_enrolled", activity.ActivityName())
require.NotNil(t, a.HostSerial)
require.Equal(t, serial, *a.HostSerial)
require.Nil(t, a.EnrollmentID)
require.Equal(t, a.HostDisplayName, model)
require.True(t, a.InstalledFromDEP)
require.Equal(t, fleet.MDMPlatformApple, a.MDMPlatform)
return nil
})
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
commander: cmdr,
logger: logging.NewNopLogger(),
}
uuid, serial, model, wantTeamID := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1", uint(12)
ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
@ -1459,22 +1457,6 @@ func TestMDMTokenUpdate(t *testing.T) {
}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
a, ok := activity.(*fleet.ActivityTypeMDMEnrolled)
require.True(t, ok)
require.Nil(t, user)
require.Equal(t, "mdm_enrolled", activity.ActivityName())
require.NotNil(t, a.HostSerial)
require.Equal(t, serial, *a.HostSerial)
require.Nil(t, a.EnrollmentID)
require.Equal(t, a.HostDisplayName, model)
require.True(t, a.InstalledFromDEP)
require.Equal(t, fleet.MDMPlatformApple, a.MDMPlatform)
return nil
}
ds.NewJobFunc = func(ctx context.Context, j *fleet.Job) (*fleet.Job, error) {
return j, nil
}
@ -1490,7 +1472,7 @@ func TestMDMTokenUpdate(t *testing.T) {
require.NoError(t, err)
require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked)
require.True(t, ds.NewJobFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, newActivityFuncInvoked)
ds.GetHostMDMCheckinInfoFuncInvoked = false
ds.NewJobFuncInvoked = false
@ -1510,7 +1492,7 @@ func TestMDMTokenUpdate(t *testing.T) {
require.NoError(t, err)
require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked)
require.True(t, ds.NewJobFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, newActivityFuncInvoked)
// With AwaitingConfiguration - should check for and enqueue SetupExperience items
ds.EnqueueSetupExperienceItemsFunc = func(ctx context.Context, hostPlatformLike string, hostUUID string, teamID uint) (bool, error) {
@ -1573,7 +1555,7 @@ func TestMDMTokenUpdate(t *testing.T) {
// Should NOT call the setup experience enqueue function but it should mark the migration complete
require.False(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
require.True(t, ds.SetHostMDMMigrationCompletedFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, newActivityFuncInvoked)
ds.SetHostMDMMigrationCompletedFuncInvoked = false
err = svc.TokenUpdate(
@ -1592,7 +1574,7 @@ func TestMDMTokenUpdate(t *testing.T) {
// Should NOT call the setup experience enqueue function but it should mark the migration complete
require.False(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
require.True(t, ds.SetHostMDMMigrationCompletedFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, newActivityFuncInvoked)
}
func TestMDMTokenUpdateIOS(t *testing.T) {
@ -1607,7 +1589,7 @@ func TestMDMTokenUpdateIOS(t *testing.T) {
NewNanoMDMLogger(logging.NewJSONLogger(os.Stdout)),
)
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error { return nil })
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
@ -1630,10 +1612,6 @@ func TestMDMTokenUpdateIOS(t *testing.T) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, j *fleet.Job) (*fleet.Job, error) {
return j, nil
}
@ -1763,7 +1741,8 @@ func TestMDMTokenUpdateIOS(t *testing.T) {
func TestMDMCheckout(t *testing.T) {
ds := new(mock.Store)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), newActivity)
mdmLifecycle := mdmlifecycle.New(ds, logging.NewNopLogger(), func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error { return nil })
var newActivityFuncInvoked bool
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
@ -1790,9 +1769,10 @@ func TestMDMCheckout(t *testing.T) {
ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
svc.newActivityFn = func(
_ context.Context, user *fleet.User, activity fleet.ActivityDetails,
) error {
newActivityFuncInvoked = true
a, ok := activity.(*fleet.ActivityTypeMDMUnenrolled)
require.True(t, ok)
require.Nil(t, user)
@ -1818,7 +1798,7 @@ func TestMDMCheckout(t *testing.T) {
require.NoError(t, err)
require.True(t, ds.MDMTurnOffFuncInvoked)
require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, newActivityFuncInvoked)
}
func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
@ -1961,7 +1941,7 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
}
func TestMDMBatchSetAppleProfiles(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
return &fleet.Team{ID: 1, Name: name}, nil
@ -1972,11 +1952,6 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) {
ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, teamID *uint, profiles []*fleet.MDMAppleConfigProfile) error {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
@ -2309,7 +2284,7 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) {
}
func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, svcOpts := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
return &fleet.Team{ID: 1, Name: name}, nil
@ -2320,11 +2295,6 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) {
ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, teamID *uint, profiles []*fleet.MDMAppleConfigProfile) error {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
@ -2341,18 +2311,18 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) {
require.NoError(t, err)
require.False(t, ds.BatchSetMDMAppleProfilesFuncInvoked)
require.False(t, ds.BulkSetPendingMDMHostProfilesFuncInvoked)
require.False(t, ds.NewActivityFuncInvoked)
require.False(t, svcOpts.ActivityMock.NewActivityFuncInvoked)
// skipping bulk set only skips that method
err = svc.BatchSetMDMAppleProfiles(ctx, nil, nil, [][]byte{}, false, true)
require.NoError(t, err)
require.True(t, ds.BatchSetMDMAppleProfilesFuncInvoked)
require.False(t, ds.BulkSetPendingMDMHostProfilesFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, svcOpts.ActivityMock.NewActivityFuncInvoked)
}
func TestUpdateMDMAppleSettings(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: id, Name: "team"}, nil
@ -2360,11 +2330,6 @@ func TestUpdateMDMAppleSettings(t *testing.T) {
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
return team, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
@ -2509,18 +2474,13 @@ func TestUpdateMDMAppleSettings(t *testing.T) {
func TestUpdateMDMAppleSetup(t *testing.T) {
setupTest := func(tier string) (fleet.Service, context.Context, *mock.Store) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: tier})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: tier})
ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: id, Name: "team"}, nil
}
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
return team, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
@ -4109,13 +4069,8 @@ func TestEnsureFleetdConfig(t *testing.T) {
}
func TestMDMAppleSetupAssistant(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, j *fleet.Job) (*fleet.Job, error) {
return j, nil
}
@ -4199,7 +4154,7 @@ func TestMDMAppleSetupAssistant(t *testing.T) {
}
func TestMDMApplePreassignEndpoints(t *testing.T) {
svc, ctx, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, _, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) {
t.Helper()
@ -5118,7 +5073,7 @@ func TestNeedsOSUpdateForDEPEnrollment(t *testing.T) {
}
func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
svc, ctx, ds, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
gdmf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)

View file

@ -5,7 +5,6 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -64,10 +63,6 @@ func TestCreateCertificateTemplate(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
t.Run("Invalid CA type", func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(InvalidCATypeID), "CN=$FLEET_VAR_HOST_UUID")
require.Error(t, err)
@ -193,10 +188,6 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) {
}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
// Set up certificate authority mocks
certAuthorities := []*fleet.CertificateAuthority{
{

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -66,11 +65,6 @@ func TestGlobalPoliciesAuth(t *testing.T) {
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
return nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.SavePolicyFunc = func(ctx context.Context, p *fleet.Policy, shouldDeleteAll bool, removePolicyStats bool) error {
return nil
}
@ -266,11 +260,6 @@ func TestApplyPolicySpecsLabelsValidation(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
return nil
}

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -31,11 +30,6 @@ func TestGlobalScheduleAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
return nil, 0, 0, nil, nil
}

View file

@ -585,7 +585,7 @@ func (svc *Service) DeleteHosts(ctx context.Context, ids []uint, filter *map[str
return err
}
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, newActivity)
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
lifecycleErrs := []error{}
serialsWithErrs := []string{}
for _, host := range hosts {
@ -1104,7 +1104,7 @@ func (svc *Service) DeleteHost(ctx context.Context, id uint) error {
}
if fleet.MDMSupported(host.Platform) {
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, newActivity)
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionDelete,
Platform: host.Platform,

View file

@ -751,9 +751,6 @@ func TestHostAuth(t *testing.T) {
ds.DeleteHostFunc = func(ctx context.Context, hid uint) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
if id == 1 {
return teamHost, nil
@ -820,9 +817,6 @@ func TestHostAuth(t *testing.T) {
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: id}, nil
}
ds.NewActivityFunc = func(ctx context.Context, u *fleet.User, a fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return nil, nil
}
@ -1371,6 +1365,7 @@ func TestDeleteHostCreatesActivity(t *testing.T) {
activitySvc := mysql.NewTestActivityService(t, ds)
svc, ctx := newTestService(t, ds, nil, nil)
svc.SetActivityService(activitySvc)
// Create a user for the deletion
user := &fleet.User{
@ -1426,6 +1421,7 @@ func TestDeleteHostsCreatesActivities(t *testing.T) {
activitySvc := mysql.NewTestActivityService(t, ds)
svc, ctx := newTestService(t, ds, nil, nil)
svc.SetActivityService(activitySvc)
// Create a user for the deletion
user := &fleet.User{
@ -1494,6 +1490,7 @@ func TestCleanupExpiredHostsActivities(t *testing.T) {
activitySvc := mysql.NewTestActivityService(t, ds)
svc, ctx := newTestService(t, ds, nil, nil)
svc.SetActivityService(activitySvc)
// Set global host expiry
const globalExpiryWindow = 10
@ -1680,11 +1677,6 @@ func TestAddHostsToTeamByFilter(t *testing.T) {
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
emptyRequest := &map[string]interface{}{}
@ -1726,11 +1718,6 @@ func TestAddHostsToTeamByFilterLabel(t *testing.T) {
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: id}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
filter := &map[string]interface{}{"label_id": expectedLabel}
@ -1778,9 +1765,6 @@ func TestAddHostsToTeamSourceTeamAuth(t *testing.T) {
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: id}, nil
}
@ -1951,9 +1935,6 @@ func TestAddHostsToTeamByFilterSourceTeamAuth(t *testing.T) {
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: id}, nil
}
@ -2384,7 +2365,8 @@ func TestHostEncryptionKey(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil, opts)
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
require.Equal(t, tt.host.ID, id)
@ -2401,9 +2383,7 @@ func TestHostEncryptionKey(t *testing.T) {
return &fleet.HostArchivedDiskEncryptionKey{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act := activity.(fleet.ActivityTypeReadHostDiskEncryptionKey)
require.Equal(t, tt.host.ID, act.HostID)
require.Equal(t, []uint{tt.host.ID}, act.HostIDs())
@ -2448,7 +2428,8 @@ func TestHostEncryptionKey(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil, opts)
ctx = test.UserContext(ctx, test.UserAdmin)
hostErr := errors.New("host error")
@ -2485,9 +2466,7 @@ func TestHostEncryptionKey(t *testing.T) {
return &fleet.HostDiskEncryptionKey{Base64Encrypted: "key"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
return errors.New("activity error")
}
@ -2531,11 +2510,6 @@ func TestHostEncryptionKey(t *testing.T) {
ds.GetHostArchivedDiskEncryptionKeyFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostArchivedDiskEncryptionKey, error) {
return &fleet.HostArchivedDiskEncryptionKey{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
@ -2576,11 +2550,6 @@ func TestHostEncryptionKey(t *testing.T) {
ds.GetHostArchivedDiskEncryptionKeyFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostArchivedDiskEncryptionKey, error) {
return &fleet.HostArchivedDiskEncryptionKey{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { // needed for new activity
return &fleet.AppConfig{}, nil
}
@ -3057,11 +3026,6 @@ func TestLockUnlockWipeHostAuth(t *testing.T) {
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, platform string, ts time.Time) error {
return nil
}
@ -3751,9 +3715,6 @@ func TestSetHostDeviceMapping(t *testing.T) {
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
return []*fleet.HostDeviceMapping{{HostID: hostID, Email: "user@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
@ -3789,9 +3750,6 @@ func TestSetHostDeviceMapping(t *testing.T) {
ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
return []*fleet.HostDeviceMapping{{HostID: hostID, Email: "any@username.com", Source: fleet.DeviceMappingMDMIdpAccounts}}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
@ -3901,7 +3859,8 @@ func TestSetHostDeviceMapping(t *testing.T) {
func TestDeleteHostDeviceIDPMapping(t *testing.T) {
t.Run("success by admin on premium", func(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}
svc, ctx := newTestService(t, ds, nil, nil, opts)
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return &fleet.Host{ID: 1}, nil
@ -3912,19 +3871,17 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
userCtx := test.UserContext(ctx, test.UserAdmin)
err := svc.DeleteHostIDP(userCtx, 1)
require.True(t, ds.DeleteHostIDPFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
require.NoError(t, err)
})
t.Run("failure by admin on free", func(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}})
opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}}
svc, ctx := newTestService(t, ds, nil, nil, opts)
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return &fleet.Host{ID: 1}, nil
@ -3935,9 +3892,6 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
userCtx := test.UserContext(ctx, test.UserAdmin)
err := svc.DeleteHostIDP(userCtx, 1)
@ -3945,7 +3899,7 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
assert.Equal(t, fleet.ErrMissingLicense, err)
require.False(t, ds.DeleteHostIDPFuncInvoked)
require.False(t, ds.NewActivityFuncInvoked)
require.False(t, opts.ActivityMock.NewActivityFuncInvoked)
})
t.Run("authorization tests", func(t *testing.T) {
@ -3953,7 +3907,8 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
globalHost := &fleet.Host{ID: 2}
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}
svc, ctx := newTestService(t, ds, nil, nil, opts)
ds.DeleteHostIDPFunc = func(ctx context.Context, id uint) error {
return nil
@ -3961,9 +3916,6 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
testCases := []struct {
name string
@ -4124,7 +4076,7 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
for _, tc := range testCases {
// reset ds mock flags
ds.DeleteHostIDPFuncInvoked = false
ds.NewActivityFuncInvoked = false
opts.ActivityMock.NewActivityFuncInvoked = false
// redefine this datastore mock for each test case since its return value is specific per case
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
@ -4143,11 +4095,11 @@ func TestDeleteHostDeviceIDPMapping(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
require.False(t, ds.DeleteHostIDPFuncInvoked)
require.False(t, ds.NewActivityFuncInvoked)
require.False(t, opts.ActivityMock.NewActivityFuncInvoked)
} else {
require.NoError(t, err)
require.True(t, ds.DeleteHostIDPFuncInvoked)
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
})
}

View file

@ -182,11 +182,6 @@ func setupAuthTest(t *testing.T) (fleet.Datastore, map[string]fleet.User, *httpt
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
return ds, usersMap, server
}

View file

@ -26,6 +26,7 @@ import (
"github.com/WatchBeam/clock"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -3376,15 +3377,15 @@ func (s *integrationTestSuite) TestListActivities() {
prevActivities := s.listActivities()
timestamp := time.Now()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
err := s.ds.NewActivity(ctx, &u, fleet.ActivityTypeAppliedSpecPack{}, nil, timestamp)
activitySvc := mysql.NewTestActivityService(t, s.ds)
apiUser := &activity_api.User{ID: u.ID, Name: u.Name, Email: u.Email}
err := activitySvc.NewActivity(ctx, apiUser, fleet.ActivityTypeAppliedSpecPack{})
require.NoError(t, err)
err = s.ds.NewActivity(ctx, &u, fleet.ActivityTypeDeletedPack{}, nil, timestamp)
err = activitySvc.NewActivity(ctx, apiUser, fleet.ActivityTypeDeletedPack{})
require.NoError(t, err)
err = s.ds.NewActivity(ctx, &u, fleet.ActivityTypeEditedPack{}, nil, timestamp)
err = activitySvc.NewActivity(ctx, apiUser, fleet.ActivityTypeEditedPack{})
require.NoError(t, err)
lenPage := len(prevActivities) + 2

View file

@ -59,7 +59,7 @@ func (s *liveQueriesTestSuite) SetupSuite() {
lq := live_query_mock.New(s.T())
s.lq = lq
opts := &TestServerOpts{Lq: lq, Rs: rs}
opts := &TestServerOpts{Lq: lq, Rs: rs, DBConns: s.dbConns}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
opts.Logger = logging.NewNopLogger()
}

View file

@ -84,7 +84,7 @@ import (
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
activitiesmod "github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/fleetdm/fleet/v4/server/service/osquery_utils"
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/fleetdm/fleet/v4/server/test"
@ -221,7 +221,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
wlog = logging.NewNopLogger()
}
activityModule := activities.NewActivityModule(s.ds, wlog)
activityModule := activitiesmod.NewActivityModule()
androidMockClient := &android_mock.Client{}
androidMockClient.SetAuthenticationSecretFunc = func(secret string) error {
return nil
@ -319,6 +319,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
BootstrapPackageStore: bootstrapPackageStore,
androidMockClient: androidMockClient,
androidModule: androidSvc,
ActivityModule: activityModule,
StartCronSchedules: []TestNewScheduleFunc{
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
@ -452,9 +453,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("cron_iphone_ipad_refetcher", func(ctx context.Context) error {
return apple_mdm.IOSiPadOSRefetch(ctx, ds, mdmCommander, logger.SlogLogger(), func(ctx context.Context, user *fleet.User, act fleet.ActivityDetails) error {
return newActivity(ctx, user, act, ds, logger)
})
return apple_mdm.IOSiPadOSRefetch(ctx, ds, mdmCommander, logger.SlogLogger(), s.fleetSvc.NewActivity)
}),
)
return refetcherSchedule, nil
@ -484,6 +483,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.server = server
s.users = users
s.fleetSvc = svc
s.token = s.getTestAdminToken()
s.cachedAdminToken = s.token
s.fleetCfg = fleetCfg
@ -19744,9 +19744,7 @@ func (s *integrationMDMTestSuite) TestIOSiPadOSRefetch() {
return nil, errors.New("unknown device")
}
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), func(ctx context.Context, user *fleet.User, act fleet.ActivityDetails) error {
return newActivity(ctx, user, act, s.ds, s.logger)
})
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), s.fleetSvc.NewActivity)
require.NoError(s.T(), err) // Verify it not longer throws an error
// Verify successful is still enrolled
@ -20760,9 +20758,7 @@ func (s *integrationMDMTestSuite) TestInstalledApplicationListCommandForBYODiDev
checkExpectedCommands(mdmClientDEP, false, 1)
// run the cron-based refetch, will not do anything as the devices were just refetched
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), func(ctx context.Context, user *fleet.User, act fleet.ActivityDetails) error {
return newActivity(ctx, user, act, s.ds, s.logger)
})
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), s.fleetSvc.NewActivity)
require.NoError(t, err)
checkExpectedCommands(mdmClientBYOD, true, 0)
@ -20775,9 +20771,7 @@ func (s *integrationMDMTestSuite) TestInstalledApplicationListCommandForBYODiDev
require.NoError(t, s.ds.UpdateHost(ctx, hostDEP))
// run the cron-based refetch again, will enqueue the commands with the correct managed only flag
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), func(ctx context.Context, user *fleet.User, act fleet.ActivityDetails) error {
return newActivity(ctx, user, act, s.ds, s.logger)
})
err = apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger.SlogLogger(), s.fleetSvc.NewActivity)
require.NoError(t, err)
checkExpectedCommands(mdmClientBYOD, true, 1)

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
@ -12,7 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/integrationtest"
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
activitiesmod "github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/stretchr/testify/require"
)
@ -26,7 +27,7 @@ func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
logger := logging.NewLogfmtLogger(os.Stdout)
proxy := android_mock.Client{}
proxy.InitCommonMocks()
activityModule := activities.NewActivityModule(ds, logger)
activityModule := activitiesmod.NewActivityModule()
androidSvc, err := android_service.NewServiceWithClient(
logger.SlogLogger(),
ds,
@ -41,14 +42,17 @@ func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
)
require.NoError(t, err)
androidSvc.(*android_service.Service).AllowLocalhostServerURL = true
dbConns := mysql.TestDBConnections(t, ds)
users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierFree,
},
FleetConfig: &fleetCfg,
Pool: redisPool,
Logger: logger,
FeatureRoutes: []endpointer.HandlerRoutesFunc{android_service.GetRoutes(fleetSvc, androidSvc)},
FleetConfig: &fleetCfg,
Pool: redisPool,
Logger: logger,
FeatureRoutes: []endpointer.HandlerRoutesFunc{android_service.GetRoutes(fleetSvc, androidSvc)},
DBConns: dbConns,
ActivityModule: activityModule,
})
s := &Suite{

View file

@ -3617,7 +3617,7 @@ func (svc *Service) UnenrollMDM(ctx context.Context, hostID uint) error {
}
installedFromDEP = info.InstalledFromDEP
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, newActivity)
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionTurnOff,
Platform: host.Platform,

View file

@ -20,6 +20,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
@ -134,10 +135,6 @@ func TestMDMAppleAuthorization(t *testing.T) {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
return nil, nil
}
@ -1213,9 +1210,6 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) {
},
}, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.GetMDMWindowsConfigProfileFunc = func(ctx context.Context, pid string) (*fleet.MDMWindowsConfigProfile, error) {
var tid uint
if pid == "team-1" {
@ -1313,9 +1307,6 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) {
}
return &fleet.Team{ID: tid, Name: "team1"}, nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
ds.NewMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile, usesFleetVars []fleet.FleetVarName) (*fleet.MDMWindowsConfigProfile, error) {
if bytes.Contains(cp.SyncML, []byte("duplicate")) {
return nil, &alreadyExistsError{}
@ -1431,11 +1422,6 @@ func TestMDMBatchSetProfiles(t *testing.T) {
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string,
hostUUIDs []string,
) (updates fleet.MDMProfilesUpdates, err error) {
@ -2231,9 +2217,6 @@ 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, []byte, time.Time) error {
return nil
}
ds.BatchResendMDMProfileToHostsFunc = func(ctx context.Context, profUUID string, filters fleet.BatchResendMDMProfileFilters) (int64, error) {
return 0, nil
}
@ -2551,7 +2534,8 @@ func TestUploadMDMAppleAPNSCertReplacesFileVaultProfile(t *testing.T) {
// We want to verify here that the disk encryption profile get's deleted for apple.
ds := new(mock.Store)
lic := &fleet.LicenseInfo{Tier: fleet.TierPremium}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true, License: lic})
opts := &TestServerOpts{SkipCreateTestUsers: true, License: lic}
svc, ctx := newTestService(t, ds, nil, nil, opts)
ctx = test.UserContext(ctx, test.UserAdmin)
ctx = license.NewContext(ctx, lic)
@ -2598,7 +2582,7 @@ func TestUploadMDMAppleAPNSCertReplacesFileVaultProfile(t *testing.T) {
}
newActivityCalls := 0
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act := fleet.ActivityTypeEnabledMacosDiskEncryption{}
require.Equal(t, act.ActivityName(), activity.ActivityName())
newActivityCalls++
@ -2686,11 +2670,6 @@ func TestNewMDMProfilePremiumOnlyAndroid(t *testing.T) {
ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: id, Name: "team"}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}

View file

@ -2123,7 +2123,7 @@ func (svc *Service) storeWindowsMDMEnrolledDevice(ctx context.Context, userID st
displayName := reqDeviceName
var serial string
if hostUUID != "" {
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, newActivity)
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionTurnOn,
Platform: "windows",

View file

@ -2,118 +2,44 @@ package activities
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/cenkalti/backoff"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/go-kit/log/level"
)
type activityModule struct {
repo ActivityStore
logger *logging.Logger
}
// ActivityModule is a thin facade that translates fleet types to bounded context
// types and delegates to the activity bounded context service.
type ActivityModule interface {
NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
}
// ActivityStore is the datastore interface needed to handle Fleet activities.
// It is implemented by fleet.Datastore.
type ActivityStore interface {
AppConfig(ctx context.Context) (*fleet.AppConfig, error)
NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error
// Module is the concrete implementation of ActivityModule. It holds a reference
// to the activity bounded context service, which must be set via SetService
// before any calls to NewActivity.
type Module struct {
svc activity_api.NewActivityService
}
func NewActivityModule(repo ActivityStore, logger *logging.Logger) ActivityModule {
return &activityModule{
repo: repo,
logger: logger,
}
// NewActivityModule creates a new activity module. The bounded context service
// must be set via SetService before use.
func NewActivityModule() *Module {
return &Module{}
}
func (a *activityModule) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
appConfig, err := a.repo.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get app config")
}
// SetService sets the activity bounded context service.
func (m *Module) SetService(svc activity_api.NewActivityService) {
m.svc = svc
}
detailsBytes, err := json.Marshal(activity)
if err != nil {
return ctxerr.Wrap(ctx, err, "marshaling activity details")
}
// Duplicate JSON keys so that stored activity details include both the
// old and new field names (e.g. team_id and fleet_id).
if rules := endpointer.ExtractAliasRules(activity); len(rules) > 0 {
detailsBytes = endpointer.DuplicateJSONKeys(detailsBytes, rules, endpointer.DuplicateJSONKeysOpts{Compact: true})
}
timestamp := time.Now()
if appConfig.WebhookSettings.ActivitiesWebhook.Enable {
webhookURL := appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL
var userID *uint
var userName *string
var userEmail *string
activityType := activity.ActivityName()
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
// the installation.
if user.ID != 0 && !user.Deleted {
userID = &user.ID
}
userName = &user.Name
userEmail = &user.Email
} else if automatableActivity, ok := activity.(fleet.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
userName = ptr.String(fleet.ActivityAutomationAuthor)
func (m *Module) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{
ID: user.ID,
Name: user.Name,
Email: user.Email,
Deleted: user.Deleted,
}
// TODO: webhook module? probably webhook job too tbh since this isn't very resilient
go func() {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 30 * time.Minute
err := backoff.Retry(
func() error {
if err := server.PostJSONWithTimeout(
context.Background(), webhookURL, &fleet.ActivityWebhookPayload{
Timestamp: timestamp,
ActorFullName: userName,
ActorID: userID,
ActorEmail: userEmail,
Type: activityType,
Details: (*json.RawMessage)(&detailsBytes),
},
); err != nil {
var statusCoder kithttp.StatusCoder
if errors.As(err, &statusCoder) && statusCoder.StatusCode() == http.StatusTooManyRequests {
level.Debug(a.logger).Log("msg", "fire activity webhook", "err", err)
return err
}
return backoff.Permanent(err)
}
return nil
}, retryStrategy,
)
if err != nil {
level.Error(a.logger).Log(
"msg", fmt.Sprintf("fire activity webhook to %s", server.MaskSecretURLParams(webhookURL)), "err",
server.MaskURLError(err).Error(),
)
}
}()
}
// We update the context to indicate that we processed the webhook.
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
return a.repo.NewActivity(ctx, user, activity, detailsBytes, timestamp)
return m.svc.NewActivity(ctx, apiUser, activity)
}

View file

@ -6,9 +6,9 @@ import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
@ -175,7 +175,8 @@ func TestOrbitLUKSDataSave(t *testing.T) {
t.Run("when private key is set", func(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
opts := &TestServerOpts{License: license, SkipCreateTestUsers: true}
svc, ctx := newTestService(t, ds, nil, nil, opts)
host := &fleet.Host{
OsqueryHostID: ptr.String("test"),
ID: 1,
@ -190,15 +191,8 @@ func TestOrbitLUKSDataSave(t *testing.T) {
}, nil
}
ds.NewActivityFunc = func(
ctx context.Context,
user *fleet.User,
activity fleet.ActivityDetails,
details []byte,
createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
require.Equal(t, activity.ActivityName(), fleet.ActivityTypeEscrowedDiskEncryptionKey{}.ActivityName())
require.NotEmpty(t, details)
return nil
}
@ -262,6 +256,7 @@ func TestOrbitLUKSDataSave(t *testing.T) {
require.NoError(t, err)
require.False(t, ds.ReportEscrowErrorFuncInvoked)
require.True(t, ds.SaveLUKSDataFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
})
t.Run("fail when no/invalid private key is set", func(t *testing.T) {

View file

@ -341,10 +341,6 @@ func TestEnrollOsqueryEnforceLimit(t *testing.T) {
ds.GetHostIdentityCertByNameFunc = func(ctx context.Context, name string) (*types.HostIdentityCertificate, error) {
return nil, newNotFoundError()
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
redisWrapDS := mysqlredis.New(ds, pool, mysqlredis.WithEnforcedHostLimit(maxHosts))
svc, ctx := newTestService(t, redisWrapDS, nil, nil, &TestServerOpts{
EnrollHostLimiter: redisWrapDS,
@ -3220,11 +3216,6 @@ func TestObserversCanOnlyRunDistributedCampaigns(t *testing.T) {
})
q := "select year, month, day, hour, minutes, seconds from time"
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
_, err := svc.NewDistributedQueryCampaign(viewerCtx, q, nil, fleet.HostTargets{HostIDs: []uint{2}, LabelIDs: []uint{1}})
require.Error(t, err)
@ -3260,11 +3251,6 @@ func TestObserversCanOnlyRunDistributedCampaigns(t *testing.T) {
ds.HostIDsInTargetsFunc = func(ctx context.Context, filter fleet.TeamFilter, targets fleet.HostTargets) ([]uint, error) {
return []uint{1, 3, 5}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
lq.On("RunQuery", "21", "select 1;", []uint{1, 3, 5}).Return(nil)
_, err = svc.NewDistributedQueryCampaign(viewerCtx, "", ptr.Uint(42), fleet.HostTargets{HostIDs: []uint{2}, LabelIDs: []uint{1}})
require.NoError(t, err)
@ -3302,11 +3288,6 @@ func TestTeamMaintainerCanRunNewDistributedCampaigns(t *testing.T) {
})
q := "select year, month, day, hour, minutes, seconds from time"
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
// var gotQuery *fleet.Query
ds.NewQueryFunc = func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) {
// gotQuery = query
@ -3326,11 +3307,6 @@ func TestTeamMaintainerCanRunNewDistributedCampaigns(t *testing.T) {
ds.HostIDsInTargetsFunc = func(ctx context.Context, filter fleet.TeamFilter, targets fleet.HostTargets) ([]uint, error) {
return []uint{1, 3, 5}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
lq.On("RunQuery", "0", "select year, month, day, hour, minutes, seconds from time", []uint{1, 3, 5}).Return(nil)
_, err := svc.NewDistributedQueryCampaign(viewerCtx, q, nil, fleet.HostTargets{HostIDs: []uint{2}, LabelIDs: []uint{1}, TeamIDs: []uint{123}})
require.NoError(t, err)

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@ -38,7 +37,8 @@ func TestGetPack(t *testing.T) {
func TestNewPackSavesTargets(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
ds.NewPackFunc = func(ctx context.Context, pack *fleet.Pack, opts ...fleet.OptionalArg) (*fleet.Pack, error) {
return pack, nil
@ -46,12 +46,6 @@ func TestNewPackSavesTargets(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
packPayload := fleet.PackPayload{
Name: ptr.String("foo"),
HostIDs: &[]uint{123},
@ -68,7 +62,7 @@ func TestNewPackSavesTargets(t *testing.T) {
assert.Equal(t, uint(456), pack.LabelIDs[0])
assert.Equal(t, uint(789), pack.TeamIDs[0])
assert.True(t, ds.NewPackFuncInvoked)
assert.True(t, ds.NewActivityFuncInvoked)
assert.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestPacksWithDS(t *testing.T) {

View file

@ -6,6 +6,7 @@ import (
"testing"
"time"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -23,15 +24,14 @@ func TestQueryPayloadValidationCreate(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act, ok := activity.(fleet.ActivityTypeCreatedSavedQuery)
assert.True(t, ok)
assert.NotEmpty(t, act.Name)
return nil
}
svc, ctx := newTestService(t, ds, nil, nil)
testCases := []struct {
name string
@ -153,17 +153,15 @@ func TestQueryPayloadValidationModify(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act, ok := activity.(fleet.ActivityTypeEditedSavedQuery)
assert.True(t, ok)
assert.NotEmpty(t, act.Name)
return nil
}
svc, ctx := newTestService(t, ds, nil, nil)
testCases := []struct {
name string
queryPayload fleet.QueryPayload
@ -372,11 +370,6 @@ func TestQueryAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
if id == 99 { //nolint:gocritic // ignore ifElseChain
return &globalQuery, nil
@ -967,11 +960,6 @@ func TestApplyQuerySpec(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
return nil, newNotFoundError()
}

View file

@ -539,11 +539,6 @@ func TestSavedScripts(t *testing.T) {
ds.ListScriptsFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
return nil, &fleet.PaginationMetadata{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: 0}, nil
}

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/WatchBeam/clock"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -71,6 +72,10 @@ type Service struct {
keyValueStore fleet.KeyValueStore
androidSvc android.Service
// activitySvc is the activity bounded context service for creating activities.
// When set, NewActivity delegates to this service instead of the legacy implementation.
activitySvc activity_api.NewActivityService
}
// ConditionalAccessMicrosoftProxy is the interface of the Microsoft compliance proxy.
@ -192,6 +197,12 @@ func (svc *Service) SendEmail(ctx context.Context, mail fleet.Email) error {
return svc.mailService.SendEmail(ctx, mail)
}
// SetActivityService sets the activity bounded context service for creating activities.
// This should be called after NewService to inject the activity service dependency.
func (svc *Service) SetActivityService(activitySvc activity_api.NewActivityService) {
svc.activitySvc = activitySvc
}
type validationMiddleware struct {
fleet.Service
ds fleet.Datastore

View file

@ -5,7 +5,6 @@ import (
"fmt"
"runtime"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -102,9 +101,6 @@ func TestEmptyEnrollSecret(t *testing.T) {
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
return nil, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
err := svc.ApplyEnrollSecretSpec(
test.UserContext(ctx, test.UserAdmin),

View file

@ -61,11 +61,6 @@ func TestStreamCampaignResultsClosesReditOnWSClose(t *testing.T) {
ds.CountHostsInTargetsFunc = func(ctx context.Context, filter fleet.TeamFilter, targets fleet.HostTargets, now time.Time) (fleet.TargetMetrics, error) {
return fleet.TargetMetrics{TotalHosts: 1}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.SessionByKeyFunc = func(ctx context.Context, key string) (*fleet.Session, error) {
return &fleet.Session{
CreateTimestamp: fleet.CreateTimestamp{CreatedAt: time.Now()},

View file

@ -5,6 +5,7 @@ import (
"testing"
"time"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
@ -154,7 +155,8 @@ func TestAuthenticate(t *testing.T) {
func TestMFA(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
user := &fleet.User{MFAEnabled: true, Name: "Bob Smith", Email: "foo@example.com"}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
@ -200,15 +202,15 @@ func TestMFA(t *testing.T) {
session = &fleet.Session{}
mfaUser = user
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
require.Equal(t, mfaUser, user)
opts.ActivityMock.NewActivityFunc = func(_ context.Context, user *activity_api.User, activity activity_api.ActivityDetails) error {
require.Equal(t, mfaUser.Email, user.Email)
require.Equal(t, fleet.ActivityTypeUserLoggedIn{}.ActivityName(), activity.ActivityName())
return nil
}
resp, err = sessionCreateEndpoint(ctx, &sessionCreateRequest{Token: mfaToken}, svc)
require.NoError(t, err)
require.Nil(t, resp.Error())
require.True(t, ds.NewActivityFuncInvoked)
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestGetSessionByKey(t *testing.T) {
@ -300,12 +302,6 @@ func TestGetSSOUser(t *testing.T) {
},
})
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
SSOSettings: &fleet.SSOSettings{

View file

@ -111,12 +111,6 @@ func TestSoftwareInstallersAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
if tt.teamID != nil {
return &fleet.TeamLite{ID: *tt.teamID}, nil

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/s3"
@ -116,10 +117,11 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
ds := new(mock.Store)
mockIconStore := s3.SetupTestSoftwareTitleIconStore(t, "software-title-icons-unit-test", "icon-store-prefix")
svc, _ := newTestService(t, ds, nil, nil, &TestServerOpts{
opts := &TestServerOpts{
License: &fleet.LicenseInfo{Tier: fleet.TierPremium},
SoftwareTitleIconStore: mockIconStore,
})
}
svc, _ := newTestService(t, ds, nil, nil, opts)
defer func() {
_, err := mockIconStore.Cleanup(ctx, []string{}, time.Now().Add(time.Hour))
require.NoError(t, err)
@ -128,8 +130,8 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
var capturedActivities []fleet.ActivityDetails
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, detailsBytes []byte, timestamp time.Time) error {
var capturedActivities []activity_api.ActivityDetails
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
capturedActivities = append(capturedActivities, activity)
return nil
}
@ -143,7 +145,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
{
name: "upload icon title with no software installer, vpp app, or in-house app",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, includeUnpublished bool) (*fleet.SoftwareInstaller, error) {
return nil, ctxerr.Wrap(ctx, &common_mysql.NotFoundError{ResourceType: "SoftwareInstaller"}, "get software installer")
}
@ -175,7 +177,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
{
name: "upload icon for software installer",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, includeUnpublished bool) (*fleet.SoftwareInstaller, error) {
return &fleet.SoftwareInstaller{
TitleID: ptr.Uint(1),
@ -225,7 +227,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
{
name: "upload icon for vpp app",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, includeUnpublished bool) (*fleet.SoftwareInstaller, error) {
return nil, ctxerr.Wrap(ctx, &common_mysql.NotFoundError{ResourceType: "SoftwareInstaller"}, "get software installer")
}
@ -279,7 +281,7 @@ func TestUploadSoftwareTitleIcon(t *testing.T) {
{
name: "upload icon for in-house app",
before: func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
return &fleet.SoftwareInstaller{
TitleID: ptr.Uint(1),
@ -340,14 +342,15 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) {
user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: user})
ds := new(mock.Store)
svc, _ := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
deleteOpts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}
svc, _ := newTestService(t, ds, nil, nil, deleteOpts)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
var capturedActivities []fleet.ActivityDetails
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, detailsBytes []byte, timestamp time.Time) error {
var capturedActivities []activity_api.ActivityDetails
deleteOpts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
capturedActivities = append(capturedActivities, activity)
return nil
}
@ -360,7 +363,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) {
{
"Delete existing icon for software installer",
func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.ActivityDetailsForSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) (fleet.DetailsForSoftwareIconActivity, error) {
return fleet.DetailsForSoftwareIconActivity{
SoftwareInstallerID: ptr.Uint(1),
@ -406,7 +409,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) {
{
"Delete existing icon for vpp app",
func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.ActivityDetailsForSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) (fleet.DetailsForSoftwareIconActivity, error) {
platform := fleet.MacOSPlatform
return fleet.DetailsForSoftwareIconActivity{
@ -454,7 +457,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) {
{
"Delete an already deleted icon",
func() {
capturedActivities = make([]fleet.ActivityDetails, 0)
capturedActivities = make([]activity_api.ActivityDetails, 0)
ds.ActivityDetailsForSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) (fleet.DetailsForSoftwareIconActivity, error) {
return fleet.DetailsForSoftwareIconActivity{}, nil
}

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@ -60,11 +59,6 @@ func TestTeamPoliciesAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: 1}, nil
}

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -40,11 +39,6 @@ func TestTeamScheduleAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.NewQueryFunc = func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return &fleet.Query{}, nil
}

View file

@ -7,6 +7,7 @@ import (
"testing"
"time"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -27,11 +28,6 @@ func TestTeamAuth(t *testing.T) {
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
return &fleet.Team{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{}, nil
}
@ -218,7 +214,8 @@ func TestTeamAuth(t *testing.T) {
func TestApplyTeamSpecs(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
opts := &TestServerOpts{License: license, SkipCreateTestUsers: true}
svc, ctx := newTestService(t, ds, nil, nil, opts)
user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
baseFeatures := fleet.Features{
@ -297,9 +294,7 @@ func TestApplyTeamSpecs(t *testing.T) {
return team, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act := activity.(fleet.ActivityTypeAppliedSpecTeam)
require.Len(t, act.Teams, 1)
return nil
@ -383,9 +378,7 @@ func TestApplyTeamSpecs(t *testing.T) {
return &fleet.Team{ID: 123}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
act := activity.(fleet.ActivityTypeAppliedSpecTeam)
require.Len(t, act.Teams, 1)
return nil
@ -418,12 +411,6 @@ func TestApplyTeamSpecEnrollSecretForNewTeams(t *testing.T) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
t.Run("creates enroll secret when not included for a new team spec", func(t *testing.T) {
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
require.Len(t, team.Secrets, 1)

View file

@ -81,6 +81,8 @@ type withServer struct {
lq *live_query_mock.MockLiveQuery
redisPool fleet.RedisPool
fleetSvc fleet.Service
}
func (ts *withServer) SetupSuite(dbName string) {

View file

@ -23,7 +23,9 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/service/est"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
@ -47,6 +49,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
fleet_mock "github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
platformlogging "github.com/fleetdm/fleet/v4/server/platform/logging"
@ -55,6 +58,7 @@ import (
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/mock"
activitiesmod "github.com/fleetdm/fleet/v4/server/service/modules/activities"
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
"github.com/fleetdm/fleet/v4/server/sso"
@ -282,6 +286,19 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
}
}
// Set up mock activity service for unit tests. When DBConns is provided,
// RunServerForTestsWithServiceWithDS will overwrite this with the real bounded context.
activityMock := &fleet_mock.MockNewActivityService{
NewActivityFunc: func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
return nil
},
}
svc.SetActivityService(activityMock)
if len(opts) > 0 {
opts[0].ActivityMock = activityMock
}
return svc, ctx
}
@ -420,8 +437,13 @@ type TestServerOpts struct {
HostIdentity *HostIdentity
androidMockClient *android_mock.Client
androidModule android.Service
ActivityModule *activitiesmod.Module
ConditionalAccess *ConditionalAccess
DBConns *common_mysql.DBConnections
// ActivityMock is populated automatically by newTestServiceWithConfig.
// After setup, tests can use it to intercept or assert on activity creation.
ActivityMock *fleet_mock.MockNewActivityService
}
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {
@ -464,12 +486,17 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
require.NoError(t, err)
activityAuthorizer := authz.NewAuthorizerAdapter(legacyAuthorizer)
activityACLAdapter := activityacl.NewFleetServiceAdapter(svc)
_, activityRoutesFn := activity_bootstrap.New(
activitySvc, activityRoutesFn := activity_bootstrap.New(
opts[0].DBConns,
activityAuthorizer,
activityACLAdapter,
server.PostJSONWithTimeout,
logger.SlogLogger(),
)
svc.SetActivityService(activitySvc)
if opts[0].ActivityModule != nil {
opts[0].ActivityModule.SetService(activitySvc)
}
activityAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(svc, next)
}
@ -501,8 +528,8 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher)
if mdmStorage != nil && scepStorage != nil {
vppInstaller := svc.(fleet.AppleMDMVPPInstaller)
checkInAndCommand := NewMDMAppleCheckinAndCommandService(ds, commander, vppInstaller, opts[0].License.IsPremium(), logger, redis_key_value.New(redisPool))
checkInAndCommand.RegisterResultsHandler("InstalledApplicationList", NewInstalledApplicationListResultsHandler(ds, commander, logger, cfg.Server.VPPVerifyTimeout, cfg.Server.VPPVerifyRequestDelay))
checkInAndCommand := NewMDMAppleCheckinAndCommandService(ds, commander, vppInstaller, opts[0].License.IsPremium(), logger, redis_key_value.New(redisPool), svc.NewActivity)
checkInAndCommand.RegisterResultsHandler("InstalledApplicationList", NewInstalledApplicationListResultsHandler(ds, commander, logger, cfg.Server.VPPVerifyTimeout, cfg.Server.VPPVerifyRequestDelay, svc.NewActivity))
checkInAndCommand.RegisterResultsHandler(fleet.DeviceLocationCmdName, NewDeviceLocationResultsHandler(ds, commander, logger))
err := RegisterAppleMDMProtocolServices(
rootMux,

View file

@ -98,11 +98,6 @@ func TestUserAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
ds.CountGlobalAdminsFunc = func(ctx context.Context) (int, error) {
return 2, nil // Return 2 to allow operations that check for last admin
}
@ -589,9 +584,6 @@ func TestMFAHandling(t *testing.T) {
user.ID = 4
return user, nil
}
ms.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
user, _, err := svc.CreateUser(ctx, payload)
require.NoError(t, err)
require.False(t, user.MFAEnabled)
@ -1542,10 +1534,6 @@ func TestDeleteUserLastAdminProtection(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
_, err := svc.DeleteUser(ctx, adminUser.ID)
require.NoError(t, err)
assert.True(t, ds.DeleteUserFuncInvoked)
@ -1592,10 +1580,6 @@ func TestDeleteUserLastAdminProtection(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
_, err := svc.DeleteUser(ctx, maintainerUser.ID)
require.NoError(t, err)
assert.True(t, ds.DeleteUserFuncInvoked)
@ -1662,10 +1646,6 @@ func TestModifyUserLastAdminProtection(t *testing.T) {
ds.SaveUserFunc = func(ctx context.Context, u *fleet.User) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
_, err := svc.ModifyUser(ctx, adminUser.ID, fleet.UserPayload{
GlobalRole: ptr.String(fleet.RoleMaintainer),
})
@ -1679,10 +1659,6 @@ func TestModifyUserLastAdminProtection(t *testing.T) {
ds.SaveUserFunc = func(ctx context.Context, u *fleet.User) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
_, err := svc.ModifyUser(ctx, adminUser.ID, fleet.UserPayload{
GlobalRole: ptr.String(fleet.RoleAdmin),
})
@ -1732,10 +1708,6 @@ func TestPasswordChangeClearsTokensAndSessions(t *testing.T) {
ds.SaveUserFunc = func(ctx context.Context, u *fleet.User) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
return nil
}
var deletedPasswordResetForUserID uint
ds.DeletePasswordResetRequestsForUserFunc = func(ctx context.Context, userID uint) error {
deletedPasswordResetForUserID = userID

View file

@ -4,7 +4,6 @@ import (
"context"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@ -137,9 +136,8 @@ func CreateHostVPPAppInstallUpcomingActivity(t *testing.T, ds fleet.Datastore, h
// The adamID is the one for the VPP app created by that call, and status is
// one of the Apple MDM status string (Acknowledged, Error, CommandFormatError,
// etc).
func SetHostVPPAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage.CommandAndReportResultsStore, host *fleet.Host, execID, adamID, status string) {
func SetHostVPPAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage.CommandAndReportResultsStore, host *fleet.Host, execID, adamID, status string, newActivityFn fleet.NewActivityFunc) {
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: host.UUID}, Context: ctx}
cmdRes := &mdm.CommandResults{
@ -149,12 +147,12 @@ func SetHostVPPAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage
}
err := nanods.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
err = newActivityFn(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: host.ID,
AppStoreID: adamID,
CommandUUID: execID,
Status: "Error",
}, []byte(`{}`), time.Now())
Status: status,
})
require.NoError(t, err)
}
@ -180,9 +178,8 @@ func CreateHostInHouseAppInstallUpcomingActivity(t *testing.T, ds fleet.Datastor
return execID
}
func SetHostInHouseAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage.CommandAndReportResultsStore, host *fleet.Host, execID, status string) {
func SetHostInHouseAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage.CommandAndReportResultsStore, host *fleet.Host, execID, status string, newActivityFn fleet.NewActivityFunc) {
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: host.UUID}, Context: ctx}
cmdRes := &mdm.CommandResults{
@ -192,10 +189,10 @@ func SetHostInHouseAppInstallResult(t *testing.T, ds fleet.Datastore, nanods sto
}
err := nanods.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityTypeInstalledSoftware{
err = newActivityFn(ctx, nil, fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
CommandUUID: execID,
Status: "Error",
}, []byte(`{}`), time.Now())
Status: status,
})
require.NoError(t, err)
}

View file

@ -14,12 +14,12 @@ import (
"html/template"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/bindata"
platformhttp "github.com/fleetdm/fleet/v4/server/platform/http"
)
// GenerateRandomText return a string generated by filling in keySize bytes with
@ -95,44 +95,15 @@ func PostJSONWithTimeout(ctx context.Context, url string, v interface{}) error {
// MaskSecretURLParams masks URL query values if the query param name includes "secret", "token",
// "key", "password". It accepts a raw string and returns a redacted string if the raw string is
// URL-parseable. If it is not URL-parseable, the raw string is returned unchanged.
func MaskSecretURLParams(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
keywords := []string{"secret", "token", "key", "password"}
containsKeyword := func(s string) bool {
s = strings.ToLower(s)
for _, kw := range keywords {
if strings.Contains(s, kw) {
return true
}
}
return false
}
q := u.Query()
for k := range q {
if containsKeyword(k) {
q[k] = []string{"MASKED"}
}
}
u.RawQuery = q.Encode()
return u.Redacted()
}
//
// Deprecated: Use github.com/fleetdm/fleet/v4/server/platform/http.MaskSecretURLParams instead.
var MaskSecretURLParams = platformhttp.MaskSecretURLParams
// MaskURLError checks if the provided error is a *url.Error. If so, it applies MaskSecretURLParams
// to the URL value and returns the modified error. If not, the error is returned unchanged.
func MaskURLError(e error) error {
ue, ok := e.(*url.Error)
if !ok {
return e
}
ue.URL = MaskSecretURLParams(ue.URL)
return ue
}
//
// Deprecated: Use github.com/fleetdm/fleet/v4/server/platform/http.MaskURLError instead.
var MaskURLError = platformhttp.MaskURLError
// TODO: Consider moving other crypto functions from server/mdm/apple/util to here