Added audit activities when secret variables are upserted

Added audit activities when secret variables are created or updated
through the `PUT /api/latest/fleet/spec/secret_variables` endpoint.
This commit is contained in:
Juan Fernandez
2026-07-16 18:54:04 -04:00
committed by GitHub
parent e28a8c76f9
commit 0d3a3bdc95
15 changed files with 219 additions and 45 deletions
@@ -0,0 +1 @@
- Added audit activities when secret variables are created or updated through the `PUT /api/latest/fleet/spec/secret_variables` endpoint.
+3 -1
View File
@@ -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"`))
+15
View File
@@ -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.
+2
View File
@@ -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<ActivityType, string> = {
"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",
@@ -2035,6 +2035,15 @@ const TAGGED_TEMPLATES = {
);
},
updatedCustomVariable: (activity: IActivity) => {
const { custom_variable_name } = activity.details || {};
return (
<>
updated custom variable <b>{custom_variable_name}</b>.
</>
);
},
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);
}
+13 -9
View File
@@ -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) {
+28 -19
View File
@@ -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)
@@ -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",
+8
View File
@@ -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"`
+3 -1
View File
@@ -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.
+2 -2
View File
@@ -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()
+27 -1
View File
@@ -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() {
+6 -3
View File
@@ -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)
+28 -1
View File
@@ -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
}
+70 -7
View File
@@ -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)
})
}