diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index 55e3c772ba1..9408027d2e7 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -916,6 +916,36 @@ Generated when a user disables GitOps mode. This activity does not contain any detail fields. +## enabled_gitops_exception + +Generated when a user enables a GitOps exception. + +This activity contains the following fields: +- "exception": Name of the exception that was enabled. One of `"labels"`, `"software"`, `"secrets"`. + +#### Example + +```json +{ + "exception": "labels" +} +``` + +## disabled_gitops_exception + +Generated when a user disables a GitOps exception. + +This activity contains the following fields: +- "exception": Name of the exception that was disabled. One of `"labels"`, `"software"`, `"secrets"`. + +#### Example + +```json +{ + "exception": "software" +} +``` + ## added_bootstrap_package Generated when a user adds a new bootstrap package to a team (or no team). diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index f585ae9c12d..b8138eae156 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -101,6 +101,8 @@ export enum ActivityType { DisabledWindowsMdm = "disabled_windows_mdm", EnabledGitOpsMode = "enabled_gitops_mode", DisabledGitOpsMode = "disabled_gitops_mode", + EnabledGitOpsException = "enabled_gitops_exception", + DisabledGitOpsException = "disabled_gitops_exception", EnabledWindowsMdmMigration = "enabled_windows_mdm_migration", DisabledWindowsMdmMigration = "disabled_windows_mdm_migration", RanScript = "ran_script", @@ -302,6 +304,7 @@ export interface IActivityDetails { certificate_name?: string; certificate_template_id?: number; detail?: string; + exception?: string; } // maps activity types to their corresponding label to use when filtering activites via the dropdown @@ -362,6 +365,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { disabled_android_mdm: "Turned off Android MDM", disabled_conditional_access_automations: "Disabled conditional access automations", + disabled_gitops_exception: "Disabled GitOps exception", disabled_gitops_mode: "Disabled GitOps mode", disabled_macos_disk_encryption: "Turned off disk encryption", disabled_macos_setup_end_user_auth: @@ -393,6 +397,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { enabled_android_mdm: "Turned on Android MDM", enabled_conditional_access_automations: "Enabled conditional access automations", + enabled_gitops_exception: "Enabled GitOps exception", enabled_gitops_mode: "Enabled GitOps mode", enabled_macos_disk_encryption: "Turned on disk encryption", enabled_macos_setup_end_user_auth: diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index b46de6a6954..022e54822aa 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -1817,4 +1817,26 @@ describe("Activity Feed", () => { expect(screen.getByText(/Lions/i)).toBeInTheDocument(); expect(screen.getByText(/fleet/i)).toBeInTheDocument(); }); + + it("renders an enabled_gitops_exception activity", () => { + const activity = createMockActivity({ + type: ActivityType.EnabledGitOpsException, + details: { exception: "labels" }, + }); + render(); + expect( + screen.getByText("enabled the labels exception for GitOps.") + ).toBeInTheDocument(); + }); + + it("renders a disabled_gitops_exception activity", () => { + const activity = createMockActivity({ + type: ActivityType.DisabledGitOpsException, + details: { exception: "software" }, + }); + render(); + expect( + screen.getByText("disabled the software exception for GitOps.") + ).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index b5f366e2cc6..e3446fef245 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -958,6 +958,14 @@ const TAGGED_TEMPLATES = { }, enabledGitOpsMode: () => "enabled GitOps mode in the UI.", disabledGitOpsMode: () => "disabled GitOps mode in the UI.", + enabledGitOpsException: (activity: IActivity) => { + const exception = activity.details?.exception ?? ""; + return `enabled the ${exception} exception for GitOps.`; + }, + disabledGitOpsException: (activity: IActivity) => { + const exception = activity.details?.exception ?? ""; + return `disabled the ${exception} exception for GitOps.`; + }, enabledWindowsMdmMigration: () => { return ( <> @@ -2097,6 +2105,12 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.DisabledGitOpsMode: { return TAGGED_TEMPLATES.disabledGitOpsMode(); } + case ActivityType.EnabledGitOpsException: { + return TAGGED_TEMPLATES.enabledGitOpsException(activity); + } + case ActivityType.DisabledGitOpsException: { + return TAGGED_TEMPLATES.disabledGitOpsException(activity); + } case ActivityType.EnabledWindowsMdmMigration: { return TAGGED_TEMPLATES.enabledWindowsMdmMigration(); } diff --git a/server/fleet/activities.go b/server/fleet/activities.go index b820b085dec..502197220d2 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -129,6 +129,8 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEnabledGitOpsMode{}, ActivityTypeDisabledGitOpsMode{}, + ActivityTypeEnabledGitOpsException{}, + ActivityTypeDisabledGitOpsException{}, ActivityTypeAddedBootstrapPackage{}, ActivityTypeDeletedBootstrapPackage{}, @@ -862,6 +864,22 @@ func (a ActivityTypeDisabledGitOpsMode) ActivityName() string { return "disabled_gitops_mode" } +type ActivityTypeEnabledGitOpsException struct { + Exception string `json:"exception"` +} + +func (a ActivityTypeEnabledGitOpsException) ActivityName() string { + return "enabled_gitops_exception" +} + +type ActivityTypeDisabledGitOpsException struct { + Exception string `json:"exception"` +} + +func (a ActivityTypeDisabledGitOpsException) ActivityName() string { + return "disabled_gitops_exception" +} + type ActivityTypeAddedBootstrapPackage struct { BootstrapPackageName string `json:"bootstrap_package_name"` TeamID *uint `json:"team_id" renameto:"fleet_id"` diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 275344421ec..9853b30eb9e 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -868,6 +868,32 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, err } + oldExceptions := oldAppConfig.GitOpsConfig.Exceptions + newExceptions := appConfig.GitOpsConfig.Exceptions + exceptionChanges := []struct { + name string + oldEnabled bool + newEnabled bool + }{ + {"labels", oldExceptions.Labels, newExceptions.Labels}, + {"software", oldExceptions.Software, newExceptions.Software}, + {"secrets", oldExceptions.Secrets, newExceptions.Secrets}, + } + for _, c := range exceptionChanges { + if c.oldEnabled == c.newEnabled { + continue + } + var act fleet.ActivityDetails + if c.newEnabled { + act = fleet.ActivityTypeEnabledGitOpsException{Exception: c.name} + } else { + act = fleet.ActivityTypeDisabledGitOpsException{Exception: c.name} + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName()) + } + } + addedEntraTenantIDs := make([]string, 0) removedEntraTenantIDs := make([]string, 0) oldTenantIDSet := make(map[string]struct{}) diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 60f497c6dc5..432fe8d28ff 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -16,6 +16,7 @@ import ( "testing" "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/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -2014,3 +2015,135 @@ func TestModifyAppConfigGoogleCalendarAPIKey(t *testing.T) { require.True(t, updatedAppConfig.Integrations.GoogleCalendar[0].ApiKey.IsMasked()) }) } + +func TestModifyAppConfigGitOpsExceptionActivities(t *testing.T) { + admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} + + type exceptionActivity struct { + name string // activity name: "enabled_gitops_exception" or "disabled_gitops_exception" + exception string + } + + testCases := []struct { + name string + initial fleet.GitOpsExceptions + patch string + expectFired []exceptionActivity + }{ + { + name: "no change fires no activity", + initial: fleet.GitOpsExceptions{Labels: true, Software: false, Secrets: true}, + patch: `{"gitops": {"exceptions": {"labels": true, "software": false, "secrets": true}}}`, + expectFired: nil, + }, + { + name: "single field flip fires one activity", + initial: fleet.GitOpsExceptions{Labels: false, Software: false, Secrets: true}, + patch: `{"gitops": {"exceptions": {"labels": true, "software": false, "secrets": true}}}`, + expectFired: []exceptionActivity{ + {name: "enabled_gitops_exception", exception: "labels"}, + }, + }, + { + name: "multiple field flips fire one activity each, in order", + initial: fleet.GitOpsExceptions{Labels: false, Software: true, Secrets: false}, + patch: `{"gitops": {"exceptions": {"labels": true, "software": false, "secrets": true}}}`, + expectFired: []exceptionActivity{ + {name: "enabled_gitops_exception", exception: "labels"}, + {name: "disabled_gitops_exception", exception: "software"}, + {name: "enabled_gitops_exception", exception: "secrets"}, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ds := new(mock.Store) + opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}} + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + dsAppConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + GitOpsConfig: fleet.GitOpsConfig{ + GitopsModeEnabled: true, + RepositoryURL: "https://example.com/repo", + Exceptions: tt.initial, + }, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return dsAppConfig, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + *dsAppConfig = *conf + return nil + } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { return nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + + var fired []exceptionActivity + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, act activity_api.ActivityDetails) error { + switch ex := act.(type) { + case fleet.ActivityTypeEnabledGitOpsException: + fired = append(fired, exceptionActivity{name: act.ActivityName(), exception: ex.Exception}) + case fleet.ActivityTypeDisabledGitOpsException: + fired = append(fired, exceptionActivity{name: act.ActivityName(), exception: ex.Exception}) + } + return nil + } + + _, err := svc.ModifyAppConfig(ctx, []byte(tt.patch), fleet.ApplySpecOptions{}) + require.NoError(t, err) + require.Equal(t, tt.expectFired, fired) + }) + } + + t.Run("no activity is emitted when SaveAppConfig fails", func(t *testing.T) { + ds := new(mock.Store) + opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}} + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + dsAppConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + GitOpsConfig: fleet.GitOpsConfig{ + GitopsModeEnabled: true, + RepositoryURL: "https://example.com/repo", + Exceptions: fleet.GitOpsExceptions{Labels: false, Software: false, Secrets: false}, + }, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return dsAppConfig, nil } + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + return errors.New("save failed") + } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { return nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{}, nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + + var fired []exceptionActivity + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, act activity_api.ActivityDetails) error { + switch ex := act.(type) { + case fleet.ActivityTypeEnabledGitOpsException: + fired = append(fired, exceptionActivity{name: act.ActivityName(), exception: ex.Exception}) + case fleet.ActivityTypeDisabledGitOpsException: + fired = append(fired, exceptionActivity{name: act.ActivityName(), exception: ex.Exception}) + } + return nil + } + + _, err := svc.ModifyAppConfig(ctx, + []byte(`{"gitops": {"exceptions": {"labels": true, "software": true, "secrets": true}}}`), + fleet.ApplySpecOptions{}) + require.Error(t, err) + require.Empty(t, fired, "no exception activity should be emitted when SaveAppConfig fails") + }) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 104d2bc8365..47fe136c4cf 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3372,15 +3372,24 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsModeConfig() { func (s *integrationEnterpriseTestSuite) TestGitOpsExceptionsConfig() { t := s.T() - // Enable GitOps mode first + // Start from a known state: GitOps mode on, all exceptions disabled. s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "gitops": { "gitops_mode_enabled": true, "repository_url": "https://example.com/repo" } + "gitops": { + "gitops_mode_enabled": true, + "repository_url": "https://example.com/repo", + "exceptions": { "labels": false, "software": false, "secrets": false } + } }`), http.StatusOK) - // Set exceptions + // Enable two exceptions (labels and software); secrets stays false. + // Two enabled_gitops_exception activities expected — one per flipped field. s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "gitops": { "exceptions": { "labels": true, "software": true, "secrets": false } } }`), http.StatusOK) + enabledExceptions := s.listRecentExceptionActivities(fleet.ActivityTypeEnabledGitOpsException{}.ActivityName()) + assert.Contains(t, enabledExceptions, "labels", "expected activity for enabled labels exception") + assert.Contains(t, enabledExceptions, "software", "expected activity for enabled software exception") + assert.NotContains(t, enabledExceptions, "secrets", "no activity expected for unchanged secrets exception") config, err := s.ds.AppConfig(context.Background()) require.NoError(t, err) @@ -3390,10 +3399,25 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsExceptionsConfig() { assert.True(t, config.GitOpsConfig.GitopsModeEnabled) assert.Equal(t, "https://example.com/repo", config.GitOpsConfig.RepositoryURL) - // Partial update — only change one exception, others should persist + // Partial update — only change one exception, others should persist. + // Capture the last activity id before the update so we can verify no + // activity is recorded for the fields that did not change. + lastIDBefore := s.lastActivityMatches("", "", 0) s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "gitops": { "exceptions": { "software": false } } }`), http.StatusOK) + lastID := s.lastActivityMatches( + fleet.ActivityTypeDisabledGitOpsException{}.ActivityName(), + `{"exception": "software"}`, 0) + assert.Greater(t, lastID, lastIDBefore, "a new activity should have been recorded") + + // Save app config with no exception changes — no new exception activity should be recorded. + lastIDBefore = s.lastActivityMatches("", "", 0) + s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "gitops": { "exceptions": { "labels": true, "software": false, "secrets": false } } + }`), http.StatusOK) + lastIDAfter := s.lastActivityMatches("", "", 0) + assert.Equal(t, lastIDBefore, lastIDAfter, "no activity should have been recorded when exceptions are unchanged") config, err = s.ds.AppConfig(context.Background()) require.NoError(t, err) @@ -3413,6 +3437,29 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsExceptionsConfig() { assert.Equal(t, "https://example.com/repo", getResp.GitOpsConfig.RepositoryURL) } +// listRecentExceptionActivities returns the set of exception names found among +// the recent activities of the given type (enabled_gitops_exception or +// disabled_gitops_exception). Used to assert on exception activities without +// depending on ordering when multiple are emitted by a single PATCH. +func (s *integrationEnterpriseTestSuite) listRecentExceptionActivities(activityType string) map[string]struct{} { + t := s.T() + var listActivities listActivitiesResponse + s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, + &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10") + found := map[string]struct{}{} + for _, act := range listActivities.Activities { + if act.Type != activityType || act.Details == nil { + continue + } + var d struct { + Exception string `json:"exception"` + } + require.NoError(t, json.Unmarshal([]byte(*act.Details), &d)) + found[d.Exception] = struct{}{} + } + return found +} + func (s *integrationEnterpriseTestSuite) assertAppleOSUpdatesDeclaration(teamID *uint, profileName string, expected *fleet.AppleOSUpdateSettings) { t := s.T() if teamID == nil {