**Related issue:** Resolves #49971 Serves the custom activations stored by #49970 to devices. Stacked on #50280. - The manifest advertises a custom activation's own identifier; the fetch serves its stored JSON with the predicate intact. Secrets and host variables are expanded at delivery, like configurations. - `activation_updated_at` folds into the token, so editing only a predicate re-syncs the host even though the declaration's content didn't change. - Management declarations are served under the manifest's `Management` section, have their own endpoint, and report status. - Predicate outcomes map per the Figma dev note: `Info.Predicate` → verified with an explanation, `Error.ActivationFailed` → failed. ### Three things to look at **Generated activations are renamed to `<declaration uuid>.activation`.** Removes the collision between an admin-chosen identifier and one Fleet generates, and collapses the fetch into a single lookup rather than match-then-trim. Note this differs from the CoS wording of "byte-for-byte identical to today". Existing hosts will see their activations renamed once. The sync token doesn't move for the rename alone — activation identifiers aren't part of it, confirmed in `MDMAppleDDMDeclarationsToken` — so it happens lazily on the next real DDM change rather than all at once. **`activation_updated_at` is not the `max()` the issue describes.** The issue asks for `max(activation uploaded_at, latest variable change)`. Variable changes stay on `variables_updated_at`; this is `GREATEST(uploaded_at, secrets_updated_at)`. Same outcome, since `EffectiveDDMToken` hashes all the timestamps together, but each signal stays independently comparable, which is what keeps the reconciler idempotent. **The token is computed in three places, not two.** SQL in `MDMAppleDDMDeclarationsToken`, Go in `EffectiveDDMToken`, and a third independent copy building the manifest-level token in `handleDeclarationItems`. All three now carry the new term in the same order. `ActivationUpdatedAtFoldsIntoToken` asserts the SQL and Go agree with it populated — they only agreed for the nil case before. # Checklist for submitter - [x] 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. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually Tested on an ADE-enrolled Mac (macOS 26.5.1) against a local server: the custom activation is advertised and fetched by its own identifier, an activation-only edit re-syncs the device even though the declaration is unchanged, a true predicate enforces the policy on the host, and a false one comes back verified with the explanation. That turned up two bugs, both fixed here and both invisible to the test suite: - `MDMAppleStoreDDMStatusReport` computed the matching token with `activation_updated_at` but never selected the column, so it was always nil. Every status report was silently dropped and profiles sat in verifying forever. - Apple splits a predicate outcome across two arrays — the activation reports `Info.Predicate`, the configuration it gates reports `Error.ActivationFailed`. Reading only the configuration made a host the predicate excluded look failed. Now correlated via the activation identifier in the failure details. The test payloads for the second one are the real reports the Mac sent, not hand-written. Note for anyone testing in the UI: the OS settings tooltip for a verified profile is hardcoded to "The host applied the setting. Fleet verified." and doesn't read `detail` yet, so a predicate-excluded profile reads wrong there. Backend stores the right message; rendering it is #49973. New cases in `apple_mdm_ddm_test.go`: the token agreement above; a custom activation advertised and served with its predicate, with the generated name no longer resolving for it; a declaration without one still getting the synthesized activation; management routing plus the endpoint type guard; the three predicate status outcomes; and a host outside a declaration's scope getting an empty manifest and an error fetching the activation by name. 13 existing assertions moved from `<identifier>.activation` to `<uuid>.activation`. The `GeneratedActivationStillServedWhenNoCustomOne` case caught a real bug on its first run: the `LEFT JOIN` returns NULL for declarations without a custom activation, which `json.RawMessage` can't scan, so every non-custom activation fetch would have 500'd. That's the no-regression path. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Apple DDM management declarations and custom activation delivery. * Added host-scoped activation resolution, activation identifiers, and activation update timestamps. * Added support for activation variables, secrets, assets, and separate management and configuration endpoints. * **Bug Fixes** * Declarations are now redelivered when activation predicates change or are removed. * Improved status reporting for predicate exclusions, activation failures, and management declarations. * Improved synchronization token updates when variables, assets, or activations change. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
684 lines
27 KiB
Go
684 lines
27 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/jmoiron/sqlx"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
var customHostVitalAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
|
|
"name": "name",
|
|
"id": "id",
|
|
"updated_at": "updated_at",
|
|
}
|
|
|
|
func (ds *Datastore) CreateCustomHostVital(ctx context.Context, name string) (fleet.CustomHostVital, error) {
|
|
res, err := ds.writer(ctx).ExecContext(ctx,
|
|
`INSERT INTO custom_host_vitals (name) VALUES (?)`,
|
|
name,
|
|
)
|
|
if err != nil {
|
|
if IsDuplicate(err) {
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate")
|
|
}
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "insert custom host vital")
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return fleet.CustomHostVital{ID: uint(id), Name: name}, nil //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
func (ds *Datastore) ListCustomHostVitals(ctx context.Context, opt fleet.ListOptions) (
|
|
customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error,
|
|
) {
|
|
stmt := `SELECT id, name, created_at, updated_at FROM custom_host_vitals WHERE true`
|
|
|
|
// normalize the name for full Unicode support (Unicode equivalence).
|
|
// Search matches the name OR the variable name (the derived
|
|
// `$FLEET_HOST_VITAL_<id>` token). The second column is a hardcoded SQL
|
|
// expression (not user input); searchLike escapes the LIKE pattern.
|
|
normMatch := norm.NFC.String(opt.MatchQuery)
|
|
whereClauses, args := searchLike("", nil, normMatch, "name", `CONCAT('$FLEET_HOST_VITAL_', id)`)
|
|
stmt += whereClauses
|
|
|
|
// perform a second query to grab the count
|
|
// build the count statement before adding pagination constraints
|
|
countStmt := fmt.Sprintf("SELECT COUNT(DISTINCT id) FROM (%s) AS s", stmt)
|
|
|
|
stmt, args, err = appendListOptionsWithCursorToSQLSecure(stmt, args, &opt, customHostVitalAllowedOrderKeys)
|
|
if err != nil {
|
|
return nil, nil, 0, ctxerr.Wrap(ctx, err, "apply list options")
|
|
}
|
|
|
|
dbReader := ds.reader(ctx)
|
|
if err := sqlx.SelectContext(ctx, dbReader, &customHostVitals, stmt, args...); err != nil {
|
|
return nil, nil, 0, ctxerr.Wrap(ctx, err, "listing custom host vitals")
|
|
}
|
|
if err := sqlx.GetContext(ctx, dbReader, &count, countStmt, args...); err != nil {
|
|
return nil, nil, 0, ctxerr.Wrap(ctx, err, "get custom host vitals count")
|
|
}
|
|
|
|
if opt.IncludeMetadata {
|
|
meta = &fleet.PaginationMetadata{
|
|
HasPreviousResults: opt.Page > 0,
|
|
TotalResults: uint(count), //nolint:gosec // dismiss G115
|
|
}
|
|
// `appendListOptionsWithCursorToSQL` used above to build the query statement will cause this discrepancy.
|
|
if len(customHostVitals) > int(opt.PerPage) { //nolint:gosec // dismiss G115
|
|
meta.HasNextResults = true
|
|
customHostVitals = customHostVitals[:len(customHostVitals)-1]
|
|
}
|
|
}
|
|
|
|
return customHostVitals, meta, count, nil
|
|
}
|
|
|
|
func (ds *Datastore) UpdateCustomHostVital(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) {
|
|
res, err := ds.writer(ctx).ExecContext(ctx,
|
|
`UPDATE custom_host_vitals SET name = ? WHERE id = ?`,
|
|
name, id,
|
|
)
|
|
if err != nil {
|
|
if IsDuplicate(err) {
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate")
|
|
}
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "update custom host vital")
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
if affected == 0 {
|
|
// No rows affected can mean the id was not found, or the name is unchanged.
|
|
// Distinguish the two so a no-op rename doesn't surface as NotFound.
|
|
// Check on the writer: the UPDATE above targeted the primary, so a replica
|
|
// lagging behind it could otherwise report a false NotFound.
|
|
var exists bool
|
|
if err := sqlx.GetContext(ctx, ds.writer(ctx), &exists,
|
|
`SELECT 1 FROM custom_host_vitals WHERE id = ?`, id); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id))
|
|
}
|
|
return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "check custom host vital exists")
|
|
}
|
|
}
|
|
return fleet.CustomHostVital{ID: id, Name: name}, nil
|
|
}
|
|
|
|
func (ds *Datastore) DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) {
|
|
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
err := sqlx.GetContext(ctx, tx, &name, `SELECT name FROM custom_host_vitals WHERE id = ?`, id)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id))
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "getting name of custom host vital to delete")
|
|
}
|
|
|
|
// Refuse to delete a definition still referenced by a script/profile.
|
|
if usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, id, name); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking custom host vital references")
|
|
} else if usedByInfo != nil {
|
|
return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use")
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM custom_host_vitals WHERE id = ?`, id); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete custom host vital")
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "delete custom host vital")
|
|
}
|
|
|
|
return name, nil
|
|
}
|
|
|
|
// customHostVitalRefEntity is a script or profile scanned for $FLEET_HOST_VITAL_<id>
|
|
// references during delete-protection.
|
|
type customHostVitalRefEntity struct {
|
|
// Type is the entity type, "script", "apple_profile", "apple_declaration", or "windows_profile".
|
|
Type string `db:"entity"`
|
|
// Name is the name of the entity.
|
|
Name string `db:"name"`
|
|
// FleetName is the name of the fleet (team) the entity belongs to.
|
|
FleetName string `db:"team_name"`
|
|
// Contents is the content of the entity (script's/profile's body).
|
|
Contents string `db:"contents"`
|
|
}
|
|
|
|
// customHostVitalUsedBy scans scripts, Apple configuration profiles, Apple
|
|
// declarations, Windows configuration profiles, Android configuration
|
|
// profiles, software installer scripts, setup-experience scripts, and
|
|
// team/No-team host name templates for a $FLEET_HOST_VITAL_<id> (or
|
|
// ${FLEET_HOST_VITAL_<id>}) reference to the given vital id, then separately
|
|
// checks host-vitals labels (which reference the vital by id in their
|
|
// criteria JSON, not via the token). It returns a *fleet.CustomHostVitalUsedInfo
|
|
// describing the first referencing entity found, or nil if unreferenced.
|
|
// Mirrors the scan structure of DeleteSecretVariable. The second return is a
|
|
// real DB error.
|
|
func (ds *Datastore) customHostVitalUsedBy(ctx context.Context, tx sqlx.ExtContext, id uint, name string) (*fleet.CustomHostVitalUsedInfo, error) {
|
|
// The token embeds the numeric id (survives renames), so match by id, not name.
|
|
token := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, id)
|
|
|
|
// Each scan mirrors DeleteSecretVariable: pull the content column of every
|
|
// script/profile/declaration and check for the token in Go (os.Expand-based,
|
|
// so it matches both $VAR and ${VAR} forms).
|
|
scans := []struct {
|
|
desc string
|
|
stmt string
|
|
}{
|
|
{
|
|
desc: "get script contents",
|
|
stmt: `SELECT 'script' AS entity, s.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
|
|
FROM script_contents sc
|
|
JOIN scripts s ON s.script_content_id = sc.id
|
|
LEFT JOIN teams t ON t.id = s.team_id;`,
|
|
},
|
|
{
|
|
desc: "get apple profile contents",
|
|
stmt: `SELECT 'apple_profile' AS entity, p.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, p.mobileconfig AS contents
|
|
FROM mdm_apple_configuration_profiles p
|
|
LEFT JOIN teams t ON t.id = p.team_id;`,
|
|
},
|
|
{
|
|
desc: "get apple declaration contents",
|
|
stmt: `SELECT 'apple_declaration' AS entity, d.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, d.raw_json AS contents
|
|
FROM mdm_apple_declarations d
|
|
LEFT JOIN teams t ON t.id = d.team_id;`,
|
|
},
|
|
{
|
|
desc: "get windows profile contents",
|
|
stmt: `SELECT 'windows_profile' AS entity, p.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, p.syncml AS contents
|
|
FROM mdm_windows_configuration_profiles p
|
|
LEFT JOIN teams t ON t.id = p.team_id;`,
|
|
},
|
|
{
|
|
desc: "get android profile contents",
|
|
stmt: `SELECT 'android_profile' AS entity, p.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, p.raw_json AS contents
|
|
FROM mdm_android_configuration_profiles p
|
|
LEFT JOIN teams t ON t.id = p.team_id;`,
|
|
},
|
|
// Software installer and setup-experience scripts exceed secret-variable
|
|
// delete-protection (which doesn't scan them), so a vital can't be deleted
|
|
// while a script that runs it would silently start failing on hosts.
|
|
{
|
|
desc: "get software installer script contents",
|
|
stmt: `SELECT 'software_installer' AS entity, COALESCE(st.name, si.filename) AS name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
|
|
FROM software_installers si
|
|
JOIN script_contents sc ON sc.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id)
|
|
LEFT JOIN software_titles st ON st.id = si.title_id
|
|
LEFT JOIN teams t ON t.id = si.team_id;`,
|
|
},
|
|
{
|
|
desc: "get setup experience script contents",
|
|
stmt: `SELECT 'setup_experience_script' AS entity, ses.name,
|
|
COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
|
|
FROM setup_experience_scripts ses
|
|
JOIN script_contents sc ON sc.id = ses.script_content_id
|
|
LEFT JOIN teams t ON t.id = ses.team_id;`,
|
|
},
|
|
// Host name templates aren't scripts/profiles, but the token can appear in
|
|
// a team's (or "No team"'s) name_template, same as DeleteSecretVariable
|
|
// scans for $FLEET_SECRET_* there. A team's name_template is a plain
|
|
// string that always serializes into the config JSON (as "" when unset),
|
|
// and the No-team template is an optjson that serializes to null when
|
|
// unset, so filter both on a non-empty resolved value rather than
|
|
// IS NOT NULL (avoids scanning a NULL contents column).
|
|
{
|
|
desc: "get host name template contents",
|
|
stmt: `SELECT 'host_name_template' AS entity, 'Host name' AS name,
|
|
t.name AS team_name, t.config->>'$.mdm.name_template' AS contents
|
|
FROM teams t
|
|
WHERE COALESCE(t.config->>'$.mdm.name_template', '') != ''
|
|
UNION ALL
|
|
SELECT 'host_name_template' AS entity, 'Host name' AS name,
|
|
'Unassigned' AS team_name, json_value->>'$.mdm.name_template' AS contents
|
|
FROM app_config_json
|
|
WHERE COALESCE(json_value->>'$.mdm.name_template', '') != '';`,
|
|
},
|
|
}
|
|
|
|
for _, scan := range scans {
|
|
var entities []customHostVitalRefEntity
|
|
if err := sqlx.SelectContext(ctx, tx, &entities, scan.stmt); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, scan.desc)
|
|
}
|
|
for _, e := range entities {
|
|
if fleet.ContainsVar(e.Contents, token) {
|
|
return &fleet.CustomHostVitalUsedInfo{
|
|
CustomHostVitalID: id,
|
|
CustomHostVitalName: name,
|
|
Entity: fleet.EntityUsingCustomHostVital{
|
|
Type: fleet.CustomHostVitalEntity(e.Type),
|
|
Name: e.Name,
|
|
FleetName: e.FleetName,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Host-vitals labels reference the vital by id inside their criteria JSON
|
|
// (not by the $FLEET_HOST_VITAL_<id> token), so they need a structured check
|
|
// rather than the content-token scan above.
|
|
var labels []struct {
|
|
Name string `db:"name"`
|
|
FleetName string `db:"team_name"`
|
|
Criteria json.RawMessage `db:"criteria"`
|
|
}
|
|
labelStmt := `SELECT l.name, COALESCE(t.name, 'Unassigned') AS team_name, l.criteria
|
|
FROM labels l
|
|
LEFT JOIN teams t ON t.id = l.team_id
|
|
WHERE l.label_membership_type = ? AND l.criteria IS NOT NULL`
|
|
if err := sqlx.SelectContext(ctx, tx, &labels, labelStmt, fleet.LabelMembershipTypeHostVitals); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host vitals label criteria")
|
|
}
|
|
for _, l := range labels {
|
|
var criteria fleet.HostVitalCriteria
|
|
// A label with malformed criteria is already broken; skip it rather than
|
|
// block the delete on it.
|
|
if err := json.Unmarshal(l.Criteria, &criteria); err != nil {
|
|
ds.logger.WarnContext(ctx, "skipping host vitals label with unparseable criteria during custom host vital delete-protection scan",
|
|
"label", l.Name, "error", err)
|
|
continue
|
|
}
|
|
if criteria.CustomHostVitalID != nil && *criteria.CustomHostVitalID == id {
|
|
return &fleet.CustomHostVitalUsedInfo{
|
|
CustomHostVitalID: id,
|
|
CustomHostVitalName: name,
|
|
Entity: fleet.EntityUsingCustomHostVital{
|
|
Type: fleet.CustomHostVitalEntityLabel,
|
|
Name: l.Name,
|
|
FleetName: l.FleetName,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (ds *Datastore) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
|
|
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value)
|
|
VALUES (?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
|
hostID, vitalID, value,
|
|
); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "set host custom host vital value")
|
|
}
|
|
|
|
// Re-queue any MDM profiles/declarations already delivered to this host
|
|
// that reference the vital, so the reconcilers re-expand
|
|
// $FLEET_HOST_VITAL_<id> with the new value. Runs in the same transaction
|
|
// as the value write so the reconciler never reads a stale value.
|
|
if err := resendMDMProfilesForCustomHostVital(ctx, tx, hostID, vitalID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "resend mdm profiles for custom host vital value change")
|
|
}
|
|
|
|
// Re-queue the host's device-name enforcement row (if its name template
|
|
// references the vital), so the cron re-resolves the name with the new
|
|
// value. Same transaction as the value write, for the same reason as above.
|
|
if err := resendDeviceNameForCustomHostVital(ctx, tx, hostID, vitalID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "resend device name for custom host vital value change")
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// resendMDMProfilesForCustomHostVital resets the status of the Apple/Windows/
|
|
// Android configuration profiles and Apple DDM declarations already delivered
|
|
// to the host that reference $FLEET_HOST_VITAL_<vitalID>, so the reconcilers
|
|
// resend them with the host's newly-set value. Mirrors triggerResendProfilesUsingVariables,
|
|
// but matches by profile/declaration content because custom host vitals aren't
|
|
// tracked in mdm_configuration_profile_variables. Declarations only reset status
|
|
// (the DDM reconciler re-stamps variables_updated_at, cache-busting the token).
|
|
//
|
|
// Unlike the IdP resend, this deliberately omits certificate templates and
|
|
// Android managed app config: a vital can't reach cert templates (they only
|
|
// take fleet_variables), and Android managed app config isn't tracked by
|
|
// content-match resend like profiles are (its delivery is driven by the
|
|
// software worker, not the profile reconciler), so there's nothing on those
|
|
// two surfaces to resend here.
|
|
func resendMDMProfilesForCustomHostVital(ctx context.Context, tx sqlx.ExtContext, hostID, vitalID uint) error {
|
|
var hostUUID string
|
|
if err := sqlx.GetContext(ctx, tx, &hostUUID, `SELECT uuid FROM hosts WHERE id = ?`, hostID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "get host uuid for custom host vital resend")
|
|
}
|
|
|
|
// varName is the exact token (id included) matched precisely, id-boundary and
|
|
// ${...}-aware, by ContainsVar in Go. The INSTR prefix filter in SQL only
|
|
// narrows candidates; it deliberately over-matches (ignores the id) so the
|
|
// Go pass does the authoritative match.
|
|
varName := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, vitalID)
|
|
|
|
// These SELECTs filter on host_uuid first — the leftmost column of each
|
|
// host-profile table's PRIMARY KEY (host_uuid, {profile,declaration}_uuid) —
|
|
// so the INSTR content match only evaluates this one host's rows, not the
|
|
// whole table; cost is independent of fleet size.
|
|
const (
|
|
customHostVitalResendAppleProfilesSelectStmt = `SELECT hmap.profile_uuid AS uuid, macp.mobileconfig AS contents
|
|
FROM host_mdm_apple_profiles hmap
|
|
JOIN mdm_apple_configuration_profiles macp ON macp.profile_uuid = hmap.profile_uuid
|
|
WHERE hmap.host_uuid = ? AND hmap.operation_type = ? AND hmap.status IS NOT NULL AND INSTR(macp.mobileconfig, ?) > 0`
|
|
|
|
customHostVitalResendWindowsProfilesSelectStmt = `SELECT hmwp.profile_uuid AS uuid, mwcp.syncml AS contents
|
|
FROM host_mdm_windows_profiles hmwp
|
|
JOIN mdm_windows_configuration_profiles mwcp ON mwcp.profile_uuid = hmwp.profile_uuid
|
|
WHERE hmwp.host_uuid = ? AND hmwp.operation_type = ? AND hmwp.status IS NOT NULL AND INSTR(mwcp.syncml, ?) > 0`
|
|
|
|
// A custom activation is expanded at delivery like the declaration, so a
|
|
// vital referenced only there has to resend the owning declaration too.
|
|
customHostVitalResendAppleDeclarationsSelectStmt = `SELECT hmad.declaration_uuid AS uuid,
|
|
CONCAT(mad.raw_json, ' ', COALESCE(act.raw_json, '')) AS contents
|
|
FROM host_mdm_apple_declarations hmad
|
|
JOIN mdm_apple_declarations mad ON mad.declaration_uuid = hmad.declaration_uuid
|
|
LEFT JOIN mdm_apple_ddm_activations act ON act.declaration_uuid = mad.declaration_uuid
|
|
WHERE hmad.host_uuid = ? AND hmad.operation_type = ? AND hmad.status IS NOT NULL
|
|
AND INSTR(CONCAT(mad.raw_json, ' ', COALESCE(act.raw_json, '')), ?) > 0`
|
|
|
|
customHostVitalResendAndroidProfilesSelectStmt = `SELECT hmap.profile_uuid AS uuid, macp.raw_json AS contents
|
|
FROM host_mdm_android_profiles hmap
|
|
JOIN mdm_android_configuration_profiles macp ON macp.profile_uuid = hmap.profile_uuid
|
|
WHERE hmap.host_uuid = ? AND hmap.operation_type = ? AND hmap.status IS NOT NULL AND INSTR(macp.raw_json, ?) > 0`
|
|
)
|
|
|
|
targets := []struct {
|
|
desc string
|
|
selectStmt string
|
|
updateStmt string
|
|
}{
|
|
{
|
|
desc: "apple profiles",
|
|
selectStmt: customHostVitalResendAppleProfilesSelectStmt,
|
|
updateStmt: `UPDATE host_mdm_apple_profiles
|
|
SET status = NULL, detail = NULL, command_uuid = ''
|
|
WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`,
|
|
},
|
|
{
|
|
desc: "windows profiles",
|
|
selectStmt: customHostVitalResendWindowsProfilesSelectStmt,
|
|
updateStmt: `UPDATE host_mdm_windows_profiles
|
|
SET status = NULL, detail = NULL, command_uuid = ''
|
|
WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`,
|
|
},
|
|
{
|
|
desc: "apple declarations",
|
|
selectStmt: customHostVitalResendAppleDeclarationsSelectStmt,
|
|
updateStmt: `UPDATE host_mdm_apple_declarations
|
|
SET status = NULL, detail = NULL
|
|
WHERE host_uuid = ? AND operation_type = ? AND declaration_uuid IN (?)`,
|
|
},
|
|
{
|
|
desc: "android profiles",
|
|
selectStmt: customHostVitalResendAndroidProfilesSelectStmt,
|
|
updateStmt: `UPDATE host_mdm_android_profiles
|
|
SET status = NULL, detail = NULL
|
|
WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`,
|
|
},
|
|
}
|
|
|
|
for _, tgt := range targets {
|
|
var rows []struct {
|
|
UUID string `db:"uuid"`
|
|
Contents string `db:"contents"`
|
|
}
|
|
if err := sqlx.SelectContext(ctx, tx, &rows, tgt.selectStmt,
|
|
hostUUID, fleet.MDMOperationTypeInstall, fleet.CustomHostVitalPrefix); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "select "+tgt.desc+" referencing custom host vital")
|
|
}
|
|
|
|
var uuids []string
|
|
for _, r := range rows {
|
|
if fleet.ContainsVar(r.Contents, varName) {
|
|
uuids = append(uuids, r.UUID)
|
|
}
|
|
}
|
|
if len(uuids) == 0 {
|
|
continue
|
|
}
|
|
|
|
stmt, args, err := sqlx.In(tgt.updateStmt, hostUUID, fleet.MDMOperationTypeInstall, uuids)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build resend update for "+tgt.desc)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "reset "+tgt.desc+" for custom host vital resend")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
|
|
var vitals []fleet.HostCustomHostVital
|
|
err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, `
|
|
SELECT chv.id AS custom_host_vital_id, chv.name, COALESCE(hchv.value, '') AS value
|
|
FROM custom_host_vitals chv
|
|
LEFT JOIN host_custom_host_vitals hchv
|
|
ON hchv.custom_host_vital_id = chv.id AND hchv.host_id = ?
|
|
ORDER BY chv.name`,
|
|
hostID,
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host custom host vitals")
|
|
}
|
|
return vitals, nil
|
|
}
|
|
|
|
// ExpandCustomHostVitals substitutes $FLEET_HOST_VITAL_<id> tokens in the
|
|
// document with the given host's stored values, applying format-aware escaping
|
|
// (JSON/XML) like expandEmbeddedSecrets. If a referenced vital has no value for
|
|
// the host (no row, or an empty value), it returns a MissingCustomHostVitalValueError
|
|
// so delivery fails rather than substituting an empty value (product decision).
|
|
func (ds *Datastore) ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) {
|
|
refIDs := fleet.FindCustomHostVitalIDs(document)
|
|
if len(refIDs) == 0 {
|
|
return document, nil
|
|
}
|
|
|
|
vitals, err := ds.GetHostCustomHostVitals(ctx, hostID)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "expanding custom host vitals")
|
|
}
|
|
|
|
// A vital with an empty value counts as missing: we refuse to ship an empty
|
|
// substitution.
|
|
valueByID := make(map[uint]string, len(vitals))
|
|
nameByID := make(map[uint]string, len(vitals))
|
|
for _, v := range vitals {
|
|
nameByID[v.CustomHostVitalID] = v.Name
|
|
if v.Value == "" {
|
|
continue
|
|
}
|
|
valueByID[v.CustomHostVitalID] = v.Value
|
|
}
|
|
|
|
var missingIDs []uint
|
|
var missingNames []string
|
|
for _, id := range refIDs {
|
|
if _, ok := valueByID[id]; !ok {
|
|
missingIDs = append(missingIDs, id)
|
|
missingNames = append(missingNames, nameByID[id])
|
|
}
|
|
}
|
|
if len(missingIDs) > 0 {
|
|
// The vital exists (validated on upload); the host just has no value for it.
|
|
return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: missingIDs, MissingNames: missingNames}
|
|
}
|
|
|
|
expanded := expandDocumentVars(document, func(s string) (string, bool) {
|
|
if !strings.HasPrefix(s, fleet.CustomHostVitalPrefix) {
|
|
return "", false
|
|
}
|
|
id, parseErr := strconv.ParseUint(strings.TrimPrefix(s, fleet.CustomHostVitalPrefix), 10, strconv.IntSize)
|
|
if parseErr != nil {
|
|
return "", false
|
|
}
|
|
val, ok := valueByID[uint(id)]
|
|
return val, ok
|
|
})
|
|
|
|
return expanded, nil
|
|
}
|
|
|
|
// ValidateReferencedCustomHostVitals parses $FLEET_HOST_VITAL_<id> tokens from
|
|
// the given documents and verifies every referenced id resolves to a definition.
|
|
// Mirrors ValidateEmbeddedSecrets. Returns a MissingCustomHostVitalsError listing
|
|
// any unknown ids.
|
|
func (ds *Datastore) ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error {
|
|
wantIDs := make(map[uint]struct{})
|
|
var malformed []string
|
|
seenMalformed := make(map[string]struct{})
|
|
for _, document := range documents {
|
|
// A $FLEET_HOST_VITAL_<x> token whose <x> isn't a valid ID (e.g. a typo like
|
|
// $FLEET_HOST_VITAL_asset_tag) is rejected rather than silently delivered as
|
|
// a literal token, matching how $FLEET_VAR_*/$FLEET_SECRET_* reject unknowns.
|
|
for _, ref := range fleet.ContainsMalformedCustomHostVitalRefs(document) {
|
|
if _, ok := seenMalformed[ref]; ok {
|
|
continue
|
|
}
|
|
seenMalformed[ref] = struct{}{}
|
|
malformed = append(malformed, ref)
|
|
}
|
|
for _, id := range fleet.FindCustomHostVitalIDs(document) {
|
|
wantIDs[id] = struct{}{}
|
|
}
|
|
}
|
|
if len(malformed) > 0 {
|
|
return &fleet.InvalidCustomHostVitalRefError{Refs: malformed}
|
|
}
|
|
if len(wantIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
wantIDsList := make([]uint, 0, len(wantIDs))
|
|
for id := range wantIDs {
|
|
wantIDsList = append(wantIDsList, id)
|
|
}
|
|
|
|
dbVitals, err := ds.GetCustomHostVitals(ctx, wantIDsList)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "validating document referenced custom host vitals")
|
|
}
|
|
|
|
haveIDs := make(map[uint]struct{}, len(dbVitals))
|
|
for _, v := range dbVitals {
|
|
haveIDs[v.ID] = struct{}{}
|
|
}
|
|
|
|
var missingIDs []uint
|
|
for id := range wantIDs {
|
|
if _, ok := haveIDs[id]; !ok {
|
|
missingIDs = append(missingIDs, id)
|
|
}
|
|
}
|
|
if len(missingIDs) > 0 {
|
|
return &fleet.MissingCustomHostVitalsError{MissingIDs: missingIDs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetCustomHostVitals(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
|
|
stmt, args, err := sqlx.In(`
|
|
SELECT id, name, created_at, updated_at
|
|
FROM custom_host_vitals
|
|
WHERE id IN (?)`, ids)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "build custom host vitals query")
|
|
}
|
|
|
|
var vitals []fleet.CustomHostVital
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, stmt, args...); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get custom host vitals")
|
|
}
|
|
return vitals, nil
|
|
}
|
|
|
|
func (ds *Datastore) UpsertCustomHostVitals(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) {
|
|
incomingNames := make(map[string]struct{}, len(vitals))
|
|
for _, v := range vitals {
|
|
incomingNames[v.Name] = struct{}{}
|
|
}
|
|
|
|
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
created, deleted = nil, nil
|
|
|
|
var existing []fleet.CustomHostVital
|
|
if err := sqlx.SelectContext(ctx, tx, &existing, `SELECT id, name FROM custom_host_vitals`); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list existing custom host vitals")
|
|
}
|
|
|
|
existingNames := make(map[string]struct{}, len(existing))
|
|
for _, e := range existing {
|
|
existingNames[e.Name] = struct{}{}
|
|
if _, ok := incomingNames[e.Name]; !ok {
|
|
deleted = append(deleted, e)
|
|
}
|
|
}
|
|
|
|
var toInsert []string
|
|
for _, v := range vitals {
|
|
if _, ok := existingNames[v.Name]; !ok {
|
|
toInsert = append(toInsert, v.Name)
|
|
}
|
|
}
|
|
|
|
for _, v := range deleted {
|
|
usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, v.ID, v.Name)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking custom host vital references")
|
|
}
|
|
if usedByInfo != nil {
|
|
return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use")
|
|
}
|
|
}
|
|
|
|
if len(deleted) > 0 {
|
|
ids := make([]uint, 0, len(deleted))
|
|
for _, v := range deleted {
|
|
ids = append(ids, v.ID)
|
|
}
|
|
stmt, args, err := sqlx.In(`DELETE FROM custom_host_vitals WHERE id IN (?)`, ids)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build delete custom host vitals query")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete custom host vitals")
|
|
}
|
|
}
|
|
|
|
// Inserted one at a time (rather than a single multi-row INSERT) so each
|
|
// row's LastInsertId can be captured for the returned `created` list.
|
|
for _, name := range toInsert {
|
|
res, err := tx.ExecContext(ctx, `INSERT INTO custom_host_vitals (name) VALUES (?)`, name)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert custom host vital")
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
created = append(created, fleet.CustomHostVital{ID: uint(id), Name: name}) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return created, deleted, nil
|
|
}
|