Files
fleet/server/fleet/labels.go
T
Ian Littman ec39fb3846 Add database migration for team ID on labels, update structs + summary DB query to pass through (#36842)
Resolves #36779.

The existing label queries (other than summary) use SELECT * so it was
easier to add the fields to the struct than to fix the queries to not do
SELECT *, hence slightly expanded scope here (which should make the
remaining API work on team labels go a bit more quickly).

# Checklist for submitter

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

Changes file skipped due to this being a small subtask.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements) -
see above

## Testing

- [x] Added/updated automated tests

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

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

GitOps work for this task will be merged in its own PR.
2025-12-08 08:52:39 -06:00

415 lines
14 KiB
Go

package fleet
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// ModifyLabelPayload is used to change editable fields for a Label
type ModifyLabelPayload struct {
Name *string `json:"name"`
Description *string `json:"description"`
// Hosts is the new list of host identifiers to apply for this label, only
// valid for manual labels. If it is nil (not just len() == 0, but == nil),
// then the list of hosts is not modified. If it is not nil and len == 0,
// then all members are removed.
Hosts []string `json:"hosts"`
HostIDs []uint `json:"host_ids"`
}
type HostVitalOperator string
const (
HostVitalOperatorEqual HostVitalOperator = "="
HostVitalOperatorNotEqual HostVitalOperator = "!="
HostVitalOperatorGreater HostVitalOperator = ">"
HostVitalOperatorLess HostVitalOperator = "<"
HostVitalOperatorLike HostVitalOperator = "LIKE"
)
type HostVitalCriteria struct {
Vital *string `json:"vital,omitempty"`
Value *string `json:"value,omitempty"`
Operator *HostVitalOperator `json:"operator,omitempty"`
And []HostVitalCriteria `json:"and,omitempty"`
Or []HostVitalCriteria `json:"or,omitempty"`
}
type LabelPayload struct {
Name string `json:"name"`
// Query is the SQL query that defines the label. This defines a dynamic
// label, where the hosts that are part of the label are determined based on
// the query result. Must be empty for a manual label, must be provided for a
// dynamic one.
Query string `json:"query"`
Platform string `json:"platform"`
Description string `json:"description"`
// Hosts is the list of host identifier (serial, uuid, name, etc. as
// supported by HostByIdentifier) that are part of the label. This defines a
// manual label. Can be empty for a manual label that doesn't target any
// host. Must be empty for a dynamic label.
Hosts []string `json:"hosts"`
HostIDs []uint `json:"host_ids"`
// Criteria is the set of criteria that defines a host vitals label.
Criteria *HostVitalCriteria `json:"criteria,omitempty"`
}
// LabelType is used to catagorize the kind of label
type LabelType uint
const (
// LabelTypeRegular is for user created labels that can be modified.
LabelTypeRegular LabelType = iota
// LabelTypeBuiltIn is for labels built into Fleet that cannot be
// modified by users.
LabelTypeBuiltIn
)
func (t LabelType) MarshalJSON() ([]byte, error) {
switch t {
case LabelTypeRegular:
return []byte(`"regular"`), nil
case LabelTypeBuiltIn:
return []byte(`"builtin"`), nil
default:
return nil, fmt.Errorf("invalid LabelType: %d", t)
}
}
func (t *LabelType) UnmarshalJSON(b []byte) error {
switch string(b) {
case `"regular"`, "0":
*t = LabelTypeRegular
case `"builtin"`, "1":
*t = LabelTypeBuiltIn
default:
return fmt.Errorf("invalid LabelType: %s", string(b))
}
return nil
}
// LabelMembershipType sets how the membership of the label is determined.
type LabelMembershipType uint
const (
// LabelTypeDynamic indicates that the label is populated dynamically (by
// the execution of a label query).
LabelMembershipTypeDynamic LabelMembershipType = iota
// LabelTypeManual indicates that the label is populated manually.
LabelMembershipTypeManual
// LabelMembershipTypeHostVitals indicates that the label is populated
// dynamically based on host vitals data.
LabelMembershipTypeHostVitals
)
func (t LabelMembershipType) MarshalJSON() ([]byte, error) {
switch t {
case LabelMembershipTypeDynamic:
return []byte(`"dynamic"`), nil
case LabelMembershipTypeManual:
return []byte(`"manual"`), nil
case LabelMembershipTypeHostVitals:
return []byte(`"host_vitals"`), nil
default:
return nil, fmt.Errorf("invalid LabelMembershipType: %d", t)
}
}
func (t *LabelMembershipType) UnmarshalJSON(b []byte) error {
switch string(b) {
case `"dynamic"`:
*t = LabelMembershipTypeDynamic
case `"manual"`:
*t = LabelMembershipTypeManual
case `"host_vitals"`:
*t = LabelMembershipTypeHostVitals
default:
return fmt.Errorf("invalid LabelMembershipType: %s", string(b))
}
return nil
}
// Create a separate interface for host vitals labels to allow for
// different query generation logic in tests.
type HostVitalsLabel interface {
CalculateHostVitalsQuery() (query string, values []any, err error)
GetLabel() *Label
}
type Label struct {
UpdateCreateTimestamps
ID uint `json:"id"`
AuthorID *uint `json:"author_id" db:"author_id"`
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"`
Platform string `json:"platform"`
LabelType LabelType `json:"label_type" db:"label_type"`
LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"`
HostCount int `json:"host_count,omitempty" db:"host_count"`
TeamID *uint `json:"team_id" db:"team_id"`
}
// Implement the HostVitalsLabel interface.
func (l *Label) GetLabel() *Label {
return l
}
type LabelSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TeamID *uint `json:"team_id" db:"team_id"`
LabelType LabelType `json:"label_type" db:"label_type"`
}
func (l Label) AuthzType() string {
return "label"
}
const (
LabelKind = "label"
)
type LabelQueryExecution struct {
ID uint
UpdatedAt time.Time
Matches bool
LabelID uint
HostID uint
}
type HostsSlice []string
// Custom unmarshaler to handle both string and integer host identifiers.
func (s *HostsSlice) UnmarshalJSON(data []byte) error {
var raw []interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if raw == nil {
// Differentiate between nil and empty array.
return nil
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
// Check if the float64 is actually an integer.
if val != float64(int64(val)) {
return fmt.Errorf("hosts must be strings or integers, got float %g", val)
}
// Convert to string.
result = append(result, fmt.Sprintf("%.0f", val))
default:
return fmt.Errorf("hosts must be strings or integers, got %T", v)
}
}
*s = result
return nil
}
type LabelSpec struct {
ID uint `json:"id" db:"id"`
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
Platform string `json:"platform,omitempty"`
LabelType LabelType `json:"label_type,omitempty" db:"label_type"`
LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"`
Hosts HostsSlice `json:"hosts"`
HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"`
}
const (
BuiltinLabelNameAllHosts = "All Hosts"
BuiltinLabelNameMacOS = "macOS"
BuiltinLabelNameUbuntuLinux = "Ubuntu Linux"
BuiltinLabelNameCentOSLinux = "CentOS Linux"
BuiltinLabelNameWindows = "MS Windows"
BuiltinLabelNameRedHatLinux = "Red Hat Linux"
BuiltinLabelNameAllLinux = "All Linux"
BuiltinLabelNameChrome = "chrome"
BuiltinLabelMacOS14Plus = "macOS 14+ (Sonoma+)"
BuiltinLabelIOS = "iOS"
BuiltinLabelIPadOS = "iPadOS"
BuiltinLabelFedoraLinux = "Fedora Linux"
BuiltinLabelNameAndroid = "Android"
)
// ReservedLabelNames returns a map of label name strings
// that are reserved by Fleet.
func ReservedLabelNames() map[string]struct{} {
return map[string]struct{}{
BuiltinLabelNameAllHosts: {},
BuiltinLabelNameMacOS: {},
BuiltinLabelNameUbuntuLinux: {},
BuiltinLabelNameCentOSLinux: {},
BuiltinLabelNameWindows: {},
BuiltinLabelNameRedHatLinux: {},
BuiltinLabelNameAllLinux: {},
BuiltinLabelNameChrome: {},
BuiltinLabelMacOS14Plus: {},
BuiltinLabelIOS: {},
BuiltinLabelIPadOS: {},
BuiltinLabelFedoraLinux: {},
BuiltinLabelNameAndroid: {},
}
}
// DetectMissingLabels returns a list of labels present in the unvalidatedLabels list that could not be found in the validLabelMap.
func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []string) []string {
missingLabels := make([]string, 0, len(unvalidatedLabels))
for _, rawLabel := range unvalidatedLabels {
label := strings.TrimSpace(rawLabel)
if _, ok := validLabelMap[label]; len(label) > 0 && !ok {
missingLabels = append(missingLabels, label)
}
}
return missingLabels
}
// LabelIdent is a simple struct to hold the ID and Name of a label
type LabelIdent struct {
LabelID uint `json:"id"`
LabelName string `json:"name"`
}
// LabelScope identifies the manner by which labels may be used to scope entities, such as MDM
// profiles and software installers, to subsets of hosts.
type LabelScope string
const (
// LabelScopeExcludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should NOT be applied to a host if the host is a mamber of any of the associated labels.
LabelScopeExcludeAny LabelScope = "exclude_any"
// LabelScopeIncludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should be applied to a host that if the host is a member of all of the associated labels.
LabelScopeIncludeAny LabelScope = "include_any"
// LabelScopeIncludeAll indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should be applied to a host if the host is a member of all of the associated labels.
LabelScopeIncludeAll LabelScope = "include_all"
)
type LabelIdentsWithScope struct {
LabelScope LabelScope
ByName map[string]LabelIdent
}
// Equal returns whether or not 2 LabelIdentsWithScope pointers point to equivalent values.
func (l *LabelIdentsWithScope) Equal(other *LabelIdentsWithScope) bool {
if l == nil || other == nil {
return l == other
}
if l.LabelScope != other.LabelScope {
return false
}
if l.ByName == nil && other.ByName == nil {
return true
}
if len(l.ByName) != len(other.ByName) {
return false
}
for k, v := range l.ByName {
otherV, ok := other.ByName[k]
if !ok {
return false
}
if v != otherV {
return false
}
}
return true
}
// Translate label host vitals crteria into a query.
// TODO -- add caching support for this query?
func (l *Label) CalculateHostVitalsQuery() (query string, values []any, err error) {
var criteria *HostVitalCriteria
if l.HostVitalsCriteria == nil {
return "", nil, errors.New("label has no host vitals criteria")
}
// Unmarshal the criteria from JSON.
if err := json.Unmarshal(*l.HostVitalsCriteria, &criteria); err != nil {
return "", nil, fmt.Errorf("unmarshalling host vitals criteria: %w", err)
}
// We'll use a set to gather the foreign vitals groups we need to join on,
// so that we can avoid duplicates.
foreignVitalsGroups := make(map[*HostForeignVitalGroup]struct{})
// Hold values to be substituted in the paramerized query.
values = make([]any, 0)
// Recursively parse the criteria to build the WHERE clause.
whereClause, err := parseHostVitalCriteria(criteria, foreignVitalsGroups, &values)
if err != nil {
return "", nil, fmt.Errorf("parsing host vitals criteria: %w", err)
}
// If there are foreign vitals groups, concatenate all their joins.
joins := make([]string, 0, len(foreignVitalsGroups))
if len(foreignVitalsGroups) > 0 {
for group := range foreignVitalsGroups {
joins = append(joins, group.Query)
}
}
// Leave SELECT and FROM to be filled in later for flexibility.
query = "SELECT %s FROM %s " + strings.Join(joins, " ") + " WHERE " + whereClause + " GROUP BY hosts.id"
return
}
// Translates a HostVitalCriteria into part of a SQL WHERE clause
// TODO: add support for And/Or criteria
func parseHostVitalCriteria(criteria *HostVitalCriteria, foreignVitalsGroups map[*HostForeignVitalGroup]struct{}, values *[]any) (string, error) {
// We don't support anything other than vital/value right now.
if criteria.And != nil || criteria.Or != nil {
return "", errors.New("And/Or criteria not supported in host vitals labels yet")
}
if criteria.Vital == nil {
return "", errors.New("vital criteria must have a vital")
}
if criteria.Value == nil {
return "", fmt.Errorf("vital %s must have a value", *criteria.Vital)
}
// Look up the vital in the map.
vital, ok := hostVitals[*criteria.Vital]
if !ok {
return "", fmt.Errorf("unknown vital %s", *criteria.Vital)
}
// If the vital is a foreign vitals group, add it to the list of foreign vitals groups.
if vital.VitalType == HostVitalTypeForeign {
foreignVitalsGroup, ok := hostForeignVitalGroups[*vital.ForeignVitalGroup]
if !ok {
return "", fmt.Errorf("unknown foreign vital group %s", *vital.ForeignVitalGroup)
}
foreignVitalsGroups[&foreignVitalsGroup] = struct{}{}
}
*values = append(*values, *criteria.Value)
operator := criteria.Operator
if operator == nil {
// Default to equality if no operator is specified.
op := HostVitalOperatorEqual
operator = &op
}
// TODO - handle different vital data types and operator types.
// For now, we only support equality checks.
if *operator != HostVitalOperatorEqual {
return "", fmt.Errorf("operator %s not supported for vital %s", *operator, *criteria.Vital)
}
return fmt.Sprintf("%s = ?", vital.Path), nil
}