diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index 1c8ee0a03a..c149acf0a0 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.EncodeDefault import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -438,6 +439,9 @@ object ApiClient : CertificateApiClient { ) } +// @EncodeDefault is marked @ExperimentalSerializationApi, but it has shipped in kotlinx.serialization since 1.3 (2022) +// and is widely used and reliable in production. The opt-in only acknowledges that the API shape could change in a future version. +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) @Serializable data class EnrollRequest( @SerialName("enroll_secret") @@ -446,6 +450,7 @@ data class EnrollRequest( val hardwareUUID: String, @SerialName("hardware_serial") val hardwareSerial: String, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) @SerialName("platform") val platform: String = "android", @SerialName("computer_name") diff --git a/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt b/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt index 97fed43533..584d3b4c31 100644 --- a/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/ApiClientReenrollTest.kt @@ -100,10 +100,15 @@ class ApiClientReenrollTest { assertTrue("First call should succeed", firstResult.isSuccess) assertEquals(2, mockWebServer.requestCount) // enroll + config - // Verify first enrollment used the enroll secret + // Verify first enrollment used the enroll secret and sent platform="android" on the wire val firstEnroll = mockWebServer.takeRequest() assertEquals("/api/fleet/orbit/enroll", firstEnroll.path) - assertTrue(firstEnroll.body.readUtf8().contains("test-enroll-secret")) + val firstEnrollBody = firstEnroll.body.readUtf8() + assertTrue(firstEnrollBody.contains("test-enroll-secret")) + assertTrue( + "Expected platform=\"android\" in enroll body, got: $firstEnrollBody", + firstEnrollBody.contains("\"platform\":\"android\""), + ) // Verify first config used first-node-key val firstConfig = mockWebServer.takeRequest() diff --git a/android/changes/43807-android-enroll-platform b/android/changes/43807-android-enroll-platform new file mode 100644 index 0000000000..dc1b0b566e --- /dev/null +++ b/android/changes/43807-android-enroll-platform @@ -0,0 +1 @@ +- Fixed Android agent to always send the `platform` field on enrollment so the device is registered with the correct platform. diff --git a/changes/42885-api-only-endpoints-middleware b/changes/42885-api-only-endpoints-middleware new file mode 100644 index 0000000000..49aff381e5 --- /dev/null +++ b/changes/42885-api-only-endpoints-middleware @@ -0,0 +1 @@ +- Added new middleware (APIOnlyEndpointCheck) that enforces 403 for API-only users whose request either isn't in the API endpoint catalog or falls outside their configured per-user endpoint restrictions. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index d8a1d16e2b..864e1ead34 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1935,9 +1935,10 @@ func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBCon activityACLAdapter, logger, ) - // Create auth middleware for activity bounded context + // Makes sure that api_only users are subject to endpoint + // restrictions on activity routes. activityAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { - return auth.AuthenticatedUser(svc, next) + return auth.AuthenticatedUser(svc, auth.APIOnlyEndpointCheck(next)) } activityRoutes := activityRoutesFn(activityAuthMiddleware) return activitySvc, activityRoutes diff --git a/ee/maintained-apps/outputs/chatgpt/darwin.json b/ee/maintained-apps/outputs/chatgpt/darwin.json index 09cb78b84a..a7c970b83a 100644 --- a/ee/maintained-apps/outputs/chatgpt/darwin.json +++ b/ee/maintained-apps/outputs/chatgpt/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "1.2026.098", + "version": "1.2026.099", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.openai.chat';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.openai.chat' AND version_compare(bundle_short_version, '1.2026.098') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.openai.chat' AND version_compare(bundle_short_version, '1.2026.099') < 0);" }, - "installer_url": "https://persistent.oaistatic.com/sidekick/public/ChatGPT_Desktop_public_1.2026.098_1776453807.dmg", + "installer_url": "https://persistent.oaistatic.com/sidekick/public/ChatGPT_Desktop_public_1.2026.099_1776706502.dmg", "install_script_ref": "105f16ab", "uninstall_script_ref": "dbaa4d2e", - "sha256": "87883fbc5761f55114a658432c0f3a3c83edd9104bb8524ebcf04ae229dff798", + "sha256": "f3a6d63494ff9ef8af8abf6dd3be5297db7f904ea01040b790251e6846b4ac88", "default_categories": [ "Productivity" ] diff --git a/ee/maintained-apps/outputs/claude/darwin.json b/ee/maintained-apps/outputs/claude/darwin.json index 638a5bb74f..9facf7c335 100644 --- a/ee/maintained-apps/outputs/claude/darwin.json +++ b/ee/maintained-apps/outputs/claude/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "1.3109.0", + "version": "1.3561.0", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.anthropic.claudefordesktop';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.anthropic.claudefordesktop' AND version_compare(bundle_short_version, '1.3109.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.anthropic.claudefordesktop' AND version_compare(bundle_short_version, '1.3561.0') < 0);" }, - "installer_url": "https://downloads.claude.ai/releases/darwin/universal/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.zip", + "installer_url": "https://downloads.claude.ai/releases/darwin/universal/1.3561.0/Claude-fbc74be3fdc714a2c46ef1fb84f71d4e4c062930.zip", "install_script_ref": "d05235fc", "uninstall_script_ref": "4cfbec7d", - "sha256": "f159693ebecd8a628cfe882c913a1979fc9a4b1d3bc9a218051f0f115c88bb68", + "sha256": "b037ee8ebe3d11e7eba959179dfdc534951ae8e387e96cacda1dceeecd035b52", "default_categories": [ "Developer tools" ] diff --git a/ee/maintained-apps/outputs/claude/windows.json b/ee/maintained-apps/outputs/claude/windows.json index b06616c8f9..c30883b875 100644 --- a/ee/maintained-apps/outputs/claude/windows.json +++ b/ee/maintained-apps/outputs/claude/windows.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "1.3109.0", + "version": "1.3561.0", "queries": { "exists": "SELECT 1 FROM programs WHERE name = 'Claude' AND publisher = 'Anthropic, PBC';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Claude' AND publisher = 'Anthropic, PBC' AND version_compare(version, '1.3109.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Claude' AND publisher = 'Anthropic, PBC' AND version_compare(version, '1.3561.0') < 0);" }, - "installer_url": "https://downloads.claude.ai/releases/win32/x64/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.msix", + "installer_url": "https://downloads.claude.ai/releases/win32/x64/1.3561.0/Claude-fbc74be3fdc714a2c46ef1fb84f71d4e4c062930.msix", "install_script_ref": "31a0b698", "uninstall_script_ref": "03f72055", - "sha256": "4c664c5390a4d5b46286158f67e21102431a1c9863dc5edb2b8683ef69e1e300", + "sha256": "726cf8552406f0a6260c5ddce9d5a6f560fb0f9098137447e636adfe752174cc", "default_categories": [ "Productivity" ] diff --git a/ee/maintained-apps/outputs/cursor/darwin.json b/ee/maintained-apps/outputs/cursor/darwin.json index ed7e777b54..70e5e01e20 100644 --- a/ee/maintained-apps/outputs/cursor/darwin.json +++ b/ee/maintained-apps/outputs/cursor/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "3.1.15", + "version": "3.1.17", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.todesktop.230313mzl4w4u92';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.todesktop.230313mzl4w4u92' AND version_compare(bundle_short_version, '3.1.15') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.todesktop.230313mzl4w4u92' AND version_compare(bundle_short_version, '3.1.17') < 0);" }, - "installer_url": "https://downloads.cursor.com/production/3a67af7b780e0bfc8d32aefa96b8ff1cb8817f88/darwin/arm64/Cursor-darwin-arm64.zip", + "installer_url": "https://downloads.cursor.com/production/fce1e9ab7844f9ea35793da01e634aa7e50bce90/darwin/arm64/Cursor-darwin-arm64.zip", "install_script_ref": "5a1b1299", "uninstall_script_ref": "f7561d44", - "sha256": "e0b65b57d30d94eb668151c9b44d474f7fbb9c78a2bc4ec1bdff1a537bbc21a2", + "sha256": "2c308456eb5b834da262fe4f3cca744e179e68eb25591d0b4f83c3b742956290", "default_categories": [ "Developer tools" ] diff --git a/ee/maintained-apps/outputs/cursor/windows.json b/ee/maintained-apps/outputs/cursor/windows.json index 27d0272907..96e3b2841b 100644 --- a/ee/maintained-apps/outputs/cursor/windows.json +++ b/ee/maintained-apps/outputs/cursor/windows.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "3.1.15", + "version": "3.1.17", "queries": { "exists": "SELECT 1 FROM programs WHERE name = 'Cursor' AND publisher = 'Anysphere';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Cursor' AND publisher = 'Anysphere' AND version_compare(version, '3.1.15') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Cursor' AND publisher = 'Anysphere' AND version_compare(version, '3.1.17') < 0);" }, - "installer_url": "https://downloads.cursor.com/production/3a67af7b780e0bfc8d32aefa96b8ff1cb8817f88/win32/x64/system-setup/CursorSetup-x64-3.1.15.exe", + "installer_url": "https://downloads.cursor.com/production/fce1e9ab7844f9ea35793da01e634aa7e50bce90/win32/x64/system-setup/CursorSetup-x64-3.1.17.exe", "install_script_ref": "03589b5e", "uninstall_script_ref": "6c8096c5", - "sha256": "d667e13c7e538e11b23569071f2a47b271494038f9ae0be6c424a30c318bc73b", + "sha256": "14f6e7c8244175496bf8be1af05a7004d7013b6928576a0cdc350705baa8808d", "default_categories": [ "Developer tools" ] diff --git a/ee/maintained-apps/outputs/discord/darwin.json b/ee/maintained-apps/outputs/discord/darwin.json index 052d69e4d9..f910be5031 100644 --- a/ee/maintained-apps/outputs/discord/darwin.json +++ b/ee/maintained-apps/outputs/discord/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "0.0.385", + "version": "0.0.386", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.hnc.Discord';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.hnc.Discord' AND version_compare(bundle_short_version, '0.0.385') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.hnc.Discord' AND version_compare(bundle_short_version, '0.0.386') < 0);" }, - "installer_url": "https://dl.discordapp.net/apps/osx/0.0.385/Discord.dmg", + "installer_url": "https://dl.discordapp.net/apps/osx/0.0.386/Discord.dmg", "install_script_ref": "dac37ced", "uninstall_script_ref": "8ed76f08", - "sha256": "c5b69b1d27abd56d610cdb6173b4575ec84971aa1bbf91875e992c85bad3cff7", + "sha256": "a01cf51f325bb95f4f9dd2ca96f9c1a03f547ac00c4e2034164835508cd8a04d", "default_categories": [ "Communication" ] diff --git a/ee/maintained-apps/outputs/discord/windows.json b/ee/maintained-apps/outputs/discord/windows.json index 577dfe48d8..f251bc36fb 100644 --- a/ee/maintained-apps/outputs/discord/windows.json +++ b/ee/maintained-apps/outputs/discord/windows.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "1.0.9233", + "version": "1.0.9234", "queries": { "exists": "SELECT 1 FROM programs WHERE name = 'Discord' AND publisher = 'Discord Inc.';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Discord' AND publisher = 'Discord Inc.' AND version_compare(version, '1.0.9233') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Discord' AND publisher = 'Discord Inc.' AND version_compare(version, '1.0.9234') < 0);" }, - "installer_url": "https://stable.dl2.discordapp.net/distro/app/stable/win/x64/1.0.9233/DiscordSetup.exe", + "installer_url": "https://stable.dl2.discordapp.net/distro/app/stable/win/x64/1.0.9234/DiscordSetup.exe", "install_script_ref": "fd04e860", "uninstall_script_ref": "73fc6eff", - "sha256": "eaba7863d5a7ed017a865f991267583e0768914aba99590874da0980832e45a3", + "sha256": "3f0306caceaa9594c608b39dbee8ceaba4422abc51052ee21deda7280eee9173", "default_categories": [ "Communication" ] diff --git a/ee/maintained-apps/outputs/docker-desktop/darwin.json b/ee/maintained-apps/outputs/docker-desktop/darwin.json index 037fc377b8..f917d9fc4c 100644 --- a/ee/maintained-apps/outputs/docker-desktop/darwin.json +++ b/ee/maintained-apps/outputs/docker-desktop/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "4.69.0", + "version": "4.70.0", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.electron.dockerdesktop';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.electron.dockerdesktop' AND version_compare(bundle_short_version, '4.69.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.electron.dockerdesktop' AND version_compare(bundle_short_version, '4.70.0') < 0);" }, - "installer_url": "https://desktop.docker.com/mac/main/arm64/224084/Docker.dmg", + "installer_url": "https://desktop.docker.com/mac/main/arm64/224270/Docker.dmg", "install_script_ref": "091b8a34", "uninstall_script_ref": "c31a36e7", - "sha256": "8e4638f2d427a4fb9d0f86f21d0975a344a164228d20fc22a620750b7943d644", + "sha256": "38a19ac039dee38e0e445118da34962039ef1601b0656dc4d138592efda2caba", "default_categories": [ "Developer tools" ] diff --git a/ee/maintained-apps/outputs/loom/darwin.json b/ee/maintained-apps/outputs/loom/darwin.json index aada7bcbde..4c52a97c13 100644 --- a/ee/maintained-apps/outputs/loom/darwin.json +++ b/ee/maintained-apps/outputs/loom/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "0.344.0", + "version": "0.345.1", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.loom.desktop';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.loom.desktop' AND version_compare(bundle_short_version, '0.344.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.loom.desktop' AND version_compare(bundle_short_version, '0.345.1') < 0);" }, - "installer_url": "https://packages.loom.com/desktop-packages/Loom-0.344.0-arm64.dmg", + "installer_url": "https://packages.loom.com/desktop-packages/Loom-0.345.1-arm64.dmg", "install_script_ref": "8185f7a5", "uninstall_script_ref": "393b959f", - "sha256": "c5c561c45ce97d6bfaf344b0f12417165345e69a78dee3cfd02705e3c56b929d", + "sha256": "dc837812b1e3255575e44e72b0a84f34b0f6500e91af32b234eb65f8fcd115ad", "default_categories": [ "Productivity" ] diff --git a/ee/maintained-apps/outputs/orbstack/darwin.json b/ee/maintained-apps/outputs/orbstack/darwin.json index 4fb02dc4be..26544d0224 100644 --- a/ee/maintained-apps/outputs/orbstack/darwin.json +++ b/ee/maintained-apps/outputs/orbstack/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "2.1.0", + "version": "2.1.1", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'dev.kdrag0n.MacVirt';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'dev.kdrag0n.MacVirt' AND version_compare(bundle_short_version, '2.1.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'dev.kdrag0n.MacVirt' AND version_compare(bundle_short_version, '2.1.1') < 0);" }, - "installer_url": "https://cdn-updates.orbstack.dev/arm64/OrbStack_v2.1.0_19993_arm64.dmg", + "installer_url": "https://cdn-updates.orbstack.dev/arm64/OrbStack_v2.1.1_20026_arm64.dmg", "install_script_ref": "3d018c85", "uninstall_script_ref": "401d288b", - "sha256": "65dcb7ac3f53022eed732d962ce97af000a10e8a3de77fa4ed47645b7591f4c3", + "sha256": "18a41f759958e1fa0951696b820b5478d3ed7353f8ca486fe9d026a1a7d97207", "default_categories": [ "Developer tools" ] diff --git a/ee/maintained-apps/outputs/zed/darwin.json b/ee/maintained-apps/outputs/zed/darwin.json index 4000a47297..c5a5cf7199 100644 --- a/ee/maintained-apps/outputs/zed/darwin.json +++ b/ee/maintained-apps/outputs/zed/darwin.json @@ -1,15 +1,15 @@ { "versions": [ { - "version": "0.232.2", + "version": "0.232.3", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'dev.zed.Zed';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'dev.zed.Zed' AND version_compare(bundle_short_version, '0.232.2') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'dev.zed.Zed' AND version_compare(bundle_short_version, '0.232.3') < 0);" }, - "installer_url": "https://zed.dev/api/releases/stable/0.232.2/Zed-aarch64.dmg", + "installer_url": "https://zed.dev/api/releases/stable/0.232.3/Zed-aarch64.dmg", "install_script_ref": "b0e5ef67", "uninstall_script_ref": "a96df5e9", - "sha256": "82717ddea4c03ad033a9c99d1440d1b71d6f6141b02eedd1aa1cb8eb3dcb938f", + "sha256": "2f6ca2b175772e57c1d5b2893f8c8556e16581341f9568ee0ce4f84f5128b488", "default_categories": [ "Developer tools" ] diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index e722f5519a..83d9c3c382 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -8,7 +8,13 @@ # youtubeVideoUrl: #. productCategories: [Observability, Software management, Device management] # -# TODO figure out what to do with this other quoteΒ that both Austin and Dre :+1:'d: "Fleet lets us be more actionable with fewer people. It helps us to filter out the noise better than we could with the other big name products we replaced." +# TODO figure out what to do with this other quote that both Austin and Dre :+1:'d: "Fleet lets us be more actionable with fewer people. It helps us to filter out the noise better than we could with the other big name products we replaced." +- quote: I think it is key that people understand the leverage they have with AI if everything is 'code'. In the AI age, clickops will not prevail! + quoteAuthorName: Thomas LΓΌbker + quoteAuthorProfileImageFilename: testimonial-author-thomas-luebker-48x48@2x.png + quoteLinkUrl: https://www.linkedin.com/in/tluebker/?skipRedirect=true + quoteAuthorJobTitle: Helping Apple devices take off in the enterprise + productCategories: [Device management] - quote: Fleet is incredibly easy to deploy and perfect for lean IT teams. It's everything we need in a device management platform, simple, efficient, and powerful. quoteAuthorName: Chayce O'Neal quoteAuthorProfileImageFilename: testimonial-author-chayce-oneal-100x100@2x.png diff --git a/handbook/marketing/marketing-assets.md b/handbook/marketing/marketing-assets.md index ea0eab7b77..940eb32b7b 100644 --- a/handbook/marketing/marketing-assets.md +++ b/handbook/marketing/marketing-assets.md @@ -9,7 +9,7 @@ If you find a piece of collateral that is duplicate to one listed below, please ## 🟒 Sales & enablement -Decks, battle cards, one-pagers, comparisons, and tools used in the sales cycle β€” from first touch through close. Audience: AEs, SDRs, SEs, and customer success. +Decks, battle cards, one-pagers, comparisons, and tools used in the sales cycle - from first touch through close. Audience: AEs, SDRs, SEs, and customer success. ### Pitch & presentation @@ -46,14 +46,14 @@ Decks, battle cards, one-pagers, comparisons, and tools used in the sales cycle | [Fleet vs. Jamf](https://fleetdm.com/compare/jamf) | Jamf | Direct comparison of Fleet and Jamf Pro product offerings. | Jan‑10‑2026 | | [MDM Providers Compared: Fleet vs Workspace ONE](https://fleetdm.com/articles/mdm-providers-compared) | Workspace ONE | Feature-by-feature comparison of Fleet and VMware Workspace ONE. | Feb‑27‑2026 | | [A comparative look at VMware Workspace ONE and Fleet](https://fleetdm.com/articles/comparative-look-at-ws1-and-fleet) | Workspace ONE | Side-by-side comparison with VMware Workspace ONE. | Feb‑01‑2024 | -| [A comparative look at VMware Workspace ONE and Fleet (announcement)](https://fleetdm.com/announcements/a-comparative-look-at-vmware-workspace-one-and-fleet) | Workspace ONE | Original announcement post β€” side-by-side comparison with VMware Workspace ONE. | Feb‑01‑2024 | -| [Apple Push Notification Service: How APNs Works in MDM](https://fleetdm.com/articles/apple-push-notification-service-apns-mdm) | β€” | How APNs enables MDM communication with Apple devices. | Mar‑09‑2026 | -| [Battle card β€” Workspace ONE](https://docs.google.com/document/d/1dV9zooPnCnHa0UfZqXKWAi6Zmye2jfqRIr_UnX70Sq4/edit) | Workspace ONE | Strategic talking points for VMware competitive scenarios. | Sep‑12‑2023 | +| [A comparative look at VMware Workspace ONE and Fleet (announcement)](https://fleetdm.com/announcements/a-comparative-look-at-vmware-workspace-one-and-fleet) | Workspace ONE | Original announcement post - side-by-side comparison with VMware Workspace ONE. | Feb‑01‑2024 | +| [Apple Push Notification Service: How APNs Works in MDM](https://fleetdm.com/articles/apple-push-notification-service-apns-mdm) | - | How APNs enables MDM communication with Apple devices. | Mar‑09‑2026 | +| [Battle card - Workspace ONE](https://docs.google.com/document/d/1dV9zooPnCnHa0UfZqXKWAi6Zmye2jfqRIr_UnX70Sq4/edit) | Workspace ONE | Strategic talking points for VMware competitive scenarios. | Sep‑12‑2023 | | [Jamf vs. Fleet terminology](https://docs.google.com/document/d/1RKojfpUMUiITPce5O7znYmxdQR9U6f5sVnVzQpPpp8Y/edit) | Jamf | Concept mapping between Jamf and Fleet for migration conversations. | Sep‑09‑2025 | | [Death to Extension Attributes](https://github.com/allenhouchins/death-to-extension-attributes) | Jamf | Compares Fleet queries to Jamf extension attributes for technical audiences. | Jun‑15‑2023 | -| [ROI Spreadsheet](https://docs.google.com/spreadsheets/d/14Cfj77ynOB6z4pmb9DD7HNRGo0kcKJuEIifgnz-YO50/edit) | β€” | Financial value justification and ROI calculator for deals. | Aug‑14‑2023 | -| [Product Roadmap](https://docs.google.com/document/d/16si8Nkh0F25opUMpZYm6XwU8LEhGd7H4eOzPKFz8jGw/edit) | β€” | Future development vision for open roadmap conversations. | Jan‑20‑2021 | -| [Gartner IT Symposium 2025 Presentation](https://drive.google.com/file/d/1HK1QXA2kOCeOoOG1E0-tk7-xwBhQ2QMa/view) | β€” | Outcomes and value achieved via GitOps. (by Allen Houchins) | Oct‑21‑2024 | +| [ROI Spreadsheet](https://docs.google.com/spreadsheets/d/14Cfj77ynOB6z4pmb9DD7HNRGo0kcKJuEIifgnz-YO50/edit) | - | Financial value justification and ROI calculator for deals. | Aug‑14‑2023 | +| [Product Roadmap](https://docs.google.com/document/d/16si8Nkh0F25opUMpZYm6XwU8LEhGd7H4eOzPKFz8jGw/edit) | - | Future development vision for open roadmap conversations. | Jan‑20‑2021 | +| [Gartner IT Symposium 2025 Presentation](https://drive.google.com/file/d/1HK1QXA2kOCeOoOG1E0-tk7-xwBhQ2QMa/view) | - | Outcomes and value achieved via GitOps. (by Allen Houchins) | Oct‑21‑2024 | ## Webinar Recordings @@ -122,7 +122,7 @@ Why Linux is now a critical enterprise platform and how to manage and secure it | 5 of 6 | [Protecting the Linux device: remote wipe, USB & sudo](https://fleetdm.com/articles/protecting-the-linux-device-remote-wipe-usb-sudo) | Ashish Kuthiala | 2026‑03‑10 | | 6 of 6 | [Owning your Linux destiny with open source](https://fleetdm.com/articles/owning-your-linux-destiny-with-open-source) | Ashish Kuthiala | 2026‑03‑06 | -#### πŸ“š OpenClaw β€” Governing Autonomous AI Agents (3-part series) +#### πŸ“š OpenClaw - Governing Autonomous AI Agents (3-part series) Security risks of autonomous AI agents running on managed endpoints, and what IT leaders can do about them. @@ -143,7 +143,7 @@ Managing the Santa binary authorization tool using Fleet's GitOps workflow, with #### πŸ“š Tales from Fleet Security (8-part series) -How Fleet's own security team secures the company β€” a window into Fleet's internal security practices useful for security-minded evaluators. +How Fleet's own security team secures the company - a window into Fleet's internal security practices useful for security-minded evaluators. | Part | Article | Author | Date | | --- | --- | --- | --- | @@ -168,7 +168,7 @@ How Fleet's own security team secures the company β€” a window into Fleet's inte | [Mac device security: Apple's native protections and third-party tools](https://fleetdm.com/articles/mac-device-security) | How Mac endpoint security works, from Apple's protections (SIP, XProtect, TCC) to third-party tools. | Brock Walters | 2026‑02‑25 | | [How to manage company laptops: a complete guide](https://fleetdm.com/articles/how-to-manage-company-laptops-a-complete-guide) | A complete guide to managing company laptops across macOS, Windows, and Linux at scale. | Brock Walters | 2026‑03‑07 | | [Migrating Intune policies to Fleet with the CSP converter](https://fleetdm.com/articles/migrating-intune-policies-to-fleet-csp-converter) | How to translate Intune configuration policies to Fleet using the CSP converter tool. | Mitch Francese | 2026‑03‑06 | -| [Why work laptops don't work on plane wifi](https://fleetdm.com/articles/why-work-laptops-dont-work-on-plane-wifi) | VPNs, DNS filters, and captive portals β€” and why IT teams should fix this by default. | Mike McNeil | 2026‑02‑13 | +| [Why work laptops don't work on plane wifi](https://fleetdm.com/articles/why-work-laptops-dont-work-on-plane-wifi) | VPNs, DNS filters, and captive portals - and why IT teams should fix this by default. | Mike McNeil | 2026‑02‑13 | | [The GitOps idea](https://fleetdm.com/articles/the-gitops-idea) | An introduction to GitOps concepts and components. | Brock Walters | 2026‑02‑04 | | [iPad MDM: a complete guide](https://fleetdm.com/articles/ipad-mdm-a-complete-guide) | A complete guide to iPad MDM, covering deployment models, enrollment methods, and enterprise management at scale. | Brock Walters | 2026‑01‑13 | | [What is Apple Business Manager? A complete guide](https://fleetdm.com/articles/what-is-apple-business-manager-a-complete-guide) | How ABM works with MDM for automated enrollment and app management. | Brock Walters | 2026‑01‑12 | @@ -180,7 +180,7 @@ How Fleet's own security team secures the company β€” a window into Fleet's inte | [Free migration from Jamf to Fleet](https://fleetdm.com/articles/free-migration-from-jamf-to-fleet) | Switch from Jamf to Fleet for free using Apple's new macOS migration flow. | Alex Mitchell | 2025‑10‑06 | | [Device enrollment lifecycle](https://fleetdm.com/articles/device-enrollment-lifecycle) | The five stages of a device enrollment lifecycle. | Brock Walters | 2025‑09‑19 | | [One agent, fewer tools, fewer gaps](https://fleetdm.com/articles/one-agent-fewer-tools-fewer-gaps) | Managing devices and vulnerabilities shouldn't mean installing two agents or paying for two platforms. | Harrison Ravazzolo | 2025‑06‑23 | -| [I work in operations. I deployed Fleet in minutes.](https://fleetdm.com/articles/i-work-in-operations-i-deployed-fleet-in-minutes) | Self-hosting Fleet is easier than you think β€” even for non-technical roles. | Nate Holliday | 2025‑06‑05 | +| [I work in operations. I deployed Fleet in minutes.](https://fleetdm.com/articles/i-work-in-operations-i-deployed-fleet-in-minutes) | Self-hosting Fleet is easier than you think - even for non-technical roles. | Nate Holliday | 2025‑06‑05 | | [Not everything runs in Kubernetes](https://fleetdm.com/articles/not-everything-runs-in-kubernete) | Why Fleet goes beyond Kubernetes to manage real-world infrastructure. | Zach Wasserman | 2025‑05‑27 | | [Work may be watching, but it might not be as bad as you think](https://fleetdm.com/articles/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think) | A clear-eyed look at employee monitoring, what IT teams actually collect, and how to set expectations. | Mike Thomas | 2021‑10‑22 | | [eBPF & the future of osquery on Linux](https://fleetdm.com/articles/ebpf-the-future-of-osquery-on-linux) | How eBPF extends osquery's visibility on Linux and what it means for endpoint security. | Zach Wasserman | 2021‑01‑25 | @@ -220,7 +220,7 @@ How-to guides, step-by-step walkthroughs, and reference material for IT admins a #### Platform setup -Turn on MDM for each platform β€” start here if Fleet MDM is not yet configured. +Turn on MDM for each platform - start here if Fleet MDM is not yet configured. | Asset | Description | Author | Date updated | | --- | --- | --- | --- | @@ -233,7 +233,7 @@ Turn on MDM for each platform β€” start here if Fleet MDM is not yet configured. #### Enrolling devices -Add hosts to Fleet β€” corporate-owned and personal (BYOD) devices. +Add hosts to Fleet - corporate-owned and personal (BYOD) devices. | Asset | Description | Author | Date updated | | --- | --- | --- | --- | @@ -282,7 +282,7 @@ Concepts, rationale, and first-time setup for teams adopting GitOps with Fleet. #### Managing configuration with GitOps -Day-to-day workflows for managing Fleet resources β€” software, profiles, policies, and packages β€” through GitOps. +Day-to-day workflows for managing Fleet resources - software, profiles, policies, and packages - through GitOps. | Asset | Description | Author | Date updated | | --- | --- | --- | --- | @@ -381,7 +381,7 @@ Command-line tools for managing Fleet configurations, running queries, and deplo | [Osquery: Consider joining against the users table](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) | Tips for enriching osquery query results by joining against the users table for better context. | Zach Wasserman | 2021‑05‑06 | | [Import and export queries in Fleet](https://fleetdm.com/guides/import-and-export-queries-in-fleet) | How to import and export saved queries in Fleet for sharing or backup purposes. | Noah Talerman | 2021‑02‑16 | | [Generate process trees with osquery](https://fleetdm.com/guides/generate-process-trees-with-osquery) | How to use osquery in Fleet to generate parent-child process trees for forensic investigation. | Zach Wasserman | 2020‑03‑17 | -| [Fleet quick tips β€” identify systems where the ProcDump EULA has been accepted](https://fleetdm.com/guides/fleet-quick-tips-querying-procdump-eula-has-been-accepted) | Use Fleet to query which systems have accepted the ProcDump EULA, a potential indicator of investigation activity. | Mike Thomas | 2021‑05‑11 | +| [Fleet quick tips - identify systems where the ProcDump EULA has been accepted](https://fleetdm.com/guides/fleet-quick-tips-querying-procdump-eula-has-been-accepted) | Use Fleet to query which systems have accepted the ProcDump EULA, a potential indicator of investigation activity. | Mike Thomas | 2021‑05‑11 | ### Integrations & automations @@ -408,7 +408,7 @@ Command-line tools for managing Fleet configurations, running queries, and deplo | [Monitor DNS traffic on macOS](https://fleetdm.com/articles/monitor-dns-traffic-on-macos) | How to use Fleet and osquery to monitor DNS queries on macOS endpoints. | Victor Lyuboslavsky | 2026‑03‑08 | | [Deploying custom osquery extensions in Fleet](https://fleetdm.com/articles/deploying-custom-osquery-extensions-in-fleet) | How to deploy custom osquery extensions to managed devices using Fleet. | Allen Houchins | 2026‑03‑05 | | [Deploying custom osquery extensions in Fleet: A step-by-step guide](https://fleetdm.com/articles/deploying-custom-osquery-extensions-in-fleet-a-step-by-step-guide) | Step-by-step walkthrough for deploying custom osquery extensions across your Fleet-managed devices. | Allen Houchins | 2026‑03‑06 | -| [What are Fleet policies?](https://fleetdm.com/articles/what-are-fleet-policies) | An introduction to Fleet policies β€” what they are, how they work, and how to use them for compliance and automation. | Andrew Baker | 2022‑05‑20 | +| [What are Fleet policies?](https://fleetdm.com/articles/what-are-fleet-policies) | An introduction to Fleet policies - what they are, how they work, and how to use them for compliance and automation. | Andrew Baker | 2022‑05‑20 | | [End-user self remediation: empower your employees to fix security issues](https://fleetdm.com/articles/end-user-self-remediation) | How Fleet Desktop enables end users to self-remediate failing policies without IT intervention. | Chris McGillicuddy | 2022‑12‑15 | | [Labels in Fleet](https://fleetdm.com/guides/managing-labels-in-fleet) | Learn how to use labels in Fleet to organize and target hosts for policies, queries, and software. | Noah Talerman | 2025‑10‑24 | | [View and manage MDM configuration profile status](https://fleetdm.com/guides/configuration-profile-status) | Learn how to view and manage the status of MDM configuration profiles across your Fleet-enrolled devices. | Gabe Hernandez | 2025‑05‑26 | @@ -482,21 +482,33 @@ ExpedITioners is Fleet's podcast, hosted by Zach Wasserman. Episodes feature gue | Episode | Guest & role | Description | Date | | --- | --- | --- | --- | -| [Huxley Barbee: The modern divergence of environments and security methodologies](https://expeditioners.podbean.com/e/huxley-barbee-the-modern-divergence-of-environments-and-security-methodologies/) | Huxley Barbee β€” Security Evangelist at RunZero; organizer of BSides NYC | The modern divergence of environments and security methodologies, improving the industry for newcomers, and comprehensive security tooling. | 2024‑01‑30 | -| [Marcus Ransom: The positive future of collaboration between vendors and Apple for enterprise](https://expeditioners.podbean.com/e/marcus-ransom-the-positive-future-of-collaboration-between-vendors-and-apple-for-enterprise/) | Marcus Ransom β€” Sales Engineer at Jamf; co-host of the Mac Admins Podcast | The exciting future of Apple for enterprise, vendor collaboration, and the MacAdmin community that supports it. | 2023‑12‑11 | -| [Jeff Chao: Configuration as code for efficiency and automation](https://expeditioners.podbean.com/e/jeff-chao-configuration-as-code-for-efficiency-and-automation/) | Jeff Chao β€” Co-Founder & CTO at Abbey Labs | Implementing configuration as code for access management, efficiency, and automation in modern engineering teams. | 2023‑11‑15 | -| [Charles Edge: The past, present, and future of all things computing and device management](https://expeditioners.podbean.com/e/charles-edge-the-past-present-and-future-of-all-things-computing-and-device-management/) | Charles Edge β€” "Old School Mac Guy"; host of the MacAdmins Podcast | The history and future of computing and device management, and what the MacAdmin community can expect next. | 2023‑10‑23 | -| [John Reynolds: Rehumanizing interactions between IT and end users](https://expeditioners.podbean.com/e/john-reynolds-rehumanizing-interactions-between-it-and-end-users/) | John Reynolds β€” IT professional and community advocate | How IT teams can rehumanize their interactions with end users and build better workplace technology experiences. | 2023‑09‑21 | -| [Rich Trouton: Declarative Device Management and a promising future for Mac Admins](https://expeditioners.podbean.com/e/rich-trouton-declarative-device-management-and-a-promising-future-for-mac-admins/) | Rich Trouton β€” IT Technology Services Expert at SAP; author of Der Flounder blog | Apple's Declarative Device Management (DDM) framework and what it means for the future of Mac administration. | 2023‑08‑31 | -| [Niels Hofmans: Threat modeling, open-source collaboration, and bug bounties](https://expeditioners.podbean.com/e/niels-hofmans-threat-modeling-open-source-collaboration-and-bug-bounties/) | Niels Hofmans β€” Head of Security at Intigriti | Threat modeling methodologies, open-source security collaboration, and the bug bounty ecosystem across 90,000+ security researchers. | 2023‑08‑18 | -| [Bradley Chambers: The bright future and golden era of macOS](https://expeditioners.podbean.com/e/bradley-chambers-the-bright-future-and-golden-era-of-macos/) | Bradley Chambers β€” Enterprise technology writer for 9to5Mac; author of Apple @ Work | Why macOS is entering a golden era for enterprise, and how Apple's platform strategy is reshaping workplace IT. | 2023‑07‑14 | -| [Mat X β€” Organizing community insights for industry growth with MacAdmin tools](https://expeditioners.podbean.com/e/mat-x-organizing-community-insights-for-industry-growth-with-macadmin-tools/) | Mat X β€” Storage, workflow, and broadcast systems engineer in film & VFX | How the MacAdmin community can better organize and share knowledge to accelerate industry growth. | 2022‑11‑03 | -| [Whitney Champion β€” Scaling infrastructure automation with open source tools](https://expeditioners.podbean.com/e/ep-6-whitney-champion-scaling-infrastructure-automation-with-open-source-tools/) | Whitney Champion β€” Infrastructure and open-source automation engineer | Scaling infrastructure automation using open source tools and the culture of contributing back to the community. | 2022‑09‑23 | -| [Jesse Peterson β€” Open source communities for better MDM and career growth](https://expeditioners.podbean.com/e/jesse-peterson-open-source-communities-for-better-mdm-and-career-growth/) | Jesse Peterson β€” Client Platform Engineer at Meta; creator of NanoMDM; contributor to MicroMDM | How open-source communities drive better MDM tooling and create career growth opportunities for contributors. | 2022‑09‑01 | -| [Nick Anderson β€” Endpoint security for osquery](https://expeditioners.podbean.com/e/ep-4-nick-anderson-endpoint-security-for-osquery/) | Nick Anderson β€” Security Engineer at Meta; osquery Technical Steering Committee member | How osquery enables robust endpoint security and detection at scale in large enterprise environments. | 2022‑08‑12 | -| [Chris Long β€” From osquery skeptic to believer](https://expeditioners.podbean.com/e/ep-3-chris-long-from-osquery-sceptic-to-believer/) | Chris Long β€” Staff Security Engineer at Material Security; creator of Detection Lab | The journey from skepticism to conviction on osquery, and how Detection Lab accelerates security team onboarding. | 2022‑07‑21 | -| [Prima Virani β€” Improving endpoint monitoring and visibility with osquery](https://expeditioners.podbean.com/e/prima-virani-improving-endpoint-monitoring-and-visibility-with-osquery/) | Prima Virani β€” Detection & Response Engineering Lead at Twilio | Using osquery to improve endpoint monitoring, detection, and security visibility across a distributed fleet. | 2022‑06‑28 | -| [Mike Arpaia β€” The story behind the creation of osquery](https://expeditioners.podbean.com/e/ep-1-mike-arpaia-the-story-behind-the-creation-of-osquery/) | Mike Arpaia β€” Co-creator of osquery; Partner at Moonfire Ventures | The origin story of osquery, the philosophy behind its design, and its lasting impact on endpoint security and observability. | 2022‑06‑02 | +| [Huxley Barbee: The modern divergence of environments and security methodologies](https://expeditioners.podbean.com/e/huxley-barbee-the-modern-divergence-of-environments-and-security-methodologies/) | Huxley Barbee - Security Evangelist at RunZero; organizer of BSides NYC | The modern divergence of environments and security methodologies, improving the industry for newcomers, and comprehensive security tooling. | 2024‑01‑30 | +| [Marcus Ransom: The positive future of collaboration between vendors and Apple for enterprise](https://expeditioners.podbean.com/e/marcus-ransom-the-positive-future-of-collaboration-between-vendors-and-apple-for-enterprise/) | Marcus Ransom - Sales Engineer at Jamf; co-host of the Mac Admins Podcast | The exciting future of Apple for enterprise, vendor collaboration, and the MacAdmin community that supports it. | 2023‑12‑11 | +| [Jeff Chao: Configuration as code for efficiency and automation](https://expeditioners.podbean.com/e/jeff-chao-configuration-as-code-for-efficiency-and-automation/) | Jeff Chao - Co-Founder & CTO at Abbey Labs | Implementing configuration as code for access management, efficiency, and automation in modern engineering teams. | 2023‑11‑15 | +| [Charles Edge: The past, present, and future of all things computing and device management](https://expeditioners.podbean.com/e/charles-edge-the-past-present-and-future-of-all-things-computing-and-device-management/) | Charles Edge - "Old School Mac Guy"; host of the MacAdmins Podcast | The history and future of computing and device management, and what the MacAdmin community can expect next. | 2023‑10‑23 | +| [John Reynolds: Rehumanizing interactions between IT and end users](https://expeditioners.podbean.com/e/john-reynolds-rehumanizing-interactions-between-it-and-end-users/) | John Reynolds - IT professional and community advocate | How IT teams can rehumanize their interactions with end users and build better workplace technology experiences. | 2023‑09‑21 | +| [Rich Trouton: Declarative Device Management and a promising future for Mac Admins](https://expeditioners.podbean.com/e/rich-trouton-declarative-device-management-and-a-promising-future-for-mac-admins/) | Rich Trouton - IT Technology Services Expert at SAP; author of Der Flounder blog | Apple's Declarative Device Management (DDM) framework and what it means for the future of Mac administration. | 2023‑08‑31 | +| [Niels Hofmans: Threat modeling, open-source collaboration, and bug bounties](https://expeditioners.podbean.com/e/niels-hofmans-threat-modeling-open-source-collaboration-and-bug-bounties/) | Niels Hofmans - Head of Security at Intigriti | Threat modeling methodologies, open-source security collaboration, and the bug bounty ecosystem across 90,000+ security researchers. | 2023‑08‑18 | +| [Bradley Chambers: The bright future and golden era of macOS](https://expeditioners.podbean.com/e/bradley-chambers-the-bright-future-and-golden-era-of-macos/) | Bradley Chambers - Enterprise technology writer for 9to5Mac; author of Apple @ Work | Why macOS is entering a golden era for enterprise, and how Apple's platform strategy is reshaping workplace IT. | 2023‑07‑14 | +| [Mat X - Organizing community insights for industry growth with MacAdmin tools](https://expeditioners.podbean.com/e/mat-x-organizing-community-insights-for-industry-growth-with-macadmin-tools/) | Mat X - Storage, workflow, and broadcast systems engineer in film & VFX | How the MacAdmin community can better organize and share knowledge to accelerate industry growth. | 2022‑11‑03 | +| [Whitney Champion - Scaling infrastructure automation with open source tools](https://expeditioners.podbean.com/e/ep-6-whitney-champion-scaling-infrastructure-automation-with-open-source-tools/) | Whitney Champion - Infrastructure and open-source automation engineer | Scaling infrastructure automation using open source tools and the culture of contributing back to the community. | 2022‑09‑23 | +| [Jesse Peterson - Open source communities for better MDM and career growth](https://expeditioners.podbean.com/e/jesse-peterson-open-source-communities-for-better-mdm-and-career-growth/) | Jesse Peterson - Client Platform Engineer at Meta; creator of NanoMDM; contributor to MicroMDM | How open-source communities drive better MDM tooling and create career growth opportunities for contributors. | 2022‑09‑01 | +| [Nick Anderson - Endpoint security for osquery](https://expeditioners.podbean.com/e/ep-4-nick-anderson-endpoint-security-for-osquery/) | Nick Anderson - Security Engineer at Meta; osquery Technical Steering Committee member | How osquery enables robust endpoint security and detection at scale in large enterprise environments. | 2022‑08‑12 | +| [Chris Long - From osquery skeptic to believer](https://expeditioners.podbean.com/e/ep-3-chris-long-from-osquery-sceptic-to-believer/) | Chris Long - Staff Security Engineer at Material Security; creator of Detection Lab | The journey from skepticism to conviction on osquery, and how Detection Lab accelerates security team onboarding. | 2022‑07‑21 | +| [Prima Virani - Improving endpoint monitoring and visibility with osquery](https://expeditioners.podbean.com/e/prima-virani-improving-endpoint-monitoring-and-visibility-with-osquery/) | Prima Virani - Detection & Response Engineering Lead at Twilio | Using osquery to improve endpoint monitoring, detection, and security visibility across a distributed fleet. | 2022‑06‑28 | +| [Mike Arpaia - The story behind the creation of osquery](https://expeditioners.podbean.com/e/ep-1-mike-arpaia-the-story-behind-the-creation-of-osquery/) | Mike Arpaia - Co-creator of osquery; Partner at Moonfire Ventures | The origin story of osquery, the philosophy behind its design, and its lasting impact on endpoint security and observability. | 2022‑06‑02 | + + +## Press Coverage + +| Publication | Headline | Journalist | Date | +|---|---|---|---| +| CRN | [Five Companies That Came To Win This Week](https://www.crn.com/news/channel-news/2026/five-companies-that-came-to-win-this-week-april-17-2026) | Rick Whiting | 2026‑04‑20 | +| Cyber Defense Wire | [Fleet Announces New Partner Program and Names MobileIron Co-founder Suresh Batchu to Board](https://cyberdefensewire.com/fleet-announces-new-partner-program-and-names-mobileiron-co-founder-suresh-batchu-to-board/) | Staff | 2026‑04‑17 | +| Channele2e | [Channel Brief: MSP Growth Is Getting Harder to Win](https://www.channele2e.com/news/channel-brief-its-less-about-tools-more-about-running-them) | Suparna Chawla Bhasin | 2026‑04‑17 | +| CRN | [Fleet Launches Inaugural Partner Program As It Adopts A 100 Percent Channel Sales Model: Exclusive](https://www.crn.com/news/channel-news/2026/fleet-launches-inaugural-partner-program-as-it-adopts-a-100-percent-channel-sales-model-exclusive) | Rick Whiting | 2026‑04‑16 | +| Channelvision | [Fleet Launches Partner Program for its Device Management](https://channelvisionmag.com/fleet-launches-partner-program-for-its-device-management/) | Martin Vilaboy | 2026‑04‑16 | +| Apple Must | [Fleet launches partner program, appoints MobileIron co-founder to its board](https://www.applemust.com/fleet-launches-partner-program-appoints-mobileiron-co-founder-to-its-board/) | Jonny Evans | 2026‑04‑16 | ## Release notes diff --git a/handbook/marketing/marketing-responsibilities.md b/handbook/marketing/marketing-responsibilities.md index 1177a54e4c..c23250aa55 100644 --- a/handbook/marketing/marketing-responsibilities.md +++ b/handbook/marketing/marketing-responsibilities.md @@ -13,7 +13,7 @@ This page outlines the different roles in the marketing team and the DRIs execut | Technical product marketing and content specialist (Consultant) | [Dan Gordon](https://www.linkedin.com/in/dangordon/) | [@danbgordon](https://github.com/danbgordon) | Create core technical marketing strategy, positioning, messaging and assets for IT technical teams Market Fleet releases Create, drive and manage analyst relations for Fleet | | Product marketing (Consultant) | [Erin Miska](https://www.linkedin.com/in/erinmiska/) | [*@miskaek*](https://github.com/miskaek) | Positioning and messaging Leadership marketing facing assets, Sales and partner enablement Content strategy | | Social media strategy and management (Consultant) | [Thomas Basgil Jr.](https://www.linkedin.com/in/tombasgil/) | [*@tombasgil*](https://github.com/tombasgil) | Establish, manage and grow Fleet’s social media presence across all appropriate channels. Monitor and respond to comments on company page posts (e.g., LinkedIn); comments on tracked posts are surfaced in the [#_linkedin-comments-from-tracked-posts](https://fleetdm.slack.com/archives/C0AP1FM3ES2) Slack channel | -| Public relations (Consultant) | [TBD] | | Establish Fleet PR program Identify and train key Fleet employees on PR interactions Establish, measure and improve Fleet share of voice with press, analysts, and media. Manage Fleet submissions for industry awards | +| Public relations (Consultant) | [Alyssa Pallotti](https://www.linkedin.com/in/alyssapallotti/) | | Establish Fleet AR & PR program Identify and train key Fleet employees on AR & PR interactions Establish, measure and improve Fleet share of voice with press, analysts, and media. Manage Fleet submissions for industry awards | diff --git a/server/activity/internal/service/endpoint_utils.go b/server/activity/internal/service/endpoint_utils.go index ec69b85897..6cc8364e4f 100644 --- a/server/activity/internal/service/endpoint_utils.go +++ b/server/activity/internal/service/endpoint_utils.go @@ -144,6 +144,12 @@ func (e *endpointer) Service() any { func newUserAuthenticatedEndpointer(svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption, r *mux.Router, versions ...string, ) *eu.CommonEndpointer[handlerFunc] { + // Append RouteTemplateRequestFunc so the api_only endpoint middleware + // can read the matched mux route template from context. + // + // Full-slice expression prevents aliasing into the caller's backing array + // if it happens to have spare capacity. + opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(eu.RouteTemplateRequestFunc)) return &eu.CommonEndpointer[handlerFunc]{ EP: &endpointer{ svc: svc, diff --git a/server/api_endpoints/api_endpoints.go b/server/api_endpoints/api_endpoints.go index 30c857c61a..1b280a21bf 100644 --- a/server/api_endpoints/api_endpoints.go +++ b/server/api_endpoints/api_endpoints.go @@ -15,6 +15,8 @@ var apiEndpointsYAML []byte var apiEndpoints []fleet.APIEndpoint +var apiEndpointsSet map[string]struct{} + // GetAPIEndpoints returns a copy of the embedded API endpoints slice. func GetAPIEndpoints() []fleet.APIEndpoint { result := make([]fleet.APIEndpoint, len(apiEndpoints)) @@ -22,6 +24,12 @@ func GetAPIEndpoints() []fleet.APIEndpoint { return result } +// IsInCatalog reports whether the given endpoint fingerprint is in the catalog. +func IsInCatalog(fingerprint string) bool { + _, ok := apiEndpointsSet[fingerprint] + return ok +} + func Init(h http.Handler) error { r, ok := h.(*mux.Router) if !ok { @@ -63,6 +71,12 @@ func Init(h http.Handler) error { apiEndpoints = loadedApiEndpoints + set := make(map[string]struct{}, len(loadedApiEndpoints)) + for _, e := range loadedApiEndpoints { + set[e.Fingerprint()] = struct{}{} + } + apiEndpointsSet = set + return nil } diff --git a/server/fleet/api_endpoints.go b/server/fleet/api_endpoints.go index 0fbae9a280..5042b7c893 100644 --- a/server/fleet/api_endpoints.go +++ b/server/fleet/api_endpoints.go @@ -68,7 +68,7 @@ func (e *APIEndpoint) normalize() { // Fingerprint return a string that uniquely identifies // the APIEndpoint func (e APIEndpoint) Fingerprint() string { - return fmt.Sprintf("|%s|%s|", e.Method, e.NormalizedPath) + return "|" + e.Method + "|" + e.NormalizedPath + "|" } func (e APIEndpoint) validate() error { diff --git a/server/mdm/android/service/endpoint_utils.go b/server/mdm/android/service/endpoint_utils.go index b919edeec1..73b18c102a 100644 --- a/server/mdm/android/service/endpoint_utils.go +++ b/server/mdm/android/service/endpoint_utils.go @@ -55,6 +55,9 @@ func (e *androidEndpointer) Service() any { func newUserAuthenticatedEndpointer(fleetSvc fleet.Service, svc android.Service, opts []kithttp.ServerOption, r *mux.Router, versions ...string, ) *eu.CommonEndpointer[handlerFunc] { + // Full-slice expression prevents aliasing into the caller's backing array + // if it happens to have spare capacity. + opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(auth.RouteTemplateRequestFunc)) return &eu.CommonEndpointer[handlerFunc]{ EP: &androidEndpointer{ svc: svc, @@ -63,7 +66,7 @@ func newUserAuthenticatedEndpointer(fleetSvc fleet.Service, svc android.Service, EncodeFn: encodeResponse, Opts: opts, AuthMiddleware: func(next endpoint.Endpoint) endpoint.Endpoint { - return auth.AuthenticatedUser(fleetSvc, next) + return auth.AuthenticatedUser(fleetSvc, auth.APIOnlyEndpointCheck(next)) }, Router: r, Versions: versions, diff --git a/server/platform/endpointer/route_template.go b/server/platform/endpointer/route_template.go new file mode 100644 index 0000000000..4c01ab3710 --- /dev/null +++ b/server/platform/endpointer/route_template.go @@ -0,0 +1,42 @@ +package endpointer + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" +) + +// contextKeyRouteTemplate is the context key type for the mux route template. +type contextKeyRouteTemplate struct{} + +var routeTemplateKey = contextKeyRouteTemplate{} + +// RouteTemplateRequestFunc captures the gorilla/mux route template for the +// matched request and stores it in the context. +func RouteTemplateRequestFunc(ctx context.Context, r *http.Request) context.Context { + route := mux.CurrentRoute(r) + if route == nil { + return ctx + } + tpl, err := route.GetPathTemplate() + if err != nil { + // Only happens when a route has no path, which Fleet never registers. + return ctx + } + return context.WithValue(ctx, routeTemplateKey, tpl) +} + +// RouteTemplateFromContext returns the mux route template stored by +// RouteTemplateRequestFunc. Returns "" and false if no template is in context. +func RouteTemplateFromContext(ctx context.Context) (string, bool) { + tpl, ok := ctx.Value(routeTemplateKey).(string) + return tpl, ok +} + +// WithRouteTemplate returns a new context with the given route template value. +// Intended for tests that need to simulate what RouteTemplateRequestFunc would +// have stored without running a real mux router. +func WithRouteTemplate(ctx context.Context, tpl string) context.Context { + return context.WithValue(ctx, routeTemplateKey, tpl) +} diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 68e8d35b1a..5de949eb9a 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -136,6 +136,9 @@ func (e *fleetEndpointer) Service() any { func newUserAuthenticatedEndpointer(svc fleet.Service, opts []kithttp.ServerOption, r *mux.Router, versions ...string, ) *eu.CommonEndpointer[handlerFunc] { + // Full-slice expression prevents aliasing into the caller's backing array + // if it happens to have spare capacity. + opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(auth.RouteTemplateRequestFunc)) return &eu.CommonEndpointer[handlerFunc]{ EP: &fleetEndpointer{ svc: svc, @@ -144,7 +147,7 @@ func newUserAuthenticatedEndpointer(svc fleet.Service, opts []kithttp.ServerOpti EncodeFn: encodeResponse, Opts: opts, AuthMiddleware: func(next endpoint.Endpoint) endpoint.Endpoint { - return auth.AuthenticatedUser(svc, next) + return auth.AuthenticatedUser(svc, auth.APIOnlyEndpointCheck(next)) }, Router: r, Versions: versions, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 8158d6b5ed..e5950ae553 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -535,12 +535,15 @@ func (s *integrationTestSuite) TestModifyAPIOnlyUser() { "name": "New Name", }, http.StatusUnprocessableEntity) - // An API-only user cannot modify their own record via this endpoint. + // An API-only user cannot reach this admin endpoint: the api_only middleware + // rejects it at the catalog check (the user-management endpoint is not in the catalog). + // + // This is to protect against privilege escalation vulnerability. s.token = apiUserToken defer func() { s.token = s.getTestAdminToken() }() s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ "name": "Self Update", - }, http.StatusUnprocessableEntity) + }, http.StatusForbidden) s.token = s.getTestAdminToken() s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4840d0ff2d..9a0b1486bb 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28985,3 +28985,73 @@ func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { require.False(t, foundRegular.APIOnly) require.Empty(t, foundRegular.APIEndpoints) } + +func (s *integrationEnterpriseTestSuite) TestAPIOnlyUserEndpointMiddleware() { + t := s.T() + + defer func() { s.token = s.getTestAdminToken() }() + + createAPIOnlyUser := func(name string, endpoints []map[string]any) string { + prev := s.token + s.token = s.getTestAdminToken() + defer func() { s.token = prev }() + + body := map[string]any{ + "name": name, + "global_role": "observer", + } + if endpoints != nil { + body["api_endpoints"] = endpoints + } + var createResp struct { + Token string `json:"token"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", body, http.StatusOK, &createResp) + require.NotEmpty(t, createResp.Token) + return createResp.Token + } + + // With no endpoint restrictions the user can reach any endpoint in the catalog. + t.Run("no restrictions allows all catalog endpoints", func(t *testing.T) { + s.token = createAPIOnlyUser("api-only-mw-no-restrictions", nil) + + s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK) + }) + + // Paths not registered in the API endpoint catalog are always rejected for + // api-only users, regardless of whether they have endpoint restrictions. + t.Run("non-catalog path is rejected", func(t *testing.T) { + s.token = createAPIOnlyUser("api-only-mw-non-catalog-unrestricted", nil) + s.Do("PATCH", "/api/latest/fleet/users/api_only/1", map[string]any{"name": "x"}, http.StatusForbidden) + + s.token = createAPIOnlyUser("api-only-mw-non-catalog-restricted", []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/version"}, + }) + s.Do("PATCH", "/api/latest/fleet/users/api_only/1", map[string]any{"name": "x"}, http.StatusForbidden) + }) + + // With endpoint restrictions, only explicitly allowed endpoints are reachable. + t.Run("endpoint restrictions limit access to the allowed list", func(t *testing.T) { + s.token = createAPIOnlyUser("api-only-mw-restricted", []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/version"}, + }) + + // The only allowed endpoint returns 200. + s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK) + + // These are in the catalog but not in the user's allow list. + s.Do("GET", "/api/latest/fleet/config", nil, http.StatusForbidden) + s.Do("GET", "/api/latest/fleet/me", nil, http.StatusForbidden) + }) + + // Non-api-only users must not be affected by the middleware at all. + t.Run("non-api-only user is unaffected", func(t *testing.T) { + s.token = s.getTestAdminToken() + + s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK) + }) +} diff --git a/server/service/middleware/auth/api_only.go b/server/service/middleware/auth/api_only.go new file mode 100644 index 0000000000..d92c1f84a6 --- /dev/null +++ b/server/service/middleware/auth/api_only.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + + apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints" + "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + eu "github.com/fleetdm/fleet/v4/server/platform/endpointer" + "github.com/go-kit/kit/endpoint" + kithttp "github.com/go-kit/kit/transport/http" +) + +// RouteTemplateRequestFunc captures the gorilla/mux route template for the +// matched request and stores it in the context. Alias of the platform +// implementation, re-exported so callers that already import this package can +// continue to reference it here. +var RouteTemplateRequestFunc = eu.RouteTemplateRequestFunc + +// APIOnlyEndpointCheck returns an endpoint.Endpoint middleware that enforces +// access control for API-only users (api_only=true). It must be wired inside +// AuthenticatedUser (so a Viewer is already in context when it runs) and the +// enclosing transport must register RouteTemplateRequestFunc as a ServerBefore +// option so the mux route template is available in context. +// +// For non-API-only users the check is skipped entirely. When there is no Viewer +// in context, the call passes through β€” AuthenticatedUser guarantees that any +// request that needs a Viewer has already been rejected before reaching here. +// +// For API-only users two checks are applied in order: +// 1. The requested route must appear in the API endpoint catalog. If not, a +// permission error (403) is returned. +// 2. If the user has configured endpoint restrictions (rows in +// user_api_endpoints), the route must match one of them. If not, a +// permission error (403) is returned. An empty restriction list grants +// full access to all catalog endpoints. +func APIOnlyEndpointCheck(next endpoint.Endpoint) endpoint.Endpoint { + return apiOnlyEndpointCheck(apiendpoints.IsInCatalog, next) +} + +func apiOnlyEndpointCheck(isInCatalog func(string) bool, next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, request any) (any, error) { + v, ok := viewer.FromContext(ctx) + if !ok || v.User == nil || !v.User.APIOnly { + return next(ctx, request) + } + + requestMethod, _ := ctx.Value(kithttp.ContextKeyRequestMethod).(string) + routeTemplate, _ := eu.RouteTemplateFromContext(ctx) + + fp := fleet.NewAPIEndpointFromTpl(requestMethod, routeTemplate).Fingerprint() + + if !isInCatalog(fp) { + return nil, permissionDenied(ctx) + } + + // No endpoint restrictions: full access to all catalog endpoints. + if len(v.User.APIEndpoints) == 0 { + return next(ctx, request) + } + + // Check whether the requested endpoint matches any of the user's allowed endpoints. + for _, ep := range v.User.APIEndpoints { + if fleet.NewAPIEndpointFromTpl(ep.Method, ep.Path).Fingerprint() == fp { + return next(ctx, request) + } + } + + return nil, permissionDenied(ctx) + } +} + +func permissionDenied(ctx context.Context) error { + if ac, ok := authz.FromContext(ctx); ok { + ac.SetChecked() + } + return fleet.NewPermissionError("forbidden") +} diff --git a/server/service/middleware/auth/api_only_test.go b/server/service/middleware/auth/api_only_test.go new file mode 100644 index 0000000000..853af0dd1d --- /dev/null +++ b/server/service/middleware/auth/api_only_test.go @@ -0,0 +1,337 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + eu "github.com/fleetdm/fleet/v4/server/platform/endpointer" + kithttp "github.com/go-kit/kit/transport/http" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" +) + +// muxVersionSegment is the gorilla/mux route template version segment that +// RouteTemplateRequestFunc would extract from a real mux router. +const muxVersionSegment = "/api/{fleetversion:(?:v1|2022-04|latest)}/" + +// testCatalogEndpoints is the minimal set of endpoints used across tests. +var testCatalogEndpoints = []fleet.APIEndpoint{ + fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts"), + fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts/:id"), + fleet.NewAPIEndpointFromTpl("POST", "/api/v1/fleet/scripts/run"), +} + +// testIsInCatalog builds a fingerprint set from testCatalogEndpoints and +// returns an isInCatalog func suitable for injection into apiOnlyEndpointCheck. +func testIsInCatalog() func(string) bool { + set := make(map[string]struct{}, len(testCatalogEndpoints)) + for _, ep := range testCatalogEndpoints { + set[ep.Fingerprint()] = struct{}{} + } + return func(fp string) bool { + _, ok := set[fp] + return ok + } +} + +// muxTemplate returns a gorilla/mux route template for the given path suffix, simulating +// what RouteTemplateRequestFunc would extract from mux.CurrentRoute(r).GetPathTemplate(). +func muxTemplate(pathSuffix string) string { + return muxVersionSegment + pathSuffix +} + +func TestAPIOnlyEndpointCheck(t *testing.T) { + newNext := func() (func(context.Context, any) (any, error), *bool) { + called := false + fn := func(ctx context.Context, request any) (any, error) { + called = true + return nil, nil + } + return fn, &called + } + + newEndpoint := func(next func(context.Context, any) (any, error)) func(context.Context, any) (any, error) { + return apiOnlyEndpointCheck(testIsInCatalog(), next) + } + + ctxWithMethod := func(method, tpl string) context.Context { + ctx := context.Background() + ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, method) + ctx = eu.WithRouteTemplate(ctx, tpl) + return ctx + } + + t.Run("non-api-only user always passes through", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: false}}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("no viewer in context passes through", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts")) + // no viewer set + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user, endpoint in catalog, no restrictions", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: nil, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user, empty APIEndpoints slice treated same as nil (no restrictions)", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{}, // empty, not nil + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user, endpoint with placeholder in catalog, no restrictions", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts/{id:[0-9]+}")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: nil, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user, endpoint not in catalog", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/secret_admin_endpoint")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user, missing route template in context is rejected", func(t *testing.T) { + next, called := newNext() + // routeTemplateKey deliberately not set (simulates RouteTemplateRequestFunc failure). + ctx := context.Background() + ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, "GET") + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user, missing method and template are both rejected", func(t *testing.T) { + next, called := newNext() + // Neither method nor template set β€” empty fingerprint never matches catalog. + ctx := context.Background() + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user, method normalization is case-insensitive", func(t *testing.T) { + // Lower-case method must normalize to the same fingerprint as upper-case. + next, called := newNext() + ctx := ctxWithMethod("get", muxTemplate("fleet/hosts")) // lower-case + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: nil, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user, rejection marks authz context as checked", func(t *testing.T) { + // Ensures authzcheck middleware does not emit a spurious "Missing + // authorization check" log when we deny an api_only user. + next, called := newNext() + ac := &authzctx.AuthorizationContext{} + ctx := authzctx.NewContext(context.Background(), ac) + ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, "GET") + ctx = eu.WithRouteTemplate(ctx, muxTemplate("fleet/secret_admin_endpoint")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + require.True(t, ac.Checked(), "authz context must be marked checked on denial") + }) + + t.Run("api-only user with restrictions, accessing allowed endpoint", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/hosts"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user with restrictions, accessing allowed placeholder endpoint", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts/{id:[0-9]+}")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + // Stored path uses colon-prefix style as in the YAML catalog. + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/hosts/:id"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) + + t.Run("api-only user with restrictions, accessing disallowed endpoint", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("POST", muxTemplate("fleet/scripts/run")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/hosts"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user, allow-list entry for non-catalog endpoint is still denied", func(t *testing.T) { + // The catalog check runs before the allow-list check; an explicit allow entry + // must not grant access to an endpoint that is not in the catalog. + next, called := newNext() + ctx := ctxWithMethod("GET", muxTemplate("fleet/secret_admin_endpoint")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/secret_admin_endpoint"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user, wrong method for catalog endpoint is rejected at catalog step", func(t *testing.T) { + // POST /fleet/hosts is not in the catalog (only GET is), so the catalog check rejects it. + next, called := newNext() + ctx := ctxWithMethod("POST", muxTemplate("fleet/hosts")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/hosts"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.Error(t, err) + require.False(t, *called) + var permErr *fleet.PermissionError + require.ErrorAs(t, err, &permErr) + }) + + t.Run("api-only user with multiple allowed endpoints, accessing one of them", func(t *testing.T) { + next, called := newNext() + ctx := ctxWithMethod("POST", muxTemplate("fleet/scripts/run")) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{ + {Method: "GET", Path: "/api/v1/fleet/hosts"}, + {Method: "POST", Path: "/api/v1/fleet/scripts/run"}, + }, + }}) + + _, err := newEndpoint(next)(ctx, nil) + require.NoError(t, err) + require.True(t, *called) + }) +} + +func TestRouteTemplateRequestFunc(t *testing.T) { + // Register a route and route the request through mux so mux.CurrentRoute + // returns a non-nil value, mirroring what happens in production. + newServedRequest := func(t *testing.T, routeTpl, reqPath string) (context.Context, bool) { + t.Helper() + var ( + got context.Context + wasMatch bool + ) + r := mux.NewRouter() + r.HandleFunc(routeTpl, func(_ http.ResponseWriter, req *http.Request) { + wasMatch = true + got = RouteTemplateRequestFunc(req.Context(), req) + }).Methods("GET") + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", reqPath, nil) + r.ServeHTTP(rec, req) + return got, wasMatch + } + + t.Run("stores the matched route template", func(t *testing.T) { + ctx, matched := newServedRequest(t, "/api/v1/fleet/hosts/{id:[0-9]+}", "/api/v1/fleet/hosts/42") + require.True(t, matched, "expected route to be matched") + tpl, ok := eu.RouteTemplateFromContext(ctx) + require.True(t, ok, "route template must be stored in context") + require.Equal(t, "/api/v1/fleet/hosts/{id:[0-9]+}", tpl) + }) + + t.Run("no matched route leaves context unchanged", func(t *testing.T) { + // Call RouteTemplateRequestFunc directly with a request that never went + // through a mux router, so mux.CurrentRoute returns nil. + req := httptest.NewRequest("GET", "/whatever", nil) + ctx := context.Background() + got := RouteTemplateRequestFunc(ctx, req) + _, ok := eu.RouteTemplateFromContext(got) + require.False(t, ok, "no route template should be stored when no route is matched") + }) +} diff --git a/website/.claude/CLAUDE.md b/website/.claude/CLAUDE.md new file mode 100644 index 0000000000..45a4a91d9d --- /dev/null +++ b/website/.claude/CLAUDE.md @@ -0,0 +1,265 @@ +# CLAUDE.md β€” Fleet Website + +This file provides guidance to Claude Code (claude.ai/code) when working with code in the `website/` folder. + +## About + +Sails.js 1.5.17 web application. Node.js 20+, PostgreSQL, EJS templates, Vue.js/Parasails frontend, LESS styles, Grunt build system. + +## Architecture + +``` +api/ +β”œβ”€β”€ controllers/ # Sails Actions2 controllers, organized by feature +β”œβ”€β”€ models/ # Waterline ORM models (User, Subscription, Quote, etc.) +β”œβ”€β”€ helpers/ # Reusable logic, organized by domain (stripe/, salesforce/, ai/, etc.) +β”œβ”€β”€ policies/ # Auth middleware (is-logged-in, is-super-admin, etc.) +β”œβ”€β”€ responses/ # Custom response handlers (unauthorized, expired, badConfig) +└── hooks/custom/ # Server initialization, security headers, globals +assets/ +β”œβ”€β”€ js/ +β”‚ β”œβ”€β”€ components/ # Vue/Parasails components (*.component.js) +β”‚ β”œβ”€β”€ pages/ # Page scripts (parasails.registerPage) +β”‚ └── utilities/ # Shared utilities (parasails.registerUtility) +└── styles/ # LESS stylesheets +views/ +β”œβ”€β”€ layouts/ # EJS layout templates +β”œβ”€β”€ pages/ # Page templates +β”œβ”€β”€ partials/ # Reusable template fragments +└── emails/ # Email templates +config/ +β”œβ”€β”€ routes.js # All route definitions +β”œβ”€β”€ policies.js # Route-to-policy mappings +β”œβ”€β”€ custom.js # App settings (API keys, TTLs, feature flags) +└── local.js # Local overrides (not committed) +``` + +## Backend conventions + +### Controllers & helpers +Both use the Sails Actions2 machine format (`friendlyName`, `inputs`, `exits`, `fn`). Call helpers with `await sails.helpers.domain.name.with({...})`. Throw exit names (e.g., `throw 'notFound'`) to trigger non-success exits. + +### Models (Waterline ORM) +Declarative attribute schemas in `api/models/`. Use `protect: true` for sensitive fields (passwords, tokens). + +### Routes +All in `config/routes.js`. Webhooks need `csrf: false`. + +### Authentication +- Session-based: `req.session.userId` +- Logged-in user auto-hydrated as `req.me` +- Policies in `config/policies.js` control access; `'*': 'is-logged-in'` is the default + +### Configuration +- `config/custom.js` β€” app settings, integration keys, feature flags +- `config/local.js` β€” local dev overrides (not committed, not deployed) +- `config/env/production.js` β€” production overrides +- Sensitive credentials go in `config/local.js` or environment variables, never in committed config + +## Frontend conventions + +### Data flow from controllers to pages +Values returned by a page's view action (e.g., `api/controllers/view-pricing.js`) are sent to the page in the `data` object. In page scripts, they're available on `this` (e.g., `this.pricingTable`). In templates: +- **EJS** (`<%- pricingTable %>`) β€” for server-side rendering of data from the view action +- **Vue** (`{{pricingTable}}`) β€” for values that change based on user interaction (filters, toggles, etc.) + +Use EJS when the data is static from the server. Use Vue templates when the value is reactive and updated by page methods. + +### Reusable components +Several Parasails components are used across multiple pages: +- `` β€” testimonial carousel. Requires `testimonialsForScrollableTweets` data from the view action (see Testimonials below). +- `` β€” animated city skyline banner, used at the bottom of landing pages. Must sit at the top level of the page, outside `page-container`/`page-content`, so it can span the full viewport width with no padding. Typically appears just after a `bottom-gradient` section. See `views/pages/landing-pages/linux-management.ejs` for the full end-of-page structure. +- `` β€” rotating customer logo strip, typically placed in hero sections. +- `` β€” modal dialog. Control visibility with `v-if="modal === 'modal-name'"` and `@close="closeModal()"`. Commonly used for video embeds. + +#### Video modal pattern +Landing pages typically include a "See Fleet in action" video button. The pattern requires: +1. Page script: add `modal: ''` to `data`, plus `clickOpenVideoModal` and `closeModal` methods +2. Template: add a `` with a YouTube iframe +3. LESS: include responsive video modal styles (see `assets/styles/pages/landing-pages/linux-management.less` for reference) + +#### Testimonials +Testimonials are defined in `handbook/company/testimonials.yml` and compiled into `sails.config.builtStaticContent.testimonials`. Each has `quote`, `quoteAuthorName`, `quoteAuthorJobTitle`, `productCategories` (e.g., `Device management`, `Observability`, `Software management`), and optional media fields. + +View actions that use `` must filter/sort testimonials and return them as `testimonialsForScrollableTweets`. See `api/controllers/landing-pages/view-linux-management.js` for the pattern. + +### Cloud SDK (API calls) +Frontend-to-backend API calls use `Cloud.*` methods, invoked by the `ajax-form` component or via a page script's `handleSubmitting` function. Each Cloud method maps to a backend action. After adding or renaming an action, regenerate the SDK: +```bash +sails run rebuild-cloud-sdk +``` + +### Ajax forms +Form submission uses `` β€” either `action="cloudMethodName"` for simple cases or `:handle-submitting="fn"` for custom logic. State props (`syncing`, `cloudError`, `formErrors`) use `.sync`. See `views/pages/contact.ejs` for a full example; see `assets/js/components/ajax-form.component.js` for supported validation rules. + +### Global browser variables +`parasails`, `Cloud`, `io`, `_` (Lodash), `$` (jQuery), `moment`, `bowser`, `Vue`, `Stripe`, `gtag`, `ace` + +### Image naming +Images in `assets/images/` follow the pattern: `{category}-{descriptor}-{css-dimensions}@2x.{extension}` + +The dimensions in the filename are CSS pixels (half the actual pixel resolution). For example, a 32x32 pixel image used at 16x16 CSS pixels: +``` +icon-checkmark-green-16x16@2x.png +``` + +## CSS/LESS conventions + +### Preprocessor & build +LESS compiled via Grunt. Single entry point: `assets/styles/importer.less` imports everything. New `.less` files must be `@import`ed in `importer.less` to take effect. + +### Selector convention +**Use `[purpose='name']` attribute selectors** β€” this is the primary styling approach, not traditional CSS classes: +```less +// In EJS template: +//
...
+ +// In LESS: +[purpose='hero-container'] { + padding: 80px 0; +} +``` +Nest `[purpose]` selectors to scope styles within a section. Traditional CSS classes are secondary β€” used only for Bootstrap utilities and state toggles (`.truncated`, `.expanded`, `.loading-spinner`). + +### Page-level scoping +Each page stylesheet is scoped to a page ID selector at the root: +```less +#pricing { + // All page-specific styles nested inside + [purpose='page-content'] { ... } + [purpose='hero-text'] { ... } + + @media (max-width: 991px) { ... } +} +``` +This prevents style leakage between pages. The page ID matches the `id` attribute on the page's outermost `
` in the EJS template. + +Some pages use a `-page` suffix (e.g., `#software-management-page` instead of `#software-management`). This is done when the base name would collide with an auto-generated heading ID β€” for example, markdown articles with a "Software management" heading get `id="software-management"` automatically. Add the `-page` suffix when the page name could conflict with a heading ID elsewhere on the site. + +### Variables and mixins +All colors, fonts, weights, and mixins live in `mixins-and-variables/`. Always use variable names instead of raw hex (e.g., `@core-fleet-black` not `#192147`). Common mixins: `.page-container()`, `.page-content()`, `.btn-reset()`, `.fade-in()`. + +Don't use `@core-vibrant-blue` in new code β€” it's deprecated. + +Primary CTA buttons should use the `btn btn-primary` Bootstrap classes β€” this adds pseudo-element shine effects on hover (defined in `bootstrap-overrides.less`). The default color is `@core-vibrant-green` but can be overridden per page; the key benefit is the shine, not the color. + +### Page backgrounds +Pages don't set their own section backgrounds. The page background is a gradient defined in `layout.less` and overridden per-page. Pages with a `` footer typically end with a dedicated `bottom-gradient` section just before the component. + +### Responsive breakpoints +Max-width media queries, typically nested inside the page's root ID selector: +```less +#my-page { + // Desktop styles at root level + + @media (max-width: 1199px) { /* large desktop adjustments */ } + @media (max-width: 991px) { /* tablet: cards stack, padding reduces */ } + @media (max-width: 767px) { /* mobile: single column, smaller text */ } + @media (max-width: 575px) { /* small mobile: minimal padding */ } + @media (max-width: 375px) { /* extra small: final adjustments */ } +} +``` + +### Framework +Bootstrap 4 is loaded as a base dependency. Global overrides live in `bootstrap-overrides.less`, page-specific overrides should be scoped inside the page's ID selector. + +Avoid using Bootstrap utility classes (`.d-flex`, `.justify-content-center`, `.flex-column`, etc.) for layout and display properties. Define these styles in the LESS stylesheet using `[purpose]` selectors instead β€” this keeps all styles in one place and makes them easier to adjust later. Bootstrap's grid (`.row`, `.col-*`) is acceptable where already established, but prefer stylesheet-defined layout for new work. + +### Browser compatibility + +The website enforces minimum browser versions via a [bowser](https://github.com/lancedikson/bowser) check in `views/layouts/layout.ejs` (around line 970). Visitors on unsupported browsers see a full-page block prompting them to upgrade. These floors were chosen to enable modern CSS features β€” notably the flexbox/grid `gap` property. + +**Minimum supported versions** (source of truth: `layout.ejs`): + +| Browser | Min version | Notes | +|---------|------------|-------| +| Chrome | 84 | `gap` support | +| Edge | 84 | `gap` support | +| Opera | 70 | `gap` support | +| Safari | 14 | `gap` support | +| Firefox | 103 | `backdrop-filter` support | +| iOS | 14 | Supports embedded podcast player | +| Android | 6 | Google's search crawler user agent | + +Internet Explorer is blocked entirely. + +**What's safe to use**: +- Flexbox and CSS Grid, including `gap` on both +- `backdrop-filter` +- CSS custom properties (variables) β€” supported everywhere above IE +- Modern ES2017+ JavaScript (async/await, object spread, etc.) + +**What to be cautious with**: +- Container queries β€” Safari 14 does not support them; need to fall back to media queries or wait to raise the floor +- `:has()` selector β€” Safari 14 does not support it +- Any CSS feature newer than ~2021 β€” check [caniuse.com](https://caniuse.com) against the table above + +**Manual QA**: Per the [handbook](https://fleetdm.com/handbook/engineering#check-browser-compatibility-for-fleetdm-com), cross-browser checks are done monthly via BrowserStack. Google Chrome (macOS) latest is the baseline; other supported browsers are checked against it. File issues as bugs and assign for fixing. + +**Raising or lowering the floor**: Update the `LATEST_SUPPORTED_VERSION_BY_USER_AGENT` and `LATEST_SUPPORTED_VERSION_BY_OS` objects in `views/layouts/layout.ejs`. Add a comment explaining *why* that version was chosen (which CSS/JS feature it enables), matching the existing pattern. + +### LESS formatting rules (from `.lesshintrc`) +- One space before `{` β€” `[purpose='hero'] {` not `[purpose='hero']{` +- One space after `:` in properties β€” `padding: 16px` not `padding:16px` +- Avoid `!important` β€” if unavoidable, add `//lesshint-disable-line importantRule` on the same line +- No strict property ordering enforced +- Zero warnings policy β€” `npm run lint` must pass with zero lesshint warnings + +## Markdown content pipeline + +### Source files +Markdown content lives outside the `website/` directory in three top-level folders: +- `docs/` β€” technical documentation +- `articles/` β€” blog posts, case studies, whitepapers, comparisons +- `handbook/` β€” internal company handbook + +### Build process +The build script `scripts/build-static-content.js` compiles markdown to HTML: +```bash +sails run build-static-content # compile markdown β†’ EJS partials +npm run build-for-prod # full production build (includes above + asset minification) +npm run start-dev # dev mode (runs build-static-content then starts console) +``` +Compiled output lands in `views/partials/built-from-markdown/`; metadata is exposed at runtime as `sails.config.builtStaticContent`. + +### Metadata +Embedded as HTML `` tags in the markdown file (not YAML frontmatter). See existing files in each folder for the required tags per content type. + +### Custom syntax +- `((bubble-text))` β€” converts to `` elements +- Blockquotes β€” automatically rendered with `purpose="tip"` styling +- Code blocks β€” language-specific highlighting (`js`, `bash`, `yaml`, `mermaid`, etc.) +- Checklists β€” `- [x]` and `- [ ]` syntax renders as checkboxes + +### Restrictions +The build script enforces several rules and will throw errors for: +- Vue template syntax (`{{ }}`) outside code blocks (conflicts with client-side Vue) +- Relative markdown links without `.md` extension +- `@fleetdm.com` email addresses in markdown +- Missing required meta tags per content type + +## Creating new pages +Use `sails generate page /` β€” scaffolds controller, view, page script, and LESS file. Then: add the route in `config/routes.js`, add the LESS `@import` to `importer.less`, and update `config/policies.js` if bypassing `is-logged-in` (not needed under folders like `landing-pages/` that already bypass it). + +## Code style + +- **Indentation**: 2 spaces +- **Quotes**: Single quotes (template literals allowed) +- **Semicolons**: Always required +- **Equality**: Strict only (`===` / `!==`) +- **Variables/functions**: camelCase +- **Files/directories**: kebab-case + +## Development commands + +```bash +npm run start-dev # Start dev server with live reload +npm run lint # Run ESLint + HTMLHint + lesshint +npm run build-for-prod # Compile markdown, build and minify assets +``` + +## Linting + +- **JS**: ESLint (`.eslintrc` at root, browser override in `assets/.eslintrc`) +- **HTML/EJS**: HTMLHint (`.htmlhintrc`) +- **LESS**: lesshint (`.lesshintrc`) β€” zero warnings policy diff --git a/website/assets/images/testimonial-author-thomas-luebker-48x48@2x.png b/website/assets/images/testimonial-author-thomas-luebker-48x48@2x.png new file mode 100644 index 0000000000..8fd35d800d Binary files /dev/null and b/website/assets/images/testimonial-author-thomas-luebker-48x48@2x.png differ