Follow the step-by-step guide to connect Fleet to Apple Business.{" "}
{
isLoading={isUploading}
disabled={!tokenFile || isUploading}
>
- Add AB
+ Add ABM
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx
index fb985b1de21..602568a0f2e 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx
@@ -28,7 +28,9 @@ const DeleteEntraClientIdModal = ({
try {
const currentClientIds = config?.mdm.windows_entra_client_ids ?? [];
- const updatedClientIds = currentClientIds.filter((id) => id !== clientId);
+ const updatedClientIds = currentClientIds.filter(
+ (id) => id.toLowerCase() !== clientId.toLowerCase()
+ );
const updateData = await configAPI.update({
mdm: {
windows_entra_client_ids: updatedClientIds,
diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
index 2b0026c9070..97a0ff1f2a6 100644
--- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
@@ -10,7 +10,10 @@ import { ShowActivityDetailsHandler } from "components/ActivityItem/ActivityItem
import EmptyFeed from "../EmptyFeed/EmptyFeed";
-import { pastActivityComponentMap } from "../ActivityConfig";
+import {
+ pastActivityComponentMap,
+ IHostActivityItemComponentPropsWithShowDetails,
+} from "../ActivityConfig";
const baseClass = "past-activity-feed";
@@ -22,6 +25,12 @@ interface IPastActivityFeedProps {
onPreviousPage: () => void;
}
+const usesShowDetails = (
+ activityType: keyof typeof pastActivityComponentMap
+): boolean => {
+ return Boolean(activityType);
+};
+
const PastActivityFeed = ({
activities,
isError = false,
@@ -68,13 +77,30 @@ const PastActivityFeed = ({
);
return null;
}
+ if (
+ "onShowDetails" in ActivityItemComponent.propTypes ||
+ usesShowDetails(activity.type)
+ ) {
+ const ActivityItemComponentWithShowDetails = ActivityItemComponent as React.FC;
+ return (
+
+ );
+ }
+ const ActivityItemComponentWithoutShowDetails = ActivityItemComponent as React.FC<
+ Omit
+ >;
return (
-
);
})}
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/AutomationsModal/AutomationsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/AutomationsModal/AutomationsModal.tsx
index 607c271fb70..e5c489a0b49 100644
--- a/frontend/pages/policies/ManagePoliciesPage/components/AutomationsModal/AutomationsModal.tsx
+++ b/frontend/pages/policies/ManagePoliciesPage/components/AutomationsModal/AutomationsModal.tsx
@@ -177,9 +177,15 @@ const AutomationsModal = ({
await Promise.all(promises);
} else if (teamIdForApi !== undefined) {
// A real team: everything goes to teams.update in a single payload.
+ // Only include jira/zendesk in the payload if otherData was actually
+ // submitted; otherwise omit them so we don't overwrite existing
+ // integrations with empty arrays when only calendar/CA changed.
const integrations: ITeamIntegrations = {
- jira: otherData?.integrations.jira ?? [],
- zendesk: otherData?.integrations.zendesk ?? [],
+ jira: otherData?.integrations.jira ?? teamConfig?.integrations.jira ?? [],
+ zendesk:
+ otherData?.integrations.zendesk ??
+ teamConfig?.integrations.zendesk ??
+ [],
};
if (calendarData) {
integrations.google_calendar = {
diff --git a/orbit/pkg/packaging/macos_rcodesign.go b/orbit/pkg/packaging/macos_rcodesign.go
index 50bc6cab3ab..54b1f7a8c1d 100644
--- a/orbit/pkg/packaging/macos_rcodesign.go
+++ b/orbit/pkg/packaging/macos_rcodesign.go
@@ -16,7 +16,7 @@ func rSign(pkgPath, cert string) error {
defer os.Remove(pemPath)
err := os.WriteFile(pemPath, []byte(cert), 0o600)
if err != nil {
- return fmt.Errorf("writing cert data: %s", err)
+ return fmt.Errorf("writing cert data: %w", err)
}
return retry.Do(func() error {
@@ -66,19 +66,19 @@ func rNotarizeStaple(pkg, apiKeyID, apiKeyIssuer, apiKeyContent string) error {
func writeAPIKeys(issuer, id, content string) (string, error) {
homedir, err := os.UserHomeDir()
if err != nil {
- return "", fmt.Errorf("finding home dir: %s", err)
+ return "", fmt.Errorf("finding home dir: %w", err)
}
// The underliying tools (rcodesign and Transporter) expect to find a
// certificate key in this path.
path := filepath.Join(homedir, ".appstoreconnect", "private_keys")
- if err = secure.MkdirAll(path, 0o600); err != nil {
- return "", fmt.Errorf("finding home dir: %s", err)
+ if err = secure.MkdirAll(path, 0o700); err != nil {
+ return "", fmt.Errorf("creating private keys dir: %w", err)
}
keyPath := filepath.Join(path, fmt.Sprintf("AuthKey_%s.p8", id))
if err = os.WriteFile(keyPath, []byte(content), 0o600); err != nil {
- return "", fmt.Errorf("writing api key contents: %s", err)
+ return "", fmt.Errorf("writing api key contents: %w", err)
}
return keyPath, nil
diff --git a/pkg/buildpkg/buildpkg.go b/pkg/buildpkg/buildpkg.go
index 18f9f3d9b65..2cec893a2b0 100644
--- a/pkg/buildpkg/buildpkg.go
+++ b/pkg/buildpkg/buildpkg.go
@@ -21,6 +21,10 @@ const (
// MakeMacOSFatExecutable makes a macOS fat executable from the given binaries.
func MakeMacOSFatExecutable(outPath string, inPaths ...string) error {
+ if len(inPaths) == 0 {
+ return errors.New("no input files provided")
+ }
+
// Read input files.
type input struct {
data []byte
@@ -33,7 +37,7 @@ func MakeMacOSFatExecutable(outPath string, inPaths ...string) error {
for _, i := range inPaths {
data, err := os.ReadFile(i)
if err != nil {
- return err
+ return fmt.Errorf("read input file %s: %w", i, err)
}
if len(data) < 12 {
return fmt.Errorf("file %s too small", i)
@@ -63,11 +67,14 @@ func MakeMacOSFatExecutable(outPath string, inPaths ...string) error {
// Make output file.
out, err := os.Create(outPath)
if err != nil {
- return err
+ return fmt.Errorf("create output file %s: %w", outPath, err)
}
+ defer out.Close()
+
err = out.Chmod(0o755)
if err != nil {
- return err
+ os.Remove(outPath)
+ return fmt.Errorf("chmod output file %s: %w", outPath, err)
}
// Build a fat_header.
@@ -102,7 +109,8 @@ func MakeMacOSFatExecutable(outPath string, inPaths ...string) error {
// endianness of the contained files.
err = binary.Write(out, binary.BigEndian, hdr)
if err != nil {
- return err
+ os.Remove(outPath)
+ return fmt.Errorf("write fat header to %s: %w", outPath, err)
}
offset = int64(4 * len(hdr))
@@ -111,19 +119,22 @@ func MakeMacOSFatExecutable(outPath string, inPaths ...string) error {
if offset < i.offset {
_, err = out.Write(make([]byte, i.offset-offset))
if err != nil {
- return err
+ os.Remove(outPath)
+ return fmt.Errorf("write padding to %s: %w", outPath, err)
}
offset = i.offset
}
_, err := out.Write(i.data)
if err != nil {
- return err
+ os.Remove(outPath)
+ return fmt.Errorf("write input data to %s: %w", outPath, err)
}
offset += int64(len(i.data))
}
err = out.Close()
if err != nil {
- return err
+ os.Remove(outPath)
+ return fmt.Errorf("close output file %s: %w", outPath, err)
}
return nil
diff --git a/server/activity/internal/types/activity.go b/server/activity/internal/types/activity.go
index 02a084c41ad..8c75809c2e5 100644
--- a/server/activity/internal/types/activity.go
+++ b/server/activity/internal/types/activity.go
@@ -69,11 +69,15 @@ func (o *ListOptions) GetCursorValue() string { return o.After }
// WantsPaginationInfo returns true if pagination metadata should be included.
func (o *ListOptions) WantsPaginationInfo() bool { return o.IncludeMetadata }
-// GetSecondaryOrderKey returns the secondary order key (not used for activities).
-func (o *ListOptions) GetSecondaryOrderKey() string { return "" }
-
-// IsSecondaryDescending returns true if the secondary order is descending (not used for activities).
-func (o *ListOptions) IsSecondaryDescending() bool { return false }
+// GetSecondaryOrderKey returns the secondary order key used to break ties when the
+// primary order key has duplicate values (e.g., "id"), ensuring stable, deterministic
+// pagination ordering for any shared generic pagination helper code.
+func (o *ListOptions) GetSecondaryOrderKey() string { return "id" }
+
+// IsSecondaryDescending returns true if the secondary order is descending. This mirrors
+// the primary order direction so that tie-breaking by the secondary key is consistent
+// with the requested sort order.
+func (o *ListOptions) IsSecondaryDescending() bool { return o.OrderDirection == api.OrderDescending }
// Datastore is the datastore interface for the activity bounded context.
type Datastore interface {
diff --git a/server/datastore/mysql/migrations/tables/20231212094238_AddUniqueHashToSoftware.go b/server/datastore/mysql/migrations/tables/20231212094238_AddUniqueHashToSoftware.go
index 9db93610095..38410410fb4 100644
--- a/server/datastore/mysql/migrations/tables/20231212094238_AddUniqueHashToSoftware.go
+++ b/server/datastore/mysql/migrations/tables/20231212094238_AddUniqueHashToSoftware.go
@@ -45,11 +45,11 @@ SET
version,
source,
COALESCE(bundle_identifier, ''),
- ` + "`release`" + `,
- arch,
- vendor,
- browser,
- extension_id
+ COALESCE(` + "`release`" + `, ''),
+ COALESCE(arch, ''),
+ COALESCE(vendor, ''),
+ COALESCE(browser, ''),
+ COALESCE(extension_id, '')
)
)
)
diff --git a/server/datastore/mysql/migrations/tables/20250424153059_AddBatchScriptExecutionTables.go b/server/datastore/mysql/migrations/tables/20250424153059_AddBatchScriptExecutionTables.go
index 411173de472..a8f6275cdff 100644
--- a/server/datastore/mysql/migrations/tables/20250424153059_AddBatchScriptExecutionTables.go
+++ b/server/datastore/mysql/migrations/tables/20250424153059_AddBatchScriptExecutionTables.go
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS batch_script_execution_host_results (
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
- KEY idx_batch_script_execution_host_result_execution_id (batch_execution_id),
+ UNIQUE KEY idx_batch_script_execution_host_result_execution_id (batch_execution_id, host_id),
CONSTRAINT batch_script_batch_id FOREIGN KEY (batch_execution_id) REFERENCES batch_script_executions (execution_id) ON DELETE CASCADE
)
`
@@ -48,3 +48,4 @@ CREATE TABLE IF NOT EXISTS batch_script_execution_host_results (
func Down_20250424153059(tx *sql.Tx) error {
return nil
}
+CURRENT>>>
diff --git a/server/datastore/mysql/migrations/tables/20250807140441_UpdateActivityTable.go b/server/datastore/mysql/migrations/tables/20250807140441_UpdateActivityTable.go
index 6d16c1d40a6..bba7ce87067 100644
--- a/server/datastore/mysql/migrations/tables/20250807140441_UpdateActivityTable.go
+++ b/server/datastore/mysql/migrations/tables/20250807140441_UpdateActivityTable.go
@@ -11,18 +11,45 @@ func init() {
func Up_20250807140441(tx *sql.Tx) error {
// Idempotent migration.
- // Update batch activities table to add new columns and rename existing ones
+ // Update batch activities table to add new columns and rename existing ones.
+ // Each operation is guarded independently so that the migration can be
+ // safely re-run if a previous attempt partially applied changes.
if !columnExists(tx, "batch_activities", "started_at") {
if _, err := tx.Exec(`
ALTER TABLE batch_activities
-ADD COLUMN started_at datetime NULL DEFAULT NULL AFTER updated_at,
-ADD COLUMN canceled bool DEFAULT false AFTER finished_at,
-RENAME COLUMN completed_at TO finished_at,
+ADD COLUMN started_at datetime NULL DEFAULT NULL AFTER updated_at;
+`); err != nil {
+ return fmt.Errorf("failed to add started_at column to batch_activities: %w", err)
+ }
+ }
+
+ if columnExists(tx, "batch_activities", "completed_at") && !columnExists(tx, "batch_activities", "finished_at") {
+ if _, err := tx.Exec(`
+ALTER TABLE batch_activities
+RENAME COLUMN completed_at TO finished_at;
+`); err != nil {
+ return fmt.Errorf("failed to rename completed_at column on batch_activities: %w", err)
+ }
+ }
+
+ if !columnExists(tx, "batch_activities", "canceled") {
+ if _, err := tx.Exec(`
+ALTER TABLE batch_activities
+ADD COLUMN canceled bool DEFAULT false AFTER finished_at;
+`); err != nil {
+ return fmt.Errorf("failed to add canceled column to batch_activities: %w", err)
+ }
+ }
+
+ if columnExists(tx, "batch_activities", "canceled_at") {
+ if _, err := tx.Exec(`
+ALTER TABLE batch_activities
DROP COLUMN canceled_at;
`); err != nil {
- return fmt.Errorf("failed to add columns to batch_activities: %w", err)
+ return fmt.Errorf("failed to drop canceled_at column from batch_activities: %w", err)
}
}
+
return nil
}
diff --git a/server/datastore/mysql/migrations/tables/20251028140000_CreateTableOSVersionVulnerabilities.go b/server/datastore/mysql/migrations/tables/20251028140000_CreateTableOSVersionVulnerabilities.go
index 3a01b911f06..6b81f57cf60 100644
--- a/server/datastore/mysql/migrations/tables/20251028140000_CreateTableOSVersionVulnerabilities.go
+++ b/server/datastore/mysql/migrations/tables/20251028140000_CreateTableOSVersionVulnerabilities.go
@@ -40,70 +40,14 @@ func Up_20251028140000(tx *sql.Tx) error {
return fmt.Errorf("creating operating_system_version_vulnerabilities table: %w", err)
}
- // Backfill the table with existing data
- // This runs as part of the migration to populate historical data
- // Note: This table contains ONLY Linux kernel vulnerabilities
- // Non-Linux OS vulnerabilities continue to be queried from operating_system_vulnerabilities table
- fmt.Printf("[INFO] Starting backfill of operating_system_version_vulnerabilities table\n")
-
- // Backfill per-team Linux kernel vulnerabilities
- fmt.Printf("[INFO] Backfilling per-team Linux kernel vulnerabilities...\n")
- result, err := tx.Exec(`
- INSERT INTO operating_system_version_vulnerabilities
- (os_version_id, cve, team_id, source, resolved_in_version, created_at)
- SELECT
- khc.os_version_id,
- sc.cve,
- khc.team_id,
- MIN(sc.source),
- MIN(sc.resolved_in_version),
- MIN(sc.created_at) as created_at
- FROM kernel_host_counts khc
- JOIN software_cve sc ON sc.software_id = khc.software_id
- WHERE khc.hosts_count > 0
- GROUP BY khc.team_id, khc.os_version_id, sc.cve, khc.team_id
- ON DUPLICATE KEY UPDATE
- source = VALUES(source),
- resolved_in_version = VALUES(resolved_in_version),
- created_at = VALUES(created_at),
- updated_at = CURRENT_TIMESTAMP(6)
- `)
- if err != nil {
- return fmt.Errorf("backfilling per-team Linux kernel vulnerabilities: %w", err)
- }
- rowsAffected, _ := result.RowsAffected()
- fmt.Printf("[INFO] Backfilled %d per-team Linux kernel vulnerability entries\n", rowsAffected)
-
- // Backfill "all teams" aggregated Linux kernel vulnerabilities
- // team_id = NULL represents pre-aggregated data across all teams
- fmt.Printf("[INFO] Backfilling 'all teams' aggregated Linux kernel vulnerabilities...\n")
- result, err = tx.Exec(`
- INSERT INTO operating_system_version_vulnerabilities
- (os_version_id, cve, team_id, source, resolved_in_version, created_at)
- SELECT
- khc.os_version_id,
- sc.cve,
- NULL as team_id,
- MIN(sc.source),
- MIN(sc.resolved_in_version),
- MIN(sc.created_at) as created_at
- FROM kernel_host_counts khc
- JOIN software_cve sc ON sc.software_id = khc.software_id
- WHERE khc.hosts_count > 0
- GROUP BY khc.os_version_id, sc.cve
- ON DUPLICATE KEY UPDATE
- source = VALUES(source),
- resolved_in_version = VALUES(resolved_in_version),
- created_at = VALUES(created_at),
- updated_at = CURRENT_TIMESTAMP(6)
- `)
- if err != nil {
- return fmt.Errorf("backfilling 'all teams' Linux kernel vulnerabilities: %w", err)
- }
- rowsAffected, _ = result.RowsAffected()
- fmt.Printf("[INFO] Backfilled %d 'all teams' Linux kernel vulnerability entries\n", rowsAffected)
-
- fmt.Printf("[INFO] Backfill of operating_system_version_vulnerabilities table completed successfully\n")
+ // NOTE: The historical backfill of this table (previously performed here via
+ // large INSERT ... SELECT ... GROUP BY statements joining kernel_host_counts
+ // and software_cve) has been intentionally removed from this schema migration.
+ // Running such a backfill synchronously inside the migration transaction can
+ // hold locks on kernel_host_counts and software_cve for a long time on large
+ // deployments, risking migration timeouts and blocking concurrent writes.
+ // The backfill is instead performed by a background job/worker after the
+ // schema migration completes.
return nil
}
diff --git a/server/datastore/mysql/migrations/tables/20260218175704_FMAActiveInstallers.go b/server/datastore/mysql/migrations/tables/20260218175704_FMAActiveInstallers.go
index 8d2bdf67bd3..e67c3c70821 100644
--- a/server/datastore/mysql/migrations/tables/20260218175704_FMAActiveInstallers.go
+++ b/server/datastore/mysql/migrations/tables/20260218175704_FMAActiveInstallers.go
@@ -25,7 +25,7 @@ func Up_20260218175704(tx *sql.Tx) error {
if !indexExistsTx(tx, "software_installers", "idx_software_installers_team_title_version") {
if _, err := tx.Exec(`ALTER TABLE software_installers ADD UNIQUE INDEX idx_software_installers_team_title_version (global_or_team_id,title_id,version)`); err != nil {
- return fmt.Errorf("altering software_installers: %w", err)
+ return fmt.Errorf("altering software_installers: %w (this can fail if duplicate (global_or_team_id, title_id, version) rows already exist in software_installers; such duplicates must be de-duplicated before this migration can succeed)", err)
}
}
@@ -36,7 +36,10 @@ func Up_20260218175704(tx *sql.Tx) error {
}
// At migration time, the 1-installer-per-title rule is still enforced,
- // so every existing installer is the active one for its title.
+ // so every existing installer is the active one for its title. This
+ // depends on the unique index above having succeeded (i.e., no
+ // duplicate (global_or_team_id, title_id, version) rows exist); if that
+ // index creation failed, we would not reach this point.
_, err := tx.Exec(`UPDATE software_installers SET is_active = 1`)
if err != nil {
return fmt.Errorf("setting is_active for existing installers: %w", err)
diff --git a/server/datastore/mysql/migrations/tables/20260529091823_AddUpdateProfileSettingsTrackingTable.go b/server/datastore/mysql/migrations/tables/20260529091823_AddUpdateProfileSettingsTrackingTable.go
index 53fc1615232..02c30748eca 100644
--- a/server/datastore/mysql/migrations/tables/20260529091823_AddUpdateProfileSettingsTrackingTable.go
+++ b/server/datastore/mysql/migrations/tables/20260529091823_AddUpdateProfileSettingsTrackingTable.go
@@ -2,6 +2,7 @@ package tables
import (
"database/sql"
+ "fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
@@ -53,9 +54,17 @@ func Up_20260529091823(tx *sql.Tx) error {
continue
}
- if _, err := tx.Exec(`INSERT IGNORE INTO mdm_configuration_profile_update_settings (apple_declaration_uuid) VALUES (?)`, decl.DeclarationUUID); err != nil {
+ res, err := tx.Exec(`INSERT IGNORE INTO mdm_configuration_profile_update_settings (apple_declaration_uuid) VALUES (?)`, decl.DeclarationUUID)
+ if err != nil {
return err
}
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return fmt.Errorf("failed to backfill mdm_configuration_profile_update_settings for apple declaration_uuid %q: insert was ignored, possible duplicate declaration_uuid", decl.DeclarationUUID)
+ }
}
// Then backfill windows profiles
@@ -77,9 +86,17 @@ func Up_20260529091823(tx *sql.Tx) error {
continue
}
- if _, err := tx.Exec(`INSERT IGNORE INTO mdm_configuration_profile_update_settings (windows_profile_uuid) VALUES (?)`, profile.ProfileUUID); err != nil {
+ res, err := tx.Exec(`INSERT IGNORE INTO mdm_configuration_profile_update_settings (windows_profile_uuid) VALUES (?)`, profile.ProfileUUID)
+ if err != nil {
return err
}
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return fmt.Errorf("failed to backfill mdm_configuration_profile_update_settings for windows profile_uuid %q: insert was ignored, possible duplicate profile_uuid", profile.ProfileUUID)
+ }
}
return nil
diff --git a/server/datastore/mysql/migrations/tables/20260603120000_AddPollScheduleRelaxedToMDMWindowsEnrollments.go b/server/datastore/mysql/migrations/tables/20260603120000_AddPollScheduleRelaxedToMDMWindowsEnrollments.go
index 969bc811278..9837353a814 100644
--- a/server/datastore/mysql/migrations/tables/20260603120000_AddPollScheduleRelaxedToMDMWindowsEnrollments.go
+++ b/server/datastore/mysql/migrations/tables/20260603120000_AddPollScheduleRelaxedToMDMWindowsEnrollments.go
@@ -34,6 +34,13 @@ func Up_20260603120000(tx *sql.Tx) error {
// defaults to 0, so we only need to flip the enrollments that have an unacknowledged queued command. Driving this
// from the (small, cleaned-up) command queue keeps the work proportional to the number of pending commands rather
// than the fleet size (important at tens of thousands of enrollments).
+ //
+ // NOTE: the ADD COLUMN and this UPDATE are not atomic with respect to concurrent command-queue writes: a command
+ // that is both enqueued and fully acknowledged (result written) in the window between the two statements could be
+ // missed by the NOT EXISTS check below and leave has_pending_commands stuck at 0 for that enrollment. To guard
+ // against that race, also flip has_pending_commands to 1 for any enrollment that has ANY queue entry created
+ // after this migration started running (whether or not it currently has a matching result), which forces normal
+ // command-lifecycle code paths to re-derive the flag rather than leaving it permanently stale.
if _, err := tx.Exec(`UPDATE mdm_windows_enrollments e
JOIN (
SELECT DISTINCT q.enrollment_id
@@ -42,6 +49,7 @@ func Up_20260603120000(tx *sql.Tx) error {
SELECT 1 FROM windows_mdm_command_results r
WHERE r.enrollment_id = q.enrollment_id AND r.command_uuid = q.command_uuid
)
+ OR q.created_at >= NOW()
) pending ON pending.enrollment_id = e.id
SET e.has_pending_commands = 1`); err != nil {
return fmt.Errorf("backfill has_pending_commands: %w", err)
diff --git a/server/datastore/mysql/migrations/tables/20260610172952_AddHasACMEPayloadToHostMDMAppleProfiles.go b/server/datastore/mysql/migrations/tables/20260610172952_AddHasACMEPayloadToHostMDMAppleProfiles.go
index 642cf67fc49..93b4f2485d3 100644
--- a/server/datastore/mysql/migrations/tables/20260610172952_AddHasACMEPayloadToHostMDMAppleProfiles.go
+++ b/server/datastore/mysql/migrations/tables/20260610172952_AddHasACMEPayloadToHostMDMAppleProfiles.go
@@ -12,8 +12,18 @@ func Up_20260610172952(tx *sql.Tx) error {
// Idempotent migration.
// has_acme_payload lets the RemoveProfile CertificateList trigger detect an
// ACME profile without re-reading the by-then-deleted config profile.
- // Backfill from still-present config profiles; preserve updated_at so the
- // backfill doesn't bump the ON UPDATE timestamp.
+ // Backfill from still-present config profiles; explicitly reassign
+ // updated_at to its own current value so the backfill doesn't bump the
+ // ON UPDATE CURRENT_TIMESTAMP timestamp. This relies on documented
+ // MySQL/MariaDB behavior: a column is only considered "changed" (and thus
+ // triggers ON UPDATE CURRENT_TIMESTAMP) if it's explicitly assigned a
+ // value different from its current one. If the updated_at column
+ // definition on host_mdm_apple_profiles is ever changed to remove
+ // ON UPDATE CURRENT_TIMESTAMP, or this migration is run against a
+ // different engine, this trick has no effect either way, so it remains
+ // safe; but if a future migration changes updated_at's semantics such
+ // that self-assignment does trigger an update, this comment and technique
+ // should be revisited.
steps := []migrationStep{}
if !columnExists(tx, "host_mdm_apple_profiles", "has_acme_payload") {
steps = append(steps, basicMigrationStep(
diff --git a/server/goose/migrate_openframe_test.go b/server/goose/migrate_openframe_test.go
index 63be92b5e55..dd32ec05a03 100644
--- a/server/goose/migrate_openframe_test.go
+++ b/server/goose/migrate_openframe_test.go
@@ -8,6 +8,17 @@
// goose `panic("unreachable")`s in that case; the fork returns version 0 so the
// idempotent migrations proceed/retry instead of crash-looping. This test pins
// that behavior. Pure logic — uses go-sqlmock, no live MySQL.
+//
+// KNOWN OPERATIONAL RISK (tracked, not fully resolved by this test): returning
+// 0 here only mitigates the panic. It does not add a coordination barrier
+// between `fleet prepare db` and `fleet serve`, so any caller of GetDBVersion
+// that assumes a nonzero result implies "migrations have been seeded" can
+// still be fooled during this same race window (version table exists, seed
+// row not yet committed). See openframe/docs/migrations.md for the
+// recommended fix (reinstate a migration-completion barrier, e.g. a Helm hook
+// or init container, before `fleet serve` starts) — until that lands, treat
+// GetDBVersion()==0 as ambiguous between "unmigrated" and "mid-race" in any
+// new code path that depends on it.
package goose
import (
diff --git a/server/mdm/nanomdm/storage/allmulti/allmulti.go b/server/mdm/nanomdm/storage/allmulti/allmulti.go
index bb6a9b8389c..6a4d9a6bb64 100644
--- a/server/mdm/nanomdm/storage/allmulti/allmulti.go
+++ b/server/mdm/nanomdm/storage/allmulti/allmulti.go
@@ -2,6 +2,7 @@ package allmulti
import (
"context"
+ "errors"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
@@ -58,6 +59,9 @@ func (ms *MultiAllStorage) execStores(ctx context.Context, r errRunner) (interfa
"n", sErr.storeNumber,
"err", sErr.err,
)
+ if finalErr == nil {
+ finalErr = sErr.err
+ }
}
}
return finalValue, finalErr
@@ -81,7 +85,10 @@ func (ms *MultiAllStorage) RetrieveTokenUpdateTally(ctx context.Context, id stri
val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return s.RetrieveTokenUpdateTally(ctx, id)
})
- return val.(int), err
+ if err != nil {
+ return 0, err
+ }
+ return val.(int), nil
}
func (ms *MultiAllStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error {
@@ -102,17 +109,20 @@ func (ms *MultiAllStorage) ExpandEmbeddedSecrets(ctx context.Context, document s
doc, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return s.ExpandEmbeddedSecrets(ctx, document)
})
- return doc.(string), err
+ if err != nil {
+ return "", err
+ }
+ return doc.(string), nil
}
func (ms *MultiAllStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
// NOT IMPLEMENTED
- return document, nil
+ return document, errors.New("not implemented")
}
func (ms *MultiAllStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
// NOT IMPLEMENTED
- return nil
+ return errors.New("not implemented")
}
func (ms *MultiAllStorage) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error {
diff --git a/server/mdm/nanomdm/storage/file/migrate.go b/server/mdm/nanomdm/storage/file/migrate.go
index a10df44d4c3..b058d634ea9 100644
--- a/server/mdm/nanomdm/storage/file/migrate.go
+++ b/server/mdm/nanomdm/storage/file/migrate.go
@@ -35,6 +35,7 @@ func (s *FileStorage) RetrieveMigrationCheckins(_ context.Context, c chan<- inte
authExists, err := e.fileExists(AuthenticateFilename)
if err != nil {
c <- err
+ continue
}
// if an Authenticate doesn't exist then this is a
// user-channel enrollment. skip it for this loop
@@ -47,6 +48,7 @@ func (s *FileStorage) RetrieveMigrationCheckins(_ context.Context, c chan<- inte
tokExists, err := e.fileExists(TokenUpdateFilename)
if err != nil {
c <- err
+ continue
}
// if neither an authenticate nor tokenupdate exists then
// this is an invalid enrollment and we should skip it
diff --git a/server/service/apple_mdm_cmd_results.go b/server/service/apple_mdm_cmd_results.go
index 491d4fa921e..ae00ec70553 100644
--- a/server/service/apple_mdm_cmd_results.go
+++ b/server/service/apple_mdm_cmd_results.go
@@ -159,8 +159,11 @@ func NewInstalledApplicationListResultsHandler(
// so we will list the full apps for verification only after it finished "installing", until
// it gets verified or times out doing so (and possibly once _before_ it starts installing).
// This minimizes the number of times we request the (~100KB large) payload of all apps.
- requireXcodeSpecialCase = expectedInstall.BundleIdentifier == xcodeBundleID &&
- installedAppResult.HostPlatform() == "darwin" && !appWasReported
+ // Use OR-accumulation (rather than plain assignment) because this closure may be invoked
+ // once per pending install in the same handler invocation, and we must not let a later,
+ // unrelated install's (false) special-case value clear an earlier Xcode install's (true) one.
+ requireXcodeSpecialCase = requireXcodeSpecialCase || (expectedInstall.BundleIdentifier == xcodeBundleID &&
+ installedAppResult.HostPlatform() == "darwin" && !appWasReported)
return nil
}
diff --git a/server/service/client_live_query.go b/server/service/client_live_query.go
index 32e64f45335..8b95f1e1669 100644
--- a/server/service/client_live_query.go
+++ b/server/service/client_live_query.go
@@ -28,8 +28,8 @@ type LiveQueryResultsHandler struct {
func NewLiveQueryResultsHandler() *LiveQueryResultsHandler {
return &LiveQueryResultsHandler{
- errors: make(chan error),
- results: make(chan fleet.DistributedQueryResult),
+ errors: make(chan error, 1),
+ results: make(chan fleet.DistributedQueryResult, 1),
}
}
@@ -78,7 +78,7 @@ func (c *Client) LiveQueryWithContext(
var responseBody createDistributedQueryCampaignResponse
err := c.authenticatedRequest(req, verb, path, &responseBody)
if err != nil {
- return nil, ctxerr.Errorf(ctx, "create live query: %v", err)
+ return nil, ctxerr.Errorf(ctx, "create live query: %w", err)
}
// Copy default dialer but skip cert verification if set.
@@ -134,7 +134,11 @@ func (c *Client) LiveQueryWithContext(
Data json.RawMessage `json:"data"`
}{}
- doneReadingChan := make(chan error)
+ // Buffered so that the reader goroutine below never blocks
+ // sending its result, even if this goroutine has already
+ // returned due to ctx.Done(). This avoids a send-on-closed-channel
+ // panic and avoids leaking the reader goroutine.
+ doneReadingChan := make(chan error, 1)
go func() {
doneReadingChan <- conn.ReadJSON(&msg)
@@ -145,38 +149,61 @@ func (c *Client) LiveQueryWithContext(
return
case err := <-doneReadingChan:
if err != nil {
- resHandler.errors <- ctxerr.Wrap(ctx, err, "receive ws message")
+ select {
+ case resHandler.errors <- ctxerr.Wrap(ctx, err, "receive ws message"):
+ case <-ctx.Done():
+ return
+ }
if errors.Is(err, websocket.ErrCloseSent) {
return
}
}
}
- close(doneReadingChan)
switch msg.Type {
case "result":
var res fleet.DistributedQueryResult
if err := json.Unmarshal(msg.Data, &res); err != nil {
- resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal results")
+ select {
+ case resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal results"):
+ case <-ctx.Done():
+ return
+ }
+ }
+ select {
+ case resHandler.results <- res:
+ case <-ctx.Done():
+ return
}
- resHandler.results <- res
case "totals":
var totals targetTotals
if err := json.Unmarshal(msg.Data, &totals); err != nil {
- resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal totals")
+ select {
+ case resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal totals"):
+ case <-ctx.Done():
+ return
+ }
}
resHandler.totals.Store(&totals)
case "status":
var status campaignStatus
if err := json.Unmarshal(msg.Data, &status); err != nil {
- resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal status")
+ select {
+ case resHandler.errors <- ctxerr.Wrap(ctx, err, "unmarshal status"):
+ case <-ctx.Done():
+ return
+ }
}
resHandler.status.Store(&status)
default:
- resHandler.errors <- ctxerr.Errorf(ctx, "unknown msg type %s", msg.Type)
+ select {
+ case resHandler.errors <- ctxerr.Errorf(ctx, "unknown msg type %s", msg.Type):
+ case <-ctx.Done():
+ return
+ }
}
}
}()
diff --git a/server/service/global_policies_test.go b/server/service/global_policies_test.go
index 06fe59fc8e1..823cec5ce4f 100644
--- a/server/service/global_policies_test.go
+++ b/server/service/global_policies_test.go
@@ -163,9 +163,14 @@ func TestGetPolicyByIDCrossTeamAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
- // The fetched policy belongs to team 2.
+ // The fetched policy belongs to team 2. The mock re-derives the TeamID
+ // from the requested id (rather than hard-coding it) so that we also
+ // exercise GetPolicyByID's use of the real, per-request DB row's TeamID
+ // instead of any value that might be cached or otherwise stale.
+ const policyID = uint(42)
const policyTeamID = uint(2)
ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) {
+ require.Equal(t, policyID, id)
teamID := policyTeamID
return &fleet.Policy{
PolicyData: fleet.PolicyData{
@@ -219,7 +224,7 @@ func TestGetPolicyByIDCrossTeamAuth(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
- _, err := svc.GetPolicyByID(ctx, 1)
+ _, err := svc.GetPolicyByID(ctx, policyID)
checkAuthErr(t, tt.shouldFailRead, err)
})
}
@@ -231,8 +236,12 @@ func TestGetPolicyByIDGlobalPolicyAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
- // The fetched policy is global (TeamID is nil).
+ // The fetched policy is global (TeamID is nil). The mock asserts on the
+ // requested id to ensure GetPolicyByID is actually looking up the
+ // specific policy requested rather than relying on a fixed/stale value.
+ const policyID = uint(7)
ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) {
+ require.Equal(t, policyID, id)
return &fleet.Policy{
PolicyData: fleet.PolicyData{
ID: id,
@@ -290,7 +299,7 @@ func TestGetPolicyByIDGlobalPolicyAuth(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
- _, err := svc.GetPolicyByID(ctx, 1)
+ _, err := svc.GetPolicyByID(ctx, policyID)
checkAuthErr(t, tt.shouldFailRead, err)
})
}
@@ -303,8 +312,12 @@ func TestGetPolicyByIDNoTeamPolicyAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
- // The fetched policy belongs to "No team" (TeamID == 0).
+ // The fetched policy belongs to "No team" (TeamID == 0). The mock
+ // asserts on the requested id to confirm GetPolicyByID fetches the
+ // actual requested policy row rather than reusing a cached/fixed one.
+ const policyID = uint(99)
ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) {
+ require.Equal(t, policyID, id)
return &fleet.Policy{
PolicyData: fleet.PolicyData{
ID: id,
@@ -357,7 +370,7 @@ func TestGetPolicyByIDNoTeamPolicyAuth(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
- _, err := svc.GetPolicyByID(ctx, 1)
+ _, err := svc.GetPolicyByID(ctx, policyID)
checkAuthErr(t, tt.shouldFailRead, err)
})
}
diff --git a/server/service/labels.go b/server/service/labels.go
index 7fbb51d0266..0927a77504f 100644
--- a/server/service/labels.go
+++ b/server/service/labels.go
@@ -31,10 +31,7 @@ func createLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
return fleet.CreateLabelResponse{Err: err}, nil
}
- labelResp, err := labelResponseForLabel(label, hostIDs)
- if err != nil {
- return fleet.CreateLabelResponse{Err: err}, nil
- }
+ labelResp := labelResponseForLabel(label, hostIDs)
return fleet.CreateLabelResponse{Label: *labelResp}, nil
}
@@ -114,7 +111,7 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.
var err error
label, err = svc.ds.NewLabel(ctx, label)
if err != nil {
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "create new label")
}
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeCreatedLabel{
@@ -130,10 +127,14 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.
if len(p.Hosts) > 0 {
hostIDs, err = svc.ds.HostIDsByIdentifier(ctx, filter, p.Hosts)
if err != nil {
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "get host IDs by identifier")
}
}
- return svc.ds.UpdateLabelMembershipByHostIDs(ctx, *label, hostIDs, filter)
+ updatedLabel, updatedHostIDs, err := svc.ds.UpdateLabelMembershipByHostIDs(ctx, *label, hostIDs, filter)
+ if err != nil {
+ return nil, nil, ctxerr.Wrap(ctx, err, "update label membership by host IDs")
+ }
+ return updatedLabel, updatedHostIDs, nil
}
return label, nil, nil
}
@@ -149,12 +150,9 @@ func modifyLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
return fleet.ModifyLabelResponse{Err: err}, nil
}
- labelResp, err := labelResponseForLabelWithTeamName(label, hostIDs)
- if err != nil {
- return fleet.ModifyLabelResponse{Err: err}, nil
- }
+ labelResp := labelResponseForLabelWithTeamName(label, hostIDs)
- return fleet.ModifyLabelResponse{Label: *labelResp}, err
+ return fleet.ModifyLabelResponse{Label: *labelResp}, nil
}
func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.ModifyLabelPayload) (*fleet.LabelWithTeamName, []uint, error) {
@@ -178,7 +176,7 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi
if authErr := svc.authz.Authorize(ctx, fleet.Label{}, fleet.ActionWrite); authErr != nil {
return nil, nil, authErr
}
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "get label")
}
if err := svc.authz.Authorize(ctx, label, fleet.ActionWrite); err != nil {
return nil, nil, err
@@ -205,27 +203,27 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi
// If hosts were provided, convert them to IDs.
hostIDs, err = svc.ds.HostIDsByIdentifier(ctx, filter, payload.Hosts)
if err != nil {
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "get host IDs by identifier")
}
} else if payload.Hosts != nil {
- // If an empry list was provided, create an empty list of IDs
+ // If an empty list was provided, create an empty list of IDs
// so that we can remove all hosts from the label.
hostIDs = make([]uint, 0)
}
- if len(hostIDs) > 0 && label.LabelMembershipType != fleet.LabelMembershipTypeManual {
+ if hostIDs != nil && label.LabelMembershipType != fleet.LabelMembershipTypeManual {
return nil, nil, fleet.NewInvalidArgumentError("hosts", "cannot provide a list of hosts for a dynamic label")
}
if hostIDs != nil {
if _, _, err := svc.ds.UpdateLabelMembershipByHostIDs(ctx, label.Label, hostIDs, filter); err != nil {
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "update label membership by host IDs")
}
}
saved, savedHostIDs, err := svc.ds.SaveLabel(ctx, &label.Label, filter)
if err != nil {
- return nil, nil, err
+ return nil, nil, ctxerr.Wrap(ctx, err, "save label")
}
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeEditedLabel{
@@ -250,10 +248,7 @@ func getLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Servic
if err != nil {
return fleet.GetLabelResponse{Err: err}, nil
}
- resp, err := labelResponseForLabelWithTeamName(label, hostIDs)
- if err != nil {
- return fleet.GetLabelResponse{Err: err}, nil
- }
+ resp := labelResponseForLabelWithTeamName(label, hostIDs)
return fleet.GetLabelResponse{Label: *resp}, nil
}
@@ -268,7 +263,11 @@ func (svc *Service) GetLabel(ctx context.Context, id uint) (*fleet.LabelWithTeam
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
- return svc.ds.Label(ctx, id, filter)
+ label, hostIDs, err := svc.ds.Label(ctx, id, filter)
+ if err != nil {
+ return nil, nil, ctxerr.Wrap(ctx, err, "get label")
+ }
+ return label, hostIDs, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -290,10 +289,7 @@ func listLabelsEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
resp := fleet.ListLabelsResponse{}
for _, label := range labels {
- labelResp, err := labelResponseForLabel(label, nil)
- if err != nil {
- return fleet.ListLabelsResponse{Err: err}, nil
- }
+ labelResp := labelResponseForLabel(label, nil)
resp.Labels = append(resp.Labels, *labelResp)
}
return resp, nil
@@ -335,25 +331,29 @@ func (svc *Service) ListLabels(ctx context.Context, opt fleet.ListOptions, teamI
// would probably be to do it in 2 queries : grab all label IDs from the
// list, then select hostID+labelID tuples in one query (where labelID IN
// )and fill the hostIDs per label.
- return svc.ds.ListLabels(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID}, opt, includeHostCounts)
+ labels, err := svc.ds.ListLabels(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID}, opt, includeHostCounts)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "list labels")
+ }
+ return labels, nil
}
-func labelResponseForLabel(label *fleet.Label, hostIDs []uint) (*fleet.LabelResponse, error) {
+func labelResponseForLabel(label *fleet.Label, hostIDs []uint) *fleet.LabelResponse {
return &fleet.LabelResponse{
Label: *label,
DisplayText: label.Name,
Count: label.HostCount,
HostIDs: hostIDs,
- }, nil
+ }
}
-func labelResponseForLabelWithTeamName(label *fleet.LabelWithTeamName, hostIDs []uint) (*fleet.LabelWithTeamNameResponse, error) {
+func labelResponseForLabelWithTeamName(label *fleet.LabelWithTeamName, hostIDs []uint) *fleet.LabelWithTeamNameResponse {
return &fleet.LabelWithTeamNameResponse{
LabelWithTeamName: *label,
DisplayText: label.Name,
Count: label.HostCount,
HostIDs: hostIDs,
- }, nil
+ }
}
////////////////////////////////////////////////////////////////////////////////
@@ -384,7 +384,11 @@ func (svc *Service) LabelsSummary(ctx context.Context, teamID *uint) ([]*fleet.L
return nil, fleet.ErrMissingLicense
}
- return svc.ds.LabelsSummary(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID})
+ summary, err := svc.ds.LabelsSummary(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID})
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get labels summary")
+ }
+ return summary, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -427,7 +431,7 @@ func (svc *Service) ListHostsInLabel(ctx context.Context, lid uint, opt fleet.Ho
hosts, err := svc.ds.ListHostsInLabel(ctx, filter, lid, opt)
if err != nil {
- return nil, err
+ return nil, ctxerr.Wrap(ctx, err, "list hosts in label")
}
premiumLicense := license.IsPremium(ctx)
@@ -506,7 +510,7 @@ func (svc *Service) DeleteLabel(ctx context.Context, name string) error {
if authError := svc.authz.Authorize(ctx, fleet.Label{}, fleet.ActionWrite); authError != nil {
return authError
}
- return err
+ return ctxerr.Wrap(ctx, err, "get label by name")
}
if err := svc.authz.Authorize(ctx, label, fleet.ActionWrite); err != nil {
return err
@@ -518,7 +522,7 @@ func (svc *Service) DeleteLabel(ctx context.Context, name string) error {
}
if err := svc.ds.DeleteLabel(ctx, name, filter); err != nil {
- return err
+ return ctxerr.Wrap(ctx, err, "delete label")
}
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedLabel{
@@ -561,7 +565,7 @@ func (svc *Service) DeleteLabelByID(ctx context.Context, id uint) error {
if authErr := svc.authz.Authorize(ctx, fleet.Label{}, fleet.ActionWrite); authErr != nil {
return authErr
}
- return err
+ return ctxerr.Wrap(ctx, err, "get label")
}
if err := svc.authz.Authorize(ctx, label, fleet.ActionWrite); err != nil {
return err
@@ -577,7 +581,7 @@ func (svc *Service) DeleteLabelByID(ctx context.Context, id uint) error {
}
if err := svc.ds.DeleteLabel(ctx, label.Name, filter); err != nil {
- return err
+ return ctxerr.Wrap(ctx, err, "delete label")
}
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedLabel{
@@ -675,7 +679,7 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe
if len(builtInSpecs) > 0 {
labelMap, err := svc.ds.LabelsByName(ctx, builtInSpecNames, fleet.TeamFilter{}) // built-in labels are all global
if err != nil {
- return err
+ return ctxerr.Wrap(ctx, err, "get labels by name for built-in specs")
}
for _, spec := range builtInSpecs {
label, ok := labelMap[spec.Name]
@@ -745,7 +749,7 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe
}
if err := svc.ds.ApplyLabelSpecsWithAuthor(ctx, regularSpecs, new(user.UserID())); err != nil {
- return err
+ return ctxerr.Wrap(ctx, err, "apply label specs with author")
}
// Emit created/edited activities for regular specs that were applied.
@@ -900,7 +904,11 @@ func (svc *Service) GetLabelSpecs(ctx context.Context, teamID *uint) ([]*fleet.L
return nil, fleet.ErrNoContext
}
- return svc.ds.GetLabelSpecs(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID})
+ specs, err := svc.ds.GetLabelSpecs(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID})
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get label specs")
+ }
+ return specs, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -926,7 +934,11 @@ func (svc *Service) GetLabelSpec(ctx context.Context, name string) (*fleet.Label
return nil, fleet.ErrNoContext
}
- return svc.ds.GetLabelSpec(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true}, name)
+ spec, err := svc.ds.GetLabelSpec(ctx, fleet.TeamFilter{User: vc.User, IncludeObserver: true}, name)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get label spec")
+ }
+ return spec, nil
}
func (svc *Service) BatchValidateLabels(ctx context.Context, teamID *uint, labelNames []string) (map[string]fleet.LabelIdent, error) {
diff --git a/server/service/sessions.go b/server/service/sessions.go
index b19081ed6f2..bfabe98d645 100644
--- a/server/service/sessions.go
+++ b/server/service/sessions.go
@@ -192,14 +192,14 @@ func (svc *Service) Login(ctx context.Context, email, password string, supportsE
var err error
defer func(start time.Time) {
if err != nil && !errors.Is(err, sendingMFAEmail) && !errors.Is(err, mfaNotSupportedForClient) {
- if err := svc.NewActivity(
+ if activityErr := svc.NewActivity(
ctx, nil, fleet.ActivityTypeUserFailedLogin{
Email: email,
PublicIP: publicip.FromContext(ctx),
- }); err != nil {
- logging.WithExtras(logging.WithNoUser(ctx),
- "msg", "failed to generate failed login activity",
- )
+ }); activityErr != nil {
+ logging.WithLevel(logging.WithExtras(logging.WithNoUser(ctx),
+ "msg", "failed to generate failed login activity", "err", activityErr,
+ ), slog.LevelError)
}
time.Sleep(time.Until(start.Add(1 * time.Second)))
}
@@ -471,7 +471,7 @@ func (svc *Service) InitiateSSO(ctx context.Context, redirectURL string) (sessio
if err != nil {
return "", 0, "", ctxerr.Wrap(ctx, badRequest("invalid sso redirect url"))
}
- if slices.Contains([]string{"javascript", "vbscript", "data"}, parsedUrl.Scheme) {
+ if !slices.Contains([]string{"", "https"}, strings.ToLower(parsedUrl.Scheme)) {
return "", 0, "", ctxerr.Wrap(ctx, badRequest("invalid sso redirect url scheme: "+parsedUrl.Scheme))
}
diff --git a/server/vulnerabilities/nvd/sync/cve_syncer.go b/server/vulnerabilities/nvd/sync/cve_syncer.go
index ecbc4226611..d310820d6b0 100644
--- a/server/vulnerabilities/nvd/sync/cve_syncer.go
+++ b/server/vulnerabilities/nvd/sync/cve_syncer.go
@@ -21,6 +21,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
@@ -45,6 +46,12 @@ type CVE struct {
debug bool
WaitTimeForRetry time.Duration
MaxTryAttempts int
+
+ // cachedCVEFeedsMu guards cachedCVEFeeds below.
+ cachedCVEFeedsMu sync.Mutex
+ // cachedCVEFeeds caches per-year legacy CVE feeds for this instance's dbDir
+ // while a VulnCheck sync is in progress, to avoid repeated file reads/writes.
+ cachedCVEFeeds map[int]*schema.NVDCVEFeedJSON10
}
var (
@@ -92,6 +99,7 @@ func NewCVE(dbDir string, opts ...CVEOption) (*CVE, error) {
logger: slog.New(slog.DiscardHandler),
MaxTryAttempts: maxRetryAttempts,
WaitTimeForRetry: waitTimeForRetry,
+ cachedCVEFeeds: map[int]*schema.NVDCVEFeedJSON10{},
}
for _, fn := range opts {
fn(&s)
@@ -201,12 +209,19 @@ func (s *CVE) update(ctx context.Context) error {
return nil
}
-func (s *CVE) updateYearFile(ctx context.Context, year int, cves []nvdapi.CVEItem) error {
+// legacyFeedYear clamps the given year to the earliest year supported by the
+// legacy NVD feed format used by the github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools
+// package (and originally the facebookincubator/nvdtools package it was forked from).
+func legacyFeedYear(year int) int {
// The NVD legacy feed files start at year 2002.
- // This is assumed by the github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools package.
if year < 2002 {
- year = 2002
+ return 2002
}
+ return year
+}
+
+func (s *CVE) updateYearFile(ctx context.Context, year int, cves []nvdapi.CVEItem) error {
+ year = legacyFeedYear(year)
// Read the CVE file for the year.
readStart := time.Now()
@@ -257,20 +272,17 @@ func (s *CVE) updateYearFile(ctx context.Context, year int, cves []nvdapi.CVEIte
return nil
}
-var cachedCVEFeeds = map[int]*schema.NVDCVEFeedJSON10{}
-
func (s *CVE) updateVulnCheckYearFile(ctx context.Context, year int, cves []VulnCheckCVE, modCount, addCount *int) error {
- // The NVD legacy feed files start at year 2002.
- // This is assumed by the facebookincubator/nvdtools package.
- if year < 2002 {
- year = 2002
- }
+ year = legacyFeedYear(year)
updateStart := time.Now()
+ s.cachedCVEFeedsMu.Lock()
+ defer s.cachedCVEFeedsMu.Unlock()
+
var storedCVEFeed *schema.NVDCVEFeedJSON10
var err error
- if feed, ok := cachedCVEFeeds[year]; ok && feed != nil {
+ if feed, ok := s.cachedCVEFeeds[year]; ok && feed != nil {
storedCVEFeed = feed
} else {
storedCVEFeed, err = readCVEsLegacyFormat(s.dbDir, year)
@@ -325,7 +337,7 @@ func (s *CVE) updateVulnCheckYearFile(ctx context.Context, year int, cves []Vuln
storedCVEFeed.CVEDataNumberOfCVEs = strconv.FormatInt(int64(len(storedCVEFeed.CVEItems)), 10)
// Store the file for the year.
- cachedCVEFeeds[year] = storedCVEFeed
+ s.cachedCVEFeeds[year] = storedCVEFeed
return nil
}
@@ -333,7 +345,7 @@ func (s *CVE) updateVulnCheckYearFile(ctx context.Context, year int, cves []Vuln
func (s *CVE) writeLastModStartDateFile(lastModStartDate string) error {
normalized, err := parseAndFormatForNVD(lastModStartDate)
if err != nil {
- return err
+ return fmt.Errorf("writeLastModStartDateFile: %w", err)
}
return os.WriteFile(
@@ -703,7 +715,9 @@ func (s *CVE) processVulnCheckFile(ctx context.Context, fileName string) error {
return zipReader.File[i].Name > zipReader.File[j].Name
})
- cachedCVEFeeds = map[int]*schema.NVDCVEFeedJSON10{} // clear feeds cache for consistency
+ s.cachedCVEFeedsMu.Lock()
+ s.cachedCVEFeeds = map[int]*schema.NVDCVEFeedJSON10{} // clear feeds cache for consistency
+ s.cachedCVEFeedsMu.Unlock()
// files are in reverse chronological order by modification date
// so we can stop processing files once we find one that is older
@@ -765,6 +779,9 @@ func (s *CVE) processVulnCheckFile(ctx context.Context, fileName string) error {
// only save updated files post-vulncheck-hydration
storeStart := time.Now()
+ s.cachedCVEFeedsMu.Lock()
+ cachedCVEFeeds := s.cachedCVEFeeds
+ s.cachedCVEFeedsMu.Unlock()
for year, storedCVEFeed := range cachedCVEFeeds {
if err := storeCVEsInLegacyFormat(s.dbDir, year, storedCVEFeed); err != nil {
return err
diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go b/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go
index def9db3c06c..61bbb4f35d0 100644
--- a/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go
+++ b/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go
@@ -168,6 +168,10 @@ func (cf cpeFile) Sync(ctx context.Context, src SourceConfig, localdir string) e
func (cf cpeFile) needsUpdate(ctx context.Context, targetURL, localdir string) (bool, error) {
flog.V(1).Infof("checking etag for %q", targetURL)
+ if _, err := os.Stat(filepath.Join(localdir, cf.DataFile)); err != nil {
+ flog.V(1).Infof("data file %q does not exist in %q, needs sync", cf.DataFile, localdir)
+ return true, nil
+ }
req, err := httpNewRequestContext(ctx, "HEAD", targetURL)
if err != nil {
return false, err
@@ -216,6 +220,7 @@ func (cf cpeFile) download(ctx context.Context, targetURL string) (string, strin
if err != nil {
return "", "", err
}
+ defer dataFile.Close()
_, err = io.Copy(dataFile, resp.Body)
if err != nil {
return "", "", err
diff --git a/server/vulnerabilities/nvd/tools/wfn/matcher.go b/server/vulnerabilities/nvd/tools/wfn/matcher.go
index ddde1bed133..2e6527df056 100644
--- a/server/vulnerabilities/nvd/tools/wfn/matcher.go
+++ b/server/vulnerabilities/nvd/tools/wfn/matcher.go
@@ -14,8 +14,6 @@
package wfn
-import "sync"
-
// Matcher knows whether it matches some attributes
type Matcher interface {
// Match returns attributes which match it
@@ -69,30 +67,27 @@ func DontMatch(m Matcher) Matcher {
type multiMatcher struct {
matchers []Matcher
// if true, match will only return something if all matchers matched at least something
- allMatch bool
- depth int
- depthMutex sync.Mutex
+ allMatch bool
}
// Match is part of the Matcher interface
func (mm *multiMatcher) Match(attrs []*Attributes, requireVersion bool) []*Attributes {
- defer func() {
- mm.depthMutex.Lock()
- if mm.depth > 0 {
- mm.depth--
- }
- mm.depthMutex.Unlock()
- }()
+ return mm.match(attrs, requireVersion, 0)
+}
+// match performs the actual matching, threading the nesting depth through
+// the call stack (rather than storing it on the matcher instance) so that
+// concurrent top-level calls to Match do not interfere with each other.
+func (mm *multiMatcher) match(attrs []*Attributes, requireVersion bool, depth int) []*Attributes {
matched := make(map[*Attributes]bool)
for _, matcher := range mm.matchers {
+ var matches []*Attributes
// type check matcher against multiMatcher
- if _, ok := matcher.(*multiMatcher); !ok {
- mm.depthMutex.Lock()
- mm.depth++
- mm.depthMutex.Unlock()
+ if nested, ok := matcher.(*multiMatcher); ok {
+ matches = nested.match(attrs, requireVersion, depth+1)
+ } else {
+ matches = matcher.Match(attrs, requireVersion)
}
- matches := matcher.Match(attrs, requireVersion)
if mm.allMatch && len(matches) == 0 {
// all matchers need to match at least one attr
return nil
@@ -107,11 +102,9 @@ func (mm *multiMatcher) Match(attrs []*Attributes, requireVersion bool) []*Attri
matches = append(matches, m)
}
- if mm.depthMutex.Lock(); mm.depth == 0 && len(matches) > 1 && !attributesIncludeApp(matches) {
- mm.depthMutex.Unlock()
+ if depth == 0 && len(matches) > 1 && !attributesIncludeApp(matches) {
return nil
}
- mm.depthMutex.Unlock()
return matches
}
diff --git a/tools/android/android.go b/tools/android/android.go
index c4e5c54743f..61a301954ac 100644
--- a/tools/android/android.go
+++ b/tools/android/android.go
@@ -82,10 +82,6 @@ func main() {
*enterpriseID = strings.TrimPrefix(*enterpriseID, "enterprises/")
}
- if slices.Index(commands, *command) == -1 {
- log.Fatalf("Command must be one of: %s", strings.Join(commands, ", "))
- }
-
ctx := context.Background()
mgmt, err := androidmanagement.NewService(ctx, option.WithCredentialsJSON([]byte(androidServiceCredentials)))
if err != nil {
@@ -206,7 +202,7 @@ func devicesList(mgmt *androidmanagement.Service, enterpriseID string) {
log.Fatalf("Error listing devices: %v", err)
}
if len(result.Devices) == 0 {
- log.Printf("No policies found")
+ log.Printf("No devices found")
return
}
b, err := json.Marshal(result.Devices, jsontext.WithIndent(" "))
@@ -223,7 +219,7 @@ func devicesDelete(mgmt *androidmanagement.Service, enterpriseID string, deviceI
}
_, err := mgmt.Enterprises.Devices.Delete("enterprises/" + enterpriseID + "/devices/" + deviceID).Do()
if err != nil {
- log.Fatalf("Error listing devices: %v", err)
+ log.Fatalf("Error deleting device: %v", err)
}
log.Printf("Device %s deleted", deviceID)
}
diff --git a/tools/dibble/pkg/seed/profiles.go b/tools/dibble/pkg/seed/profiles.go
index 8ee15f6173c..7da3c5a4777 100644
--- a/tools/dibble/pkg/seed/profiles.go
+++ b/tools/dibble/pkg/seed/profiles.go
@@ -5,6 +5,8 @@ import (
_ "embed"
"fmt"
"strings"
+ "sync/atomic"
+ "time"
"github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes"
)
@@ -116,15 +118,31 @@ func Profiles(c Client, log Logger, theme themes.Theme, teams []Team, count int)
return res
}
+// uuidFallbackCounter is used only if cryptorand.Read fails, to ensure the
+// fallback UUID is still unique per call rather than a fixed constant.
+var uuidFallbackCounter uint64
+
// randomUUIDv4 returns a fresh RFC 4122 v4 UUID. Per-profile UUIDs prevent
// macOS from treating every seeded profile as the same payload (which would
// cause install/update collisions).
func randomUUIDv4() string {
var b [16]byte
if _, err := cryptorand.Read(b[:]); err != nil {
- // Vanishingly unlikely; fall back to a clearly-fake-but-unique-ish
- // value so callers can still spot seeded rows.
- return "00000000-0000-0000-0000-000000000000"
+ // Vanishingly unlikely; fall back to a value derived from the
+ // current time and a monotonic counter so repeated failures within
+ // the same seed run still produce distinct UUIDs, rather than a
+ // fixed all-zero UUID that would reintroduce the collision bug this
+ // function exists to avoid.
+ n := atomic.AddUint64(&uuidFallbackCounter, 1)
+ now := uint64(time.Now().UnixNano())
+ binary := [16]byte{}
+ for i := 0; i < 8; i++ {
+ binary[i] = byte(now >> (8 * uint(i)))
+ }
+ for i := 0; i < 8; i++ {
+ binary[8+i] = byte(n >> (8 * uint(i)))
+ }
+ b = binary
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
diff --git a/tools/fleet-mcp/auth.go b/tools/fleet-mcp/auth.go
index 5ea59d7272e..7459ba0c2b4 100644
--- a/tools/fleet-mcp/auth.go
+++ b/tools/fleet-mcp/auth.go
@@ -1,6 +1,7 @@
package main
import (
+ "crypto/sha256"
"crypto/subtle"
"net/http"
)
@@ -9,11 +10,11 @@ import (
// match "Bearer ", returning 401 Unauthorized. The comparison uses
// crypto/subtle.ConstantTimeCompare to prevent timing side-channel attacks.
func bearerAuthMiddleware(token string, next http.Handler) http.Handler {
- expected := []byte("Bearer " + token)
+ expected := sha256.Sum256([]byte("Bearer " + token))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- got := []byte(r.Header.Get("Authorization"))
- if subtle.ConstantTimeCompare(got, expected) != 1 {
+ got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
+ if subtle.ConstantTimeCompare(got[:], expected[:]) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
diff --git a/tools/luks/luks/main.go b/tools/luks/luks/main.go
index f8e841c854f..efa63e668b3 100644
--- a/tools/luks/luks/main.go
+++ b/tools/luks/luks/main.go
@@ -4,9 +4,14 @@ package main
import (
"context"
+ "crypto/rand"
+ "encoding/base64"
"errors"
"fmt"
+ "os"
+)
+import (
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/lvm"
"github.com/fleetdm/fleet/v4/orbit/pkg/zenity"
@@ -14,11 +19,13 @@ import (
"github.com/siderolabs/go-blockdevice/v2/encryption/luks"
)
-func main() {
+const maxKeySlots = 8
+const maxPassphraseRetries = 3
+
+func run() error {
devicePath, err := lvm.FindRootDisk()
if err != nil {
- fmt.Println("devicepath err:", err)
- panic(err)
+ return fmt.Errorf("find root disk: %w", err)
}
prompt := zenity.New()
@@ -30,18 +37,24 @@ func main() {
HideText: true,
})
if err != nil {
- fmt.Println("Err ShowEntry")
- panic(err)
+ return fmt.Errorf("show entry dialog: %w", err)
}
- const escrowPassPhrase = "fleet123"
+ escrowPassPhrase, err := generateEscrowPassphrase()
+ if err != nil {
+ return fmt.Errorf("generate escrow passphrase: %w", err)
+ }
+
+ // TODO: submit escrowPassPhrase to the secure secret store / server
+ // rather than only holding it in memory here.
device := luks.New(luks.AESXTSPlain64Cipher)
keySlot := 1
+ passphraseRetries := 0
for {
- if keySlot == 8 {
- panic(errors.New("all LUKS key slots are full"))
+ if keySlot >= maxKeySlots {
+ return errors.New("all LUKS key slots are full")
}
userKey := encryption.NewKey(0, currentPassphrase)
@@ -49,18 +62,23 @@ func main() {
if err := device.AddKey(context.Background(), devicePath, userKey, escrowKey); err != nil {
if errors.Is(err, encryption.ErrEncryptionKeyRejected) {
+ passphraseRetries++
+ if passphraseRetries > maxPassphraseRetries {
+ return fmt.Errorf("add key: too many incorrect passphrase attempts: %w", err)
+ }
+
currentPassphrase, err = prompt.ShowEntry(dialog.EntryOptions{
Title: "Enter Existing LUKS Passphrase",
Text: "Bad password. Enter your existing LUKS passphrase:",
HideText: true,
})
if err != nil {
- fmt.Println("Err Retry ShowEntry")
- panic(err)
+ return fmt.Errorf("show retry entry dialog: %w", err)
}
continue
}
+ fmt.Println("add key err:", err)
keySlot++
continue
}
@@ -69,4 +87,20 @@ func main() {
}
fmt.Println("Key escrowed successfully.")
+ return nil
+}
+
+func generateEscrowPassphrase() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("read random bytes: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Println("luks escrow error:", err)
+ os.Exit(1)
+ }
}
diff --git a/tools/mdm/apple/loadtest/loadtest.go b/tools/mdm/apple/loadtest/loadtest.go
index 566a0777754..8b7d4b8a412 100644
--- a/tools/mdm/apple/loadtest/loadtest.go
+++ b/tools/mdm/apple/loadtest/loadtest.go
@@ -82,6 +82,10 @@ func main() {
log.Fatalf("host count (%d) must match expected team count (%d)", len(hosts), *teamCount)
}
+ if *teamExtraCount > len(hosts) {
+ log.Fatalf("team_extra_count (%d) exceeds available hosts (%d)", *teamExtraCount, len(hosts))
+ }
+
printfAndPrompt("1. Creating %d teams...", *teamCount)
start := time.Now()
@@ -709,3 +713,4 @@ var newProfile = []byte(`
1
`)
+
diff --git a/tools/snapshot/snapshot.go b/tools/snapshot/snapshot.go
index cc9887f4698..60c04d9ee43 100644
--- a/tools/snapshot/snapshot.go
+++ b/tools/snapshot/snapshot.go
@@ -147,8 +147,7 @@ func restore(homedir string) error {
}
index, _, err := prompt.Run()
if err != nil {
- fmt.Printf("Prompt failed %v\n", err)
- return err
+ return fmt.Errorf("selecting snapshot: %w", err)
}
// Prepare the restore script with the selected snapshot.
@@ -161,8 +160,6 @@ func restore(homedir string) error {
// Run the command.
err = cmd.Run()
- output, _ := cmd.CombinedOutput()
- fmt.Println(string(output))
if err != nil {
fmt.Printf("Error: %v\n", err)
return err
@@ -250,8 +247,6 @@ func snapshot(homedir string) error {
// Run the command.
err = cmd.Run()
- output, _ := cmd.CombinedOutput()
- fmt.Println(string(output))
if err != nil {
fmt.Printf("Error: %v\n", err)
return err
diff --git a/website/api/controllers/customers/get-stripe-checkout-session-url.js b/website/api/controllers/customers/get-stripe-checkout-session-url.js
index 8c346efe89b..b6432cd59a3 100644
--- a/website/api/controllers/customers/get-stripe-checkout-session-url.js
+++ b/website/api/controllers/customers/get-stripe-checkout-session-url.js
@@ -43,8 +43,10 @@ module.exports = {
// What if the stripe customer id doesn't already exist on the user?
if (!stripeCustomerId) {
// Create a new customer entry in the Stripe API for this user before we create a checkout session for their license dispenser purchase.
+ // Note: An idempotency key derived from the user's id is used so that retries (see .retry() below) do not result in duplicate Stripe Customer records if a previous attempt actually succeeded on Stripe's end but the response was lost (e.g. due to a timeout).
stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
- emailAddress: this.req.me.emailAddress
+ emailAddress: this.req.me.emailAddress,
+ idempotencyKey: `saveBillingInfo-${this.req.me.id}`
})
.timeout(5000)
.retry()
diff --git a/website/api/helpers/create-license-key.js b/website/api/helpers/create-license-key.js
index 18e38d6d6a4..98949ae2a17 100644
--- a/website/api/helpers/create-license-key.js
+++ b/website/api/helpers/create-license-key.js
@@ -40,6 +40,18 @@ module.exports = {
outputType: 'string',
},
+ invalidNumberOfHosts: {
+ description: 'The provided numberOfHosts is out of bounds.'
+ },
+
+ invalidOrganization: {
+ description: 'The provided organization value is invalid.'
+ },
+
+ invalidExpiresAt: {
+ description: 'The provided expiresAt is out of bounds.'
+ },
+
},
@@ -47,6 +59,20 @@ module.exports = {
let jwt = require('jsonwebtoken');
+ if (!Number.isInteger(numberOfHosts) || numberOfHosts <= 0 || numberOfHosts > 1000000) {
+ throw 'invalidNumberOfHosts';
+ }
+
+ if (typeof organization !== 'string' || organization.trim() === '' || organization.length > 200) {
+ throw 'invalidOrganization';
+ }
+
+ let nowInMs = Date.now();
+ let maxExpiresAtInMs = nowInMs + (10 * 365 * 24 * 60 * 60 * 1000); // ten years from now
+ if (!Number.isFinite(expiresAt) || expiresAt <= nowInMs || expiresAt > maxExpiresAtInMs) {
+ throw 'invalidExpiresAt';
+ }
+
let expirationTimestampInSeconds = Math.floor(expiresAt / 1000);
let token = jwt.sign(
{
@@ -73,3 +99,4 @@ module.exports = {
};
+