diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 2b38a2dda1..ed97316d78 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -3908,20 +3908,22 @@ func TestGitOpsAndroidCertificatesAdd(t *testing.T) { // Track certificate templates that are created var createdCertificates []fleet.CertificateTemplate - ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) error { + ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) { createdCertificates = nil + createdMap := make([]uint, 0, len(certificates)) for _, cert := range certificates { createdCertificates = append(createdCertificates, *cert) + createdMap = append(createdMap, cert.TeamID) } - return nil + return createdMap, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } - ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) error { - return nil + ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) (bool, error) { + return false, nil } // Create team config @@ -3995,12 +3997,14 @@ func TestGitOpsAndroidCertificatesChange(t *testing.T) { var updatedCertificates []fleet.CertificateTemplate var deletedCertificateIDs []uint - ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) error { + ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) { updatedCertificates = nil + createdMap := make([]uint, 0, len(certificates)) for _, cert := range certificates { updatedCertificates = append(updatedCertificates, *cert) + createdMap = append(createdMap, cert.TeamID) } - return nil + return createdMap, nil } // Simulate existing certificates @@ -4022,9 +4026,9 @@ func TestGitOpsAndroidCertificatesChange(t *testing.T) { return existing, &fleet.PaginationMetadata{}, nil } - ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) error { + ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) (bool, error) { deletedCertificateIDs = append(deletedCertificateIDs, ids...) - return nil + return true, nil } // Create team config with modified subjectNames @@ -4216,17 +4220,19 @@ func TestGitOpsAndroidCertificatesDeleteOne(t *testing.T) { var deletedCertificateIDs []uint var remainingCertificates []fleet.CertificateTemplate - ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) error { + ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) (bool, error) { deletedCertificateIDs = ids - return nil + return true, nil } - ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) error { + ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) { remainingCertificates = nil + createdMap := make([]uint, 0, len(certificates)) for _, cert := range certificates { remainingCertificates = append(remainingCertificates, *cert) + createdMap = append(createdMap, cert.TeamID) } - return nil + return createdMap, nil } // Simulate existing certificates @@ -4318,9 +4324,9 @@ func TestGitOpsAndroidCertificatesDeleteAll(t *testing.T) { // Track what was deleted var deletedCertificateIDs []uint - ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) error { + ds.BatchDeleteCertificateTemplatesFunc = func(ctx context.Context, ids []uint) (bool, error) { deletedCertificateIDs = ids - return nil + return true, nil } // Simulate existing certificates diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index 41bb59c47e..df20ebee2b 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -1352,6 +1352,23 @@ This activity contains the following fields: } ``` +## edited_android_certificate + +Generated when a user adds or removes Android certificate templates of a team (or no team) via the fleetctl CLI. + +This activity contains the following fields: +- "team_id": The ID of the team that the certificate templates apply to, `null` if they apply to devices that are not in a team. +- "team_name": The name of the team that the certificate templates apply to, `null` if they apply to devices that are not in a team. + +#### Example + +```json +{ + "team_id": 123, + "team_name": "Workstations" +} +``` + ## resent_configuration_profile Generated when a user resends a configuration profile to a host. diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 8231b66d15..1bd22564d5 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -79,6 +79,7 @@ export enum ActivityType { CreatedAndroidProfile = "created_android_profile", DeletedAndroidProfile = "deleted_android_profile", EditedAndroidProfile = "edited_android_profile", + EditedAndroidCertificate = "edited_android_certificate", // Note: Both "enabled_disk_encryption" and "enabled_macos_disk_encryption" display the same // message. The latter is deprecated in the API but it is retained here for backwards compatibility. EnabledDiskEncryption = "enabled_disk_encryption", @@ -416,6 +417,8 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { "Deleted configuration profile: Android", [ActivityType.EditedAndroidProfile]: "GitOps: edited configuration profiles: Android", + [ActivityType.EditedAndroidCertificate]: + "GitOps: edited certificate templates: Android", [ActivityType.AddedConditionalAccessOkta]: "Added conditional access: Okta", [ActivityType.DeletedConditionalAccessOkta]: "Deleted conditional access: Okta", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index eedf36cf12..95e2e31c23 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -604,6 +604,20 @@ const TAGGED_TEMPLATES = { ); }, + editedAndroidCertificate: (activity: IActivity, isPremiumTier: boolean) => { + return ( + <> + {" "} + edited certificate templates for{" "} + {getProfileMessageSuffix( + isPremiumTier, + "android", + activity.details?.team_name + )}{" "} + via fleetctl. + + ); + }, addedCertificateAuthority: (name = "") => { return name ? ( <> @@ -1792,6 +1806,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.EditedAndroidProfile: { return TAGGED_TEMPLATES.editedAndroidProfile(activity, isPremiumTier); } + case ActivityType.EditedAndroidCertificate: { + return TAGGED_TEMPLATES.editedAndroidCertificate(activity, isPremiumTier); + } case ActivityType.AddedNdesScepProxy: { return TAGGED_TEMPLATES.addedCertificateAuthority("NDES"); } diff --git a/server/datastore/mysql/certificate_templates.go b/server/datastore/mysql/certificate_templates.go index 58ed86c21c..50a800eedf 100644 --- a/server/datastore/mysql/certificate_templates.go +++ b/server/datastore/mysql/certificate_templates.go @@ -197,45 +197,46 @@ func (ds *Datastore) DeleteCertificateTemplate(ctx context.Context, id uint) err return nil } -func (ds *Datastore) BatchUpsertCertificateTemplates(ctx context.Context, certificateTemplates []*fleet.CertificateTemplate) error { +func (ds *Datastore) BatchUpsertCertificateTemplates(ctx context.Context, certificateTemplates []*fleet.CertificateTemplate) ([]uint, error) { if len(certificateTemplates) == 0 { - return nil + return nil, nil } - const argsCountInsertCertificate = 4 - const sqlInsertCertificate = ` INSERT INTO certificate_templates ( name, team_id, certificate_authority_id, subject_name - ) VALUES %s + ) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), team_id = VALUES(team_id) ` - var placeholders strings.Builder - args := make([]interface{}, 0, len(certificateTemplates)*argsCountInsertCertificate) - + teamsModifiedSet := make(map[uint]struct{}) for _, cert := range certificateTemplates { - args = append(args, cert.Name, cert.TeamID, cert.CertificateAuthorityID, cert.SubjectName) - placeholders.WriteString("(?,?,?,?),") + result, err := ds.writer(ctx).ExecContext(ctx, sqlInsertCertificate, cert.Name, cert.TeamID, cert.CertificateAuthorityID, cert.SubjectName) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "upserting certificate_template") + } + + if insertOnDuplicateDidInsertOrUpdate(result) { + teamsModifiedSet[cert.TeamID] = struct{}{} + } } - stmt := fmt.Sprintf(sqlInsertCertificate, strings.TrimSuffix(placeholders.String(), ",")) - - if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "upserting certificate_templates") + teamsModified := make([]uint, 0, len(teamsModifiedSet)) + for teamID := range teamsModifiedSet { + teamsModified = append(teamsModified, teamID) } - return nil + return teamsModified, nil } -func (ds *Datastore) BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) error { +func (ds *Datastore) BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) (bool, error) { if len(certificateTemplateIDs) == 0 { - return nil + return false, nil } const sqlDeleteCertificateTemplates = ` @@ -252,11 +253,13 @@ func (ds *Datastore) BatchDeleteCertificateTemplates(ctx context.Context, certif stmt := fmt.Sprintf(sqlDeleteCertificateTemplates, strings.TrimSuffix(placeholders.String(), ",")) - if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting certificate_templates") + result, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting certificate_templates") } - return nil + rowsAffected, _ := result.RowsAffected() + return rowsAffected > 0, nil } func (ds *Datastore) GetHostCertificateTemplates(ctx context.Context, hostUUID string) ([]fleet.HostCertificateTemplate, error) { diff --git a/server/datastore/mysql/certificate_templates_test.go b/server/datastore/mysql/certificate_templates_test.go index 5d510fc4ae..d3d39c15fe 100644 --- a/server/datastore/mysql/certificate_templates_test.go +++ b/server/datastore/mysql/certificate_templates_test.go @@ -629,7 +629,7 @@ func testBatchUpsertCertificates(t *testing.T, ds *Datastore) { var teamID, caID uint var certificates []*fleet.CertificateTemplate - var err error + testCases := []struct { name string before func(ds *Datastore) @@ -640,8 +640,10 @@ func testBatchUpsertCertificates(t *testing.T, ds *Datastore) { func(ds *Datastore) {}, func(t *testing.T, ds *Datastore) { // Test with empty slice - err = ds.BatchUpsertCertificateTemplates(ctx, []*fleet.CertificateTemplate{}) + teamsModified, err := ds.BatchUpsertCertificateTemplates(ctx, []*fleet.CertificateTemplate{}) require.NoError(t, err) + + require.Empty(t, teamsModified) }, }, { @@ -678,13 +680,16 @@ func testBatchUpsertCertificates(t *testing.T, ds *Datastore) { }, } - err = ds.BatchUpsertCertificateTemplates(ctx, certificates) + teamsModified, err := ds.BatchUpsertCertificateTemplates(ctx, certificates) require.NoError(t, err) var count int err = ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") require.NoError(t, err) require.Equal(t, 2, count) + + require.Len(t, teamsModified, 1) + require.Equal(t, teamsModified[0], teamID) }, }, { @@ -724,18 +729,20 @@ func testBatchUpsertCertificates(t *testing.T, ds *Datastore) { }, func(t *testing.T, ds *Datastore) { var count int - err = ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") + err := ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") require.NoError(t, err) require.Equal(t, 1, count) certificates[0].SubjectName = "Updated Subject" - err = ds.BatchUpsertCertificateTemplates(ctx, certificates) + teamsModified, err := ds.BatchUpsertCertificateTemplates(ctx, certificates) require.NoError(t, err) err = ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") require.NoError(t, err) require.Equal(t, 1, count) + require.Len(t, teamsModified, 0) + var subjectName string err = ds.writer(ctx).GetContext(ctx, &subjectName, "SELECT subject_name FROM certificate_templates WHERE name = ?", "Cert1") require.NoError(t, err) @@ -760,7 +767,7 @@ func testBatchDeleteCertificateTemplates(t *testing.T, ds *Datastore) { var teamID, caID uint var certificateTemplateIDs []uint - var err error + testCases := []struct { name string before func(ds *Datastore) @@ -771,8 +778,10 @@ func testBatchDeleteCertificateTemplates(t *testing.T, ds *Datastore) { func(ds *Datastore) {}, func(t *testing.T, ds *Datastore) { // Test with empty slice - err = ds.BatchDeleteCertificateTemplates(ctx, []uint{}) + generateActivity, err := ds.BatchDeleteCertificateTemplates(ctx, []uint{}) require.NoError(t, err) + + require.False(t, generateActivity) }, }, { @@ -832,13 +841,15 @@ func testBatchDeleteCertificateTemplates(t *testing.T, ds *Datastore) { }, func(t *testing.T, ds *Datastore) { var count int - err = ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") + err := ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") require.NoError(t, err) require.Equal(t, 2, count) - err = ds.BatchDeleteCertificateTemplates(ctx, certificateTemplateIDs) + generateActivity, err := ds.BatchDeleteCertificateTemplates(ctx, certificateTemplateIDs) require.NoError(t, err) + require.True(t, generateActivity) + err = ds.writer(ctx).GetContext(ctx, &count, "SELECT COUNT(*) FROM certificate_templates") require.NoError(t, err) require.Equal(t, 0, count) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 9cd796d9e7..d8dccd2431 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -189,6 +189,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeCreatedAndroidProfile{}, ActivityTypeDeletedAndroidProfile{}, ActivityTypeEditedAndroidProfile{}, + ActivityTypeEditedAndroidCertificate{}, ActivityTypeResentConfigurationProfile{}, ActivityTypeResentConfigurationProfileBatch{}, @@ -3178,6 +3179,25 @@ func (a ActivityTypeEditedAndroidProfile) Documentation() (activity, details, de }` } +type ActivityTypeEditedAndroidCertificate struct { + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` +} + +func (a ActivityTypeEditedAndroidCertificate) ActivityName() string { + return "edited_android_certificate" +} + +func (a ActivityTypeEditedAndroidCertificate) Documentation() (activity, details, detailsExample string) { + return `Generated when a user adds or removes Android certificate templates of a team (or no team) via the fleetctl CLI.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the certificate templates apply to, ` + "`null`" + ` if they apply to devices that are not in a team. +- "team_name": The name of the team that the certificate templates apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ + "team_id": 123, + "team_name": "Workstations" +}` +} + type ActivityTypeEditedHostIdpData struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index bfcc64b9fb..6a476bffa4 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2542,9 +2542,11 @@ type Datastore interface { UpsertCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status MDMDeliveryStatus, detail *string) error // BatchUpsertCertificateTemplates upserts a batch of certificates. - BatchUpsertCertificateTemplates(ctx context.Context, certificates []*CertificateTemplate) error + // Returns a map of team IDs that had certificates inserted or updated. + BatchUpsertCertificateTemplates(ctx context.Context, certificates []*CertificateTemplate) ([]uint, error) // BatchDeleteCertificateTemplates deletes a batch of certificates. - BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) error + // Returns true if any rows were deleted. + BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) (bool, error) // CreateCertificateTemplate creates a new certificate template. CreateCertificateTemplate(ctx context.Context, certificateTemplate *CertificateTemplate) (*CertificateTemplateResponse, error) // DeleteCertificateTemplate deletes a certificate template by its ID. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 48556c1271..2cf5cfcc2c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1657,9 +1657,9 @@ type BatchApplyCertificateAuthoritiesFunc func(ctx context.Context, ops fleet.Ce type UpsertCertificateStatusFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string) error -type BatchUpsertCertificateTemplatesFunc func(ctx context.Context, certificates []*fleet.CertificateTemplate) error +type BatchUpsertCertificateTemplatesFunc func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) -type BatchDeleteCertificateTemplatesFunc func(ctx context.Context, certificateTemplateIDs []uint) error +type BatchDeleteCertificateTemplatesFunc func(ctx context.Context, certificateTemplateIDs []uint) (bool, error) type CreateCertificateTemplateFunc func(ctx context.Context, certificateTemplate *fleet.CertificateTemplate) (*fleet.CertificateTemplateResponse, error) @@ -9936,14 +9936,14 @@ func (s *DataStore) UpsertCertificateStatus(ctx context.Context, hostUUID string return s.UpsertCertificateStatusFunc(ctx, hostUUID, certificateTemplateID, status, detail) } -func (s *DataStore) BatchUpsertCertificateTemplates(ctx context.Context, certificates []*fleet.CertificateTemplate) error { +func (s *DataStore) BatchUpsertCertificateTemplates(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) { s.mu.Lock() s.BatchUpsertCertificateTemplatesFuncInvoked = true s.mu.Unlock() return s.BatchUpsertCertificateTemplatesFunc(ctx, certificates) } -func (s *DataStore) BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) error { +func (s *DataStore) BatchDeleteCertificateTemplates(ctx context.Context, certificateTemplateIDs []uint) (bool, error) { s.mu.Lock() s.BatchDeleteCertificateTemplatesFuncInvoked = true s.mu.Unlock() diff --git a/server/service/certificate_templates_test.go b/server/service/certificate_templates_test.go index 0753d70af6..e9447ecbdc 100644 --- a/server/service/certificate_templates_test.go +++ b/server/service/certificate_templates_test.go @@ -93,6 +93,21 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) { ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{ + ID: id, + Name: "Test Team", + }, 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{ { @@ -139,12 +154,14 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) { var createdCertificates []fleet.CertificateTemplate var nextTemplateID uint = 100 - ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) error { + ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) { createdCertificates = nil + createdMap := make([]uint, 0, len(certificates)) for _, cert := range certificates { createdCertificates = append(createdCertificates, *cert) + createdMap = append(createdMap, cert.TeamID) } - return nil + return createdMap, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { diff --git a/server/service/certificates.go b/server/service/certificates.go index f14c930d14..ff83d942cb 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -408,7 +408,8 @@ func (svc *Service) ApplyCertificateTemplateSpecs(ctx context.Context, specs []* certificates = append(certificates, cert) } - if err := svc.ds.BatchUpsertCertificateTemplates(ctx, certificates); err != nil { + teamsModified, err := svc.ds.BatchUpsertCertificateTemplates(ctx, certificates) + if err != nil { return err } @@ -425,6 +426,28 @@ func (svc *Service) ApplyCertificateTemplateSpecs(ctx context.Context, specs []* } } + // Only create activity for teams that actually had certificates affected + for _, teamID := range teamsModified { + var tmID *uint + var tmName *string + if teamID != 0 { + team, err := svc.ds.TeamLite(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting team for activity") + } + tmID = &team.ID + tmName = &team.Name + } + + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedAndroidCertificate{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited android certificate") + } + } + return nil } @@ -452,7 +475,37 @@ func (svc *Service) DeleteCertificateTemplateSpecs(ctx context.Context, certific if err := svc.authz.Authorize(ctx, &fleet.CertificateTemplate{TeamID: teamID}, fleet.ActionWrite); err != nil { return err } - return svc.ds.BatchDeleteCertificateTemplates(ctx, certificateTemplateIDs) + + deletedRows, err := svc.ds.BatchDeleteCertificateTemplates(ctx, certificateTemplateIDs) + if err != nil { + return err + } + + if !deletedRows { + return nil + } + + // Only create activity if rows were actually deleted + var tmID *uint + var tmName *string + if teamID != 0 { + team, err := svc.ds.TeamLite(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting team for activity") + } + tmID = &team.ID + tmName = &team.Name + } + + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedAndroidCertificate{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited android certificate") + } + + return nil } type updateCertificateStatusRequest struct { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index c801b20006..cf112107cb 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8001,6 +8001,9 @@ func (s *integrationTestSuite) TestCertificatesSpecs() { }, }, http.StatusNotFound, &applyResp) + activitiesBeforeInsert, _, err := s.ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + // valid templates - test team name (not team ID) s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ Specs: []*fleet.CertificateRequestSpec{ @@ -8019,12 +8022,45 @@ func (s *integrationTestSuite) TestCertificatesSpecs() { }, }, http.StatusOK, &applyResp) + // Only one activity per team + activitiesAfterInsert, _, err := s.ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activitiesAfterInsert, len(activitiesBeforeInsert)+1, "expected exactly one new activity for the team") + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, team.Name), + 0, + ) + // list specs var listCertifcatesResp listCertificateTemplatesResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) require.Len(t, listCertifcatesResp.Certificates, 2) assert.ElementsMatch(t, []string{"Template 1", "Template 2"}, []string{listCertifcatesResp.Certificates[0].Name, listCertifcatesResp.Certificates[1].Name}) + lastActivityID := s.lastActivityMatches("", "", 0) + + s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ + Specs: []*fleet.CertificateRequestSpec{ + { + Name: "Template 1", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL", + }, + { + Name: "Template 2", + Team: team.Name, + CertificateAuthorityId: ca.ID, + SubjectName: "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID", + }, + }, + }, http.StatusOK, &applyResp) + + // No new activities created + currentActivityID := s.lastActivityMatches("", "", 0) + assert.Equal(t, lastActivityID, currentActivityID, "no new activity should be created when re-applying same certificates") + // Create a host to get certificate get by id endpoint host, err := s.ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), @@ -8105,13 +8141,24 @@ func (s *integrationTestSuite) TestCertificatesSpecs() { // batch delete certificate templates var delBatchResp deleteCertificateTemplateSpecsResponse s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]interface{}{ - "ids": []uint{listCertifcatesResp.Certificates[0].ID, listCertifcatesResp.Certificates[1].ID}, + "ids": []uint{listCertifcatesResp.Certificates[0].ID, listCertifcatesResp.Certificates[1].ID}, + "team_id": team.ID, }, http.StatusOK, &delBatchResp) + // Verify activity was created for deleting certificates + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, team.Name), + 0, + ) + // list specs s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team.ID), nil, http.StatusOK, &listCertifcatesResp) require.Len(t, listCertifcatesResp.Certificates, 0) + activitiesBeforeNoTeam, _, err := s.ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + // certificate templates for "No team" s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{ Specs: []*fleet.CertificateRequestSpec{ @@ -8136,6 +8183,16 @@ func (s *integrationTestSuite) TestCertificatesSpecs() { }, }, http.StatusOK, &applyResp) + // Only one activity was created for "No team" + activitiesAfterNoTeam, _, err := s.ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activitiesAfterNoTeam, len(activitiesBeforeNoTeam)+1, "expected exactly one new activity for no team") + s.lastActivityMatches( + fleet.ActivityTypeEditedAndroidCertificate{}.ActivityName(), + `{"team_id": null, "team_name": null}`, + 0, + ) + // list specs for "no team" (team_id 0) var noTeamCertificatesResp listCertificateTemplatesResponse s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp)