Files
fleet/ee/server/service/categories.go
T
Jonathan Katz 89b2a5e470 Change self-service categories GitOps to not require dedicated key (#47439)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
    - Not needed

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

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


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

* **New Features**
* Batch software installer and app-association endpoints now return the
list of referenced self-service categories.
* Category fields support an “omit when unset” JSON behavior so omitted
vs empty categories are distinguishable.

* **Bug Fixes**
* Improved category validation (trim + case-insensitive dedupe) and
GitOps reconciliation to remove unused categories.

* **Chores**
* GitOps schema simplified: no separate top-level
self_service_categories; categories are defined inline with packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 16:19:11 -04:00

214 lines
6.8 KiB
Go

package service
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
)
func (svc *Service) ListSoftwareCategories(ctx context.Context, teamID *uint) ([]fleet.SoftwareCategory, error) {
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("fleet_id", "fleet_id is required")
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareCategory{TeamID: *teamID}, fleet.ActionRead); err != nil {
return nil, err
}
if *teamID != 0 {
exists, err := svc.ds.TeamExists(ctx, *teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking if fleet exists")
}
if !exists {
return nil, fleet.NewInvalidArgumentError("fleet_id", fmt.Sprintf("fleet %d does not exist", *teamID)).WithStatus(http.StatusNotFound)
}
}
categories, err := svc.ds.ListSoftwareCategories(ctx, *teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list software categories")
}
return categories, nil
}
func (svc *Service) ListSelfServiceSoftwareCategoriesForHost(ctx context.Context, host *fleet.Host) ([]fleet.SoftwareCategory, error) {
teamID := ptr.ValOrZero(host.TeamID)
categories, err := svc.ds.ListSoftwareCategories(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list self-service software categories for host")
}
return categories, nil
}
func (svc *Service) NewSoftwareCategory(ctx context.Context, teamID *uint, name string) (*fleet.SoftwareCategory, error) {
name = strings.TrimSpace(name)
if err := (fleet.SoftwareCategory{Name: name}).Validate(); err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "validating new software category")
}
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("fleet_id", "fleet_id is required")
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareCategory{TeamID: *teamID}, fleet.ActionWrite); err != nil {
return nil, err
}
if *teamID != 0 {
exists, err := svc.ds.TeamExists(ctx, *teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking if fleet exists")
}
if !exists {
return nil, fleet.NewInvalidArgumentError("fleet_id", fmt.Sprintf("fleet %d does not exist", *teamID)).
WithStatus(http.StatusNotFound)
}
}
category, err := svc.ds.NewSoftwareCategory(ctx, *teamID, name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "new software category")
}
teamName, err := svc.teamNameForActivity(ctx, category.TeamID)
if err != nil {
return nil, err
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeAddedSelfServiceCategory{
SelfServiceCategoryName: category.Name,
TeamID: new(category.TeamID),
TeamName: teamName,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for added self-service category")
}
return category, nil
}
func (svc *Service) UpdateSoftwareCategory(ctx context.Context, id uint, name string) (*fleet.SoftwareCategory, error) {
name = strings.TrimSpace(name)
if err := (fleet.SoftwareCategory{Name: name}).Validate(); err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "validating updated software category")
}
// we need to load the category first to scope authz to its team_id
category, err := svc.ds.SoftwareCategory(ctx, id)
if err != nil {
if fleet.IsNotFound(err) {
if authzErr := svc.authz.Authorize(ctx, &fleet.SoftwareCategory{}, fleet.ActionWrite); authzErr != nil {
return nil, authzErr
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get software category")
}
if err := svc.authz.Authorize(ctx, category, fleet.ActionWrite); err != nil {
return nil, err
}
if category.Name == name {
return category, nil
}
updated, err := svc.ds.UpdateSoftwareCategory(ctx, id, name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "update software category")
}
teamName, err := svc.teamNameForActivity(ctx, updated.TeamID)
if err != nil {
return nil, err
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEditedSelfServiceCategory{
SelfServiceCategoryName: updated.Name,
TeamID: new(updated.TeamID),
TeamName: teamName,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for edited self-service category")
}
return updated, nil
}
func (svc *Service) DeleteSoftwareCategory(ctx context.Context, id uint) error {
// we need to load the category first to scope authz to its team_id
category, err := svc.ds.SoftwareCategory(ctx, id)
if err != nil {
if fleet.IsNotFound(err) {
if authzErr := svc.authz.Authorize(ctx, &fleet.SoftwareCategory{}, fleet.ActionWrite); authzErr != nil {
return authzErr
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get software category")
}
if err := svc.authz.Authorize(ctx, category, fleet.ActionWrite); err != nil {
return err
}
if err := svc.ds.DeleteSoftwareCategory(ctx, id); err != nil {
return ctxerr.Wrap(ctx, err, "delete software category")
}
teamName, err := svc.teamNameForActivity(ctx, category.TeamID)
if err != nil {
return err
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDeletedSelfServiceCategory{
SelfServiceCategoryName: category.Name,
TeamID: new(category.TeamID),
TeamName: teamName,
}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for deleted self-service category")
}
return nil
}
func (svc *Service) removeDuplicateOrMissingCategories(ctx context.Context, teamID uint, names []string) ([]string, []uint, error) {
names = server.RemoveDuplicatesFromSlice(names)
categories := []string{}
ids := []uint{}
if len(names) == 0 {
return categories, ids, nil
}
categoryMap, err := svc.ds.GetSoftwareCategoryNameToIDMap(ctx, teamID, names)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "getting software category name to id map")
}
for _, name := range names {
if id, ok := categoryMap[name]; ok {
categories = append(categories, name)
ids = append(ids, id)
}
}
return categories, ids, nil
}
func (svc *Service) teamNameForActivity(ctx context.Context, teamID uint) (*string, error) {
if teamID == 0 {
return nil, nil
}
tm, err := svc.ds.TeamLite(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "fetching team name for category activity")
}
return &tm.Name, nil
}
func trimAndValidateCategories(ctx context.Context, categories []string) error {
for i, name := range categories {
categories[i] = strings.TrimSpace(name)
err := (fleet.SoftwareCategory{Name: categories[i]}).Validate()
if err != nil {
return ctxerr.Wrapf(ctx, err, "category %q", categories[i])
}
}
return nil
}