diff --git a/changes/16779-secret-variable-spec-activities b/changes/16779-secret-variable-spec-activities new file mode 100644 index 0000000000..b63440d328 --- /dev/null +++ b/changes/16779-secret-variable-spec-activities @@ -0,0 +1 @@ +- Added audit activities when secret variables are created or updated through the `PUT /api/latest/fleet/spec/secret_variables` endpoint. diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index ea40775106..fbe0568a73 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -7441,7 +7441,9 @@ software: t.Setenv("FLEET_SECRET_FOO", "someValue") // The base mock provides ValidateEmbeddedSecretsFunc (used by scripts/batch), // so leave it alone; only the secret-upload func needs a stub here. - ds.UpsertSecretVariablesFunc = func(ctx context.Context, secretVariables []fleet.SecretVariable) error { return nil } + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secretVariables []fleet.SecretVariable) (created []string, updated []string, err error) { + return nil, nil, nil + } ds.UpsertSecretVariablesFuncInvoked = false yml := writeYAML(t, teamYAML(` name_template: "iPad $FLEET_SECRET_FOO"`)) diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index 3d491cb39b..1ed97ed4b6 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -2663,6 +2663,21 @@ This activity contains the following fields: } ``` +## updated_custom_variable + +Generated when a custom variable's value is updated. + +This activity contains the following fields: +- "custom_variable_name": the name of the custom variable. + +#### Example + +```json +{ + "custom_variable_name": "SOME_API_KEY" +} +``` + ## deleted_custom_variable Generated when custom variable is deleted. diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index b261784a4e..6c25e04660 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -160,6 +160,7 @@ export enum ActivityType { DisabledConditionalAccessAutomations = "disabled_conditional_access_automations", EscrowedDiskEncryptionKey = "escrowed_disk_encryption_key", CreatedCustomVariable = "created_custom_variable", + UpdatedCustomVariable = "updated_custom_variable", DeletedCustomVariable = "deleted_custom_variable", EditedCustomHostVitalValue = "edited_custom_host_vital_value", EditedSetupExperienceSoftware = "edited_setup_experience_software", @@ -538,6 +539,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { "Deleted conditional access integration: Microsoft", escrowed_disk_encryption_key: "Escrowed disk encryption key", created_custom_variable: "Created custom variable", + updated_custom_variable: "Updated custom variable", deleted_custom_variable: "Deleted custom variable", [ActivityType.EditedCustomHostVitalValue]: "Edited custom host vital value", [ActivityType.HostDeleted]: "Host deleted", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 419641572a..df181345d3 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -2035,6 +2035,15 @@ const TAGGED_TEMPLATES = { ); }, + updatedCustomVariable: (activity: IActivity) => { + const { custom_variable_name } = activity.details || {}; + return ( + <> + updated custom variable {custom_variable_name}. + + ); + }, + deletedCustomVariable: (activity: IActivity) => { const { custom_variable_name } = activity.details || {}; return ( @@ -2650,6 +2659,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.CreatedCustomVariable: { return TAGGED_TEMPLATES.createdCustomVariable(activity); } + case ActivityType.UpdatedCustomVariable: { + return TAGGED_TEMPLATES.updatedCustomVariable(activity); + } case ActivityType.DeletedCustomVariable: { return TAGGED_TEMPLATES.deletedCustomVariable(activity); } diff --git a/server/datastore/mysql/secret_variables.go b/server/datastore/mysql/secret_variables.go index 59c07b5b8d..f302ab954d 100644 --- a/server/datastore/mysql/secret_variables.go +++ b/server/datastore/mysql/secret_variables.go @@ -28,9 +28,9 @@ var secretVariableAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ "updated_at": "updated_at", } -func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable) error { +func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable) (created []string, updated []string, err error) { if len(secretVariables) == 0 { - return nil + return nil, nil, nil } // The secret variables should rarely change, so we do not use a transaction here. @@ -44,7 +44,7 @@ func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables } existingVariables, err := ds.GetSecretVariables(ctx, names) if err != nil { - return ctxerr.Wrap(ctx, err, "get existing secret variables") + return nil, nil, ctxerr.Wrap(ctx, err, "get existing secret variables") } existingVariableMap := make(map[string]string, len(existingVariables)) for _, existingVariable := range existingVariables { @@ -73,12 +73,15 @@ func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables for _, secretVariable := range variablesToInsert { valueEncrypted, err := encrypt([]byte(secretVariable.Value), ds.serverPrivateKey) if err != nil { - return ctxerr.Wrap(ctx, err, "encrypt secret value for insert with server private key") + return nil, nil, ctxerr.Wrap(ctx, err, "encrypt secret value for insert with server private key") } args = append(args, secretVariable.Name, valueEncrypted) } if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "insert secret variables") + return nil, nil, ctxerr.Wrap(ctx, err, "insert secret variables") + } + for _, secretVariable := range variablesToInsert { + created = append(created, secretVariable.Name) } } @@ -90,11 +93,12 @@ func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables for _, secretVariable := range variablesToUpdate { valueEncrypted, err := encrypt([]byte(secretVariable.Value), ds.serverPrivateKey) if err != nil { - return ctxerr.Wrap(ctx, err, "encrypt secret value for update with server private key") + return nil, nil, ctxerr.Wrap(ctx, err, "encrypt secret value for update with server private key") } if _, err := ds.writer(ctx).ExecContext(ctx, stmt, valueEncrypted, secretVariable.Name); err != nil { - return ctxerr.Wrap(ctx, err, "update secret variables") + return nil, nil, ctxerr.Wrap(ctx, err, "update secret variables") } + updated = append(updated, secretVariable.Name) } // A changed secret value changes the resolved name of any host whose host @@ -104,11 +108,11 @@ func (ds *Datastore) UpsertSecretVariables(ctx context.Context, secretVariables changedNames = append(changedNames, secretVariable.Name) } if err := ds.resendDeviceNamesForSecretChange(ctx, changedNames); err != nil { - return ctxerr.Wrap(ctx, err, "resend device names for secret change") + return nil, nil, ctxerr.Wrap(ctx, err, "resend device names for secret change") } } - return nil + return created, updated, nil } func (ds *Datastore) CreateSecretVariable(ctx context.Context, name string, value string) (id uint, err error) { diff --git a/server/datastore/mysql/secret_variables_test.go b/server/datastore/mysql/secret_variables_test.go index feebc59947..019037fe18 100644 --- a/server/datastore/mysql/secret_variables_test.go +++ b/server/datastore/mysql/secret_variables_test.go @@ -49,11 +49,13 @@ func TestSecretVariables(t *testing.T) { func testUpsertSecretVariables(t *testing.T, ds *Datastore) { ctx := t.Context() - err := ds.UpsertSecretVariables(ctx, nil) - assert.NoError(t, err) + createdNames, updatedNames, err := ds.UpsertSecretVariables(ctx, nil) + require.NoError(t, err) + require.Empty(t, createdNames) + require.Empty(t, updatedNames) results, err := ds.GetSecretVariables(ctx, nil) - assert.NoError(t, err) - assert.Empty(t, results) + require.NoError(t, err) + require.Empty(t, results) secretMap := map[string]string{ "test1": "testValue1", @@ -68,43 +70,50 @@ func testUpsertSecretVariables(t *testing.T, ds *Datastore) { return secrets } secrets := createExpectedSecrets() - err = ds.UpsertSecretVariables(ctx, secrets) - assert.NoError(t, err) + createdNames, updatedNames, err = ds.UpsertSecretVariables(ctx, secrets) + require.NoError(t, err) + require.ElementsMatch(t, []string{"test1", "test2", "test3"}, createdNames) + require.Empty(t, updatedNames) results, err = ds.GetSecretVariables(ctx, []string{"test1", "test2", "test3"}) - assert.NoError(t, err) - assert.Len(t, results, 3) + require.NoError(t, err) + require.Len(t, results, 3) for _, result := range results { - assert.Equal(t, secretMap[result.Name], result.Value) + require.Equal(t, secretMap[result.Name], result.Value) } // Update a secret and insert a new one secretMap["test2"] = "newTestValue2" secretMap["test4"] = "testValue4" - err = ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ + createdNames, updatedNames, err = ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ {Name: "test2", Value: secretMap["test2"]}, {Name: "test4", Value: secretMap["test4"]}, }) - assert.NoError(t, err) + require.NoError(t, err) + require.ElementsMatch(t, []string{"test4"}, createdNames) + require.ElementsMatch(t, []string{"test2"}, updatedNames) results, err = ds.GetSecretVariables(ctx, []string{"test2", "test4"}) - assert.NoError(t, err) + require.NoError(t, err) require.Len(t, results, 2) for _, result := range results { - assert.Equal(t, secretMap[result.Name], result.Value) + require.Equal(t, secretMap[result.Name], result.Value) } - // Make sure updated_at timestamp does not change when we update a secret with the same value + // Make sure updated_at timestamp does not change when we update a secret with the same value, + // and that an unchanged value produces neither a created nor an updated result. original, err := ds.GetSecretVariables(ctx, []string{"test1"}) require.NoError(t, err) require.Len(t, original, 1) - err = ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ + createdNames, updatedNames, err = ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ {Name: "test1", Value: secretMap["test1"]}, }) require.NoError(t, err) + require.Empty(t, createdNames) + require.Empty(t, updatedNames) updated, err := ds.GetSecretVariables(ctx, []string{"test1"}) require.NoError(t, err) - require.Len(t, original, 1) - assert.Equal(t, original[0], updated[0]) + require.Len(t, updated, 1) + require.Equal(t, original[0], updated[0]) } func testValidateEmbeddedSecrets(t *testing.T, ds *Datastore) { @@ -134,7 +143,7 @@ Hello doc${FLEET_SECRET_INVALID}. $FLEET_SECRET_ALSO_INVALID secrets = append(secrets, fleet.SecretVariable{Name: name, Value: value}) } - err := ds.UpsertSecretVariables(ctx, secrets) + _, _, err := ds.UpsertSecretVariables(ctx, secrets) require.NoError(t, err) err = ds.ValidateEmbeddedSecrets(ctx, []string{noSecrets}) @@ -193,7 +202,7 @@ Hello doc${FLEET_SECRET_INVALID}. $FLEET_SECRET_ALSO_INVALID secrets = append(secrets, fleet.SecretVariable{Name: name, Value: value}) } - err := ds.UpsertSecretVariables(ctx, secrets) + _, _, err := ds.UpsertSecretVariables(ctx, secrets) require.NoError(t, err) expanded, err := ds.ExpandEmbeddedSecrets(ctx, noSecrets) diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index b9a5c05a88..df18348fb6 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -236,7 +236,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { host3 := test.NewHost(t, ds, "host3", "3", "host3key", "host3uuid", time.Now()) user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) - err := ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ + _, _, err := ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{ { Name: "RUBBER", Value: "DUCKY", diff --git a/server/fleet/activities.go b/server/fleet/activities.go index c81f7f4961..08e3010b0e 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1855,6 +1855,14 @@ func (a ActivityCreatedCustomVariable) ActivityName() string { return "created_custom_variable" } +type ActivityUpdatedCustomVariable struct { + CustomVariableName string `json:"custom_variable_name"` +} + +func (a ActivityUpdatedCustomVariable) ActivityName() string { + return "updated_custom_variable" +} + type ActivityDeletedCustomVariable struct { CustomVariableID uint `json:"custom_variable_id"` CustomVariableName string `json:"custom_variable_name"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 7e643b15ba..80cfd77a5c 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -3248,7 +3248,9 @@ type Datastore interface { // Secret variables // UpsertSecretVariables inserts or updates secret variables in the database. - UpsertSecretVariables(ctx context.Context, secretVariables []SecretVariable) error + // It returns the names of the variables that were created and the names of + // those that were updated, so callers can emit the corresponding activities. + UpsertSecretVariables(ctx context.Context, secretVariables []SecretVariable) (created []string, updated []string, err error) // CreateSecretVariable inserts a secret variable (value encrypted) and returns its ID. // Returns an AlreadyExistsError error if there's already a secret variable with the same name. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index b454e8e612..4af8948d7f 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1840,7 +1840,7 @@ type ListHostMDMManagedCertificatesFunc func(ctx context.Context, hostUUID strin type ResendHostCertificateProfileFunc func(ctx context.Context, hostUUID string, profUUID string) error -type UpsertSecretVariablesFunc func(ctx context.Context, secretVariables []fleet.SecretVariable) error +type UpsertSecretVariablesFunc func(ctx context.Context, secretVariables []fleet.SecretVariable) (created []string, updated []string, err error) type CreateSecretVariableFunc func(ctx context.Context, name string, value string) (id uint, err error) @@ -11894,7 +11894,7 @@ func (s *DataStore) ResendHostCertificateProfile(ctx context.Context, hostUUID s return s.ResendHostCertificateProfileFunc(ctx, hostUUID, profUUID) } -func (s *DataStore) UpsertSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable) error { +func (s *DataStore) UpsertSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable) (created []string, updated []string, err error) { s.mu.Lock() s.UpsertSecretVariablesFuncInvoked = true s.mu.Unlock() diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 756e6dc365..bd691aebea 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -15117,19 +15117,45 @@ func (s *integrationTestSuite) TestSecretVariablesGitOps() { } // Do dry run req.DryRun = true + idBeforeDryRun := s.lastActivityMatches("", "", 0) s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &resp) secrets, err := s.ds.GetSecretVariables(ctx, []string{validName}) require.NoError(t, err) require.Empty(t, secrets) + // A dry run persists nothing, so it must not emit any activity. + require.Equal(t, idBeforeDryRun, s.lastActivityMatches("", "", 0)) - // Do real run + // Do real run: creating the variable emits a created_custom_variable activity. req.DryRun = false s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &resp) secrets, err = s.ds.GetSecretVariables(ctx, []string{validName}) require.NoError(t, err) require.Len(t, secrets, 1) assert.Equal(t, "value", secrets[0].Value) + s.lastActivityMatches( + fleet.ActivityCreatedCustomVariable{}.ActivityName(), + fmt.Sprintf(`{"custom_variable_id":0,"custom_variable_name":%q}`, validName), + 0, + ) + + // Re-applying the same spec is a no-op and must not emit any activity. + idAfterCreate := s.lastActivityMatches("", "", 0) + s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &resp) + require.Equal(t, idAfterCreate, s.lastActivityMatches("", "", 0)) + + // Changing the value via the spec endpoint emits an updated_custom_variable activity. + req.SecretVariables[0].Value = "new-value" + s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &resp) + secrets, err = s.ds.GetSecretVariables(ctx, []string{validName}) + require.NoError(t, err) + require.Len(t, secrets, 1) + assert.Equal(t, "new-value", secrets[0].Value) + s.lastActivityMatches( + fleet.ActivityUpdatedCustomVariable{}.ActivityName(), + fmt.Sprintf(`{"custom_variable_name":%q}`, validName), + 0, + ) } func (s *integrationTestSuite) TestSecretVariables() { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index efd38ecda4..8502fc0081 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -26128,7 +26128,8 @@ func (s *integrationMDMTestSuite) TestHostNameTemplateEndToEnd() { requireRowStatus(macHost.UUID, &fleet.MDMDeliveryVerifying) // --- changing the secret value re-enqueues a fresh command --- - require.NoError(t, s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "NYC"}})) + _, _, err = s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "NYC"}}) + require.NoError(t, err) // the secret change reset the enforcement row back to queued requireRowStatus(macHost.UUID, nil) runDeviceNameCron() @@ -26602,14 +26603,16 @@ func (s *integrationMDMTestSuite) TestHostNameTemplateSecretReenqueue() { requireRowStatus(noTeamHost.UUID, &fleet.MDMDeliveryVerifying) // Re-upserting SITE with its current value changes nothing → no re-queue. - require.NoError(t, s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "HQ"}})) + _, _, err = s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "HQ"}}) + require.NoError(t, err) requireRowStatus(siteHost.UUID, &fleet.MDMDeliveryVerifying) requireRowStatus(noTeamHost.UUID, &fleet.MDMDeliveryVerifying) // Changing SITE re-queues the SITE team and the No-team host, but not the // SITE_CODE team (the trailing word boundary prevents SITE from matching // SITE_CODE). - require.NoError(t, s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "NYC"}})) + _, _, err = s.ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{{Name: "SITE", Value: "NYC"}}) + require.NoError(t, err) requireRowStatus(siteHost.UUID, nil) requireRowStatus(noTeamHost.UUID, nil) requireRowStatus(codeHost.UUID, &fleet.MDMDeliveryVerifying) diff --git a/server/service/secret_variables.go b/server/service/secret_variables.go index 159c61e383..095dd63c73 100644 --- a/server/service/secret_variables.go +++ b/server/service/secret_variables.go @@ -56,9 +56,36 @@ func (svc *Service) CreateSecretVariables(ctx context.Context, secretVariables [ return nil } - if err := svc.ds.UpsertSecretVariables(ctx, secretVariables); err != nil { + created, updated, err := svc.ds.UpsertSecretVariables(ctx, secretVariables) + if err != nil { return ctxerr.Wrap(ctx, err, "saving secret variables") } + + // Emit an activity per created/updated variable so secret changes are + // auditable. + user := authz.UserFromContext(ctx) + for _, name := range created { + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityCreatedCustomVariable{ + CustomVariableName: name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for secret variable creation") + } + } + for _, name := range updated { + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityUpdatedCustomVariable{ + CustomVariableName: name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for secret variable update") + } + } return nil } diff --git a/server/service/secret_variables_test.go b/server/service/secret_variables_test.go index 7d7fba22db..9ba8fe6a63 100644 --- a/server/service/secret_variables_test.go +++ b/server/service/secret_variables_test.go @@ -5,11 +5,12 @@ import ( "errors" "testing" + activity_api "github.com/fleetdm/fleet/v4/server/activity/api" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCreateSecretVariables(t *testing.T) { @@ -17,8 +18,8 @@ func TestCreateSecretVariables(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) - ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) error { - return nil + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) (created []string, updated []string, err error) { + return nil, nil, nil } t.Run("authorization checks", func(t *testing.T) { @@ -95,13 +96,75 @@ func TestCreateSecretVariables(t *testing.T) { testSetEmptyPrivateKey = false }) err := svc.CreateSecretVariables(ctx, []fleet.SecretVariable{{Name: "foo", Value: "bar"}}, true) - assert.ErrorContains(t, err, "Couldn't save secret variables. Missing required private key") + require.ErrorContains(t, err, "Couldn't save secret variables. Missing required private key") testSetEmptyPrivateKey = false - ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) error { - return errors.New("test error") + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) (created []string, updated []string, err error) { + return nil, nil, errors.New("test error") } err = svc.CreateSecretVariables(ctx, []fleet.SecretVariable{{Name: "FOO", Value: "bar"}}, false) - assert.ErrorContains(t, err, "test error") + require.ErrorContains(t, err, "test error") + }) +} + +func TestCreateSecretVariablesEmitsActivities(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + opts := &TestServerOpts{} + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("emits a created activity per created variable and an updated activity per updated variable", func(t *testing.T) { + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) (created []string, updated []string, err error) { + return []string{"CREATED"}, []string{"UPDATED"}, nil + } + var activities []activity_api.ActivityDetails + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error { + activities = append(activities, activity) + return nil + } + err := svc.CreateSecretVariables(ctx, []fleet.SecretVariable{ + {Name: "FLEET_SECRET_CREATED", Value: "a"}, + {Name: "FLEET_SECRET_UPDATED", Value: "b"}, + }, false) + require.NoError(t, err) + require.Len(t, activities, 2) + + createdActivity, ok := activities[0].(fleet.ActivityCreatedCustomVariable) + require.True(t, ok) + require.Equal(t, "CREATED", createdActivity.CustomVariableName) + + updatedActivity, ok := activities[1].(fleet.ActivityUpdatedCustomVariable) + require.True(t, ok) + require.Equal(t, "UPDATED", updatedActivity.CustomVariableName) + }) + + t.Run("emits no activity when nothing changed", func(t *testing.T) { + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) (created []string, updated []string, err error) { + return nil, nil, nil + } + activityCalled := false + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error { + activityCalled = true + return nil + } + err := svc.CreateSecretVariables(ctx, []fleet.SecretVariable{{Name: "FLEET_SECRET_UNCHANGED", Value: "a"}}, false) + require.NoError(t, err) + require.False(t, activityCalled) + }) + + t.Run("emits no activity on a dry run", func(t *testing.T) { + ds.UpsertSecretVariablesFunc = func(ctx context.Context, secrets []fleet.SecretVariable) (created []string, updated []string, err error) { + t.Fatal("UpsertSecretVariables should not be called on a dry run") + return nil, nil, nil + } + activityCalled := false + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error { + activityCalled = true + return nil + } + err := svc.CreateSecretVariables(ctx, []fleet.SecretVariable{{Name: "FLEET_SECRET_DRY", Value: "a"}}, true) + require.NoError(t, err) + require.False(t, activityCalled) }) }