fleet/server/service/secret_variables.go
Lucas Manuel Rodriguez 53b7a0628a
Prevent IT admins from deleting a secret variable in use (#32161)
#31977

Tested with:
224 scripts, 102 Apple configuration profiles, 105 Apple declarations,
37 Windows configuration profiles. Scattered in 7 teams.

No variables on profiles/scripts:
- scan scripts: 22.079416ms
- scan apple profiles: 1.865083ms
- scan apple declarations: 683.75µs
- scan windows profiles: 531.167µs

A variable "X" on with profiles/scripts that have variable "Y":
- scan scripts: 26.305459ms
- scan apple profiles: 1.841708ms
- scan apple declarations: 756.917µs
- scan windows profiles: 571.875µs

---

## Testing

- [X] Added/updated automated tests

- [X] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [X] Confirmed that the fix is not expected to adversely impact load
test results
- [X] Alerted the release DRI if additional load testing is needed

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

## Summary by CodeRabbit

- New Features
- Prevents deleting a secret that’s still in use. The system now checks
scripts, Apple/Windows MDM profiles, and Apple declarations across teams
before allowing deletion.
- The delete dialog now shows a clear message indicating which item (and
team) is using the secret, replacing the previous generic error.
- Deletion succeeds once all references are removed, ensuring safer
secret management.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-22 11:22:37 -03:00

256 lines
7.7 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
const (
SecretVariablePrefix = "FLEET_SECRET_"
)
//////////////////////////////////////////////////////////////////////////////////
// Create secret variables (spec)
//////////////////////////////////////////////////////////////////////////////////
type createSecretVariablesRequest struct {
DryRun bool `json:"dry_run"`
SecretVariables []fleet.SecretVariable `json:"secrets"`
}
type createSecretVariablesResponse struct {
Err error `json:"error,omitempty"`
}
func (r createSecretVariablesResponse) Error() error { return r.Err }
func createSecretVariablesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*createSecretVariablesRequest)
err := svc.CreateSecretVariables(ctx, req.SecretVariables, req.DryRun)
return createSecretVariablesResponse{Err: err}, nil
}
func (svc *Service) CreateSecretVariables(ctx context.Context, secretVariables []fleet.SecretVariable, dryRun bool) error {
// Do authorization check first so that we don't have to worry about it later in the flow.
if err := svc.authz.Authorize(ctx, &fleet.SecretVariable{}, fleet.ActionWrite); err != nil {
return err
}
privateKey := svc.config.Server.PrivateKey
if testSetEmptyPrivateKey {
privateKey = ""
}
if len(privateKey) == 0 {
return ctxerr.Wrap(ctx,
&fleet.BadRequestError{Message: "Couldn't save secret variables. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key"})
}
// Preprocess: strip FLEET_SECRET_ prefix from variable names
for i, secretVariable := range secretVariables {
secretVariables[i].Name = fleet.Preprocess(strings.TrimPrefix(secretVariable.Name, SecretVariablePrefix))
}
for _, secretVariable := range secretVariables {
if err := fleet.ValidateSecretVariableName(secretVariable.Name); err != nil {
return ctxerr.Wrap(ctx, err, "validate secret variable name")
}
}
if dryRun {
return nil
}
if err := svc.ds.UpsertSecretVariables(ctx, secretVariables); err != nil {
return ctxerr.Wrap(ctx, err, "saving secret variables")
}
return nil
}
//////////////////////////////////////////////////////////////////////////////////
// Create secret variable
//////////////////////////////////////////////////////////////////////////////////
type createSecretVariableRequest struct {
Name string `json:"name"`
Value string `json:"value"`
}
type createSecretVariableResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Err error `json:"error,omitempty"`
}
func (r createSecretVariableResponse) Error() error { return r.Err }
func createSecretVariableEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*createSecretVariableRequest)
id, err := svc.CreateSecretVariable(ctx, req.Name, req.Value)
if err != nil {
return createSecretVariableResponse{Err: err}, nil
}
return createSecretVariableResponse{
ID: id,
Name: req.Name,
}, nil
}
func (svc *Service) CreateSecretVariable(ctx context.Context, name string, value string) (id uint, err error) {
if err := svc.authz.Authorize(ctx, &fleet.SecretVariable{}, fleet.ActionWrite); err != nil {
return 0, err
}
privateKey := svc.config.Server.PrivateKey
if testSetEmptyPrivateKey {
privateKey = ""
}
if len(privateKey) == 0 {
return 0, ctxerr.Wrap(ctx,
&fleet.BadRequestError{
Message: "Couldn't save secret variable. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key",
})
}
if err := fleet.ValidateSecretVariableName(name); err != nil {
return 0, ctxerr.Wrap(ctx, err, "validate secret variable name")
}
if value == "" {
return 0, fleet.NewInvalidArgumentError("name", "secret variable value cannot be empty")
}
id, err = svc.ds.CreateSecretVariable(ctx, name, value)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "saving secret variable")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityCreatedCustomVariable{
CustomVariableID: id,
CustomVariableName: name,
},
); err != nil {
return 0, ctxerr.Wrap(ctx, err, "create activity for secret variable creation")
}
return id, nil
}
//////////////////////////////////////////////////////////////////////////////////
// List secret variables
//////////////////////////////////////////////////////////////////////////////////
type listSecretVariablesRequest struct {
ListOptions fleet.ListOptions `url:"list_options"`
}
type listSecretVariablesResponse struct {
CustomVariables []fleet.SecretVariableIdentifier `json:"custom_variables"`
Meta *fleet.PaginationMetadata `json:"meta"`
Count int `json:"count"`
Err error `json:"error,omitempty"`
}
func (r listSecretVariablesResponse) Error() error { return r.Err }
func listSecretVariablesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listSecretVariablesRequest)
secretVariables, meta, count, err := svc.ListSecretVariables(ctx, req.ListOptions)
return listSecretVariablesResponse{
CustomVariables: secretVariables,
Meta: meta,
Count: count,
Err: err,
}, nil
}
func (svc *Service) ListSecretVariables(
ctx context.Context,
opts fleet.ListOptions,
) (
secretVariables []fleet.SecretVariableIdentifier,
meta *fleet.PaginationMetadata,
count int,
err error,
) {
if err := svc.authz.Authorize(ctx, &fleet.SecretVariable{}, fleet.ActionRead); err != nil {
return nil, nil, 0, err
}
// MatchQuery/After currently not supported
opts.MatchQuery = ""
opts.After = ""
// Always include pagination info.
opts.IncludeMetadata = true
// Default sort order is name ascending.
opts.OrderKey = "name"
opts.OrderDirection = fleet.OrderAscending
secretVariables, meta, count, err = svc.ds.ListSecretVariables(ctx, opts)
if err != nil {
return nil, nil, 0, ctxerr.Wrap(ctx, err, "list secret variables")
}
return secretVariables, meta, count, nil
}
//////////////////////////////////////////////////////////////////////////////////
// Delete secret variable
//////////////////////////////////////////////////////////////////////////////////
type deleteSecretVariableRequest struct {
ID uint `url:"id"`
}
type deleteSecretVariableResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteSecretVariableResponse) Error() error { return r.Err }
func deleteSecretVariableEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteSecretVariableRequest)
err := svc.DeleteSecretVariable(ctx, req.ID)
return deleteSecretVariableResponse{
Err: err,
}, nil
}
func (svc *Service) DeleteSecretVariable(ctx context.Context, id uint) error {
if err := svc.authz.Authorize(ctx, &fleet.SecretVariable{}, fleet.ActionWrite); err != nil {
return err
}
deletedSecretVariableName, err := svc.ds.DeleteSecretVariable(ctx, id)
if err != nil {
var secretUsedErr *fleet.SecretUsedError
if errors.As(err, &secretUsedErr) {
return ctxerr.Wrap(ctx, &fleet.ConflictError{
Message: fmt.Sprintf("Couldn't delete. %s", secretUsedErr.Error()),
}, "delete secret variable")
}
return ctxerr.Wrap(ctx, err, "delete secret variable")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityDeletedCustomVariable{
CustomVariableID: id,
CustomVariableName: deletedSecretVariableName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for secret variable deletion")
}
return nil
}