From 4d0534a48deb85ee8016fd68157a3b0923637986 Mon Sep 17 00:00:00 2001 From: George Karr Date: Mon, 18 May 2026 16:05:44 -0500 Subject: [PATCH] Adding my device link on host details page with backend logic to generate it if needed (#45659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #43895 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [ ] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed ## Database migrations - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [ ] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [ ] Verified that any relevant UI is disabled when GitOps mode is enabled ## fleetd/orbit/Fleet Desktop - [ ] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [ ] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [ ] Verified that fleetd runs on macOS, Linux and Windows - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) ## Summary by CodeRabbit * **New Features** * Self-service install/uninstall activities now render in passive voice, omit an actor name, and include a “(self-service)” marker across feeds * Global admins see a "My device" button on host user cards that opens the end‑user device page in a new tab; the link is refreshed/generated as needed * Device page and browser tab header show the end‑user's name when available (fallback: "My device") * **Tests** * Added/updated coverage for self‑service activity rendering and the "My device" flow [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45659?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- changes/43895-host-my-device-link | 3 + docs/Contributing/reference/audit-logs.md | 17 ++ frontend/interfaces/software.ts | 47 ++++++ .../GlobalActivityItem.tests.tsx | 18 +- .../GlobalActivityItem/GlobalActivityItem.tsx | 50 +++++- .../details/DeviceUserPage/DeviceUserPage.tsx | 8 +- .../HostDetailsPage/HostDetailsPage.tsx | 34 ++++ .../InstalledSoftwareActivityItem.tsx | 37 +++- .../details/cards/HostHeader/HostHeader.tsx | 8 +- .../hosts/details/cards/User/User.tests.tsx | 19 +++ .../pages/hosts/details/cards/User/User.tsx | 38 +++-- .../hosts/details/cards/User/_styles.scss | 6 + frontend/services/entities/hosts.ts | 10 ++ frontend/utilities/endpoints.ts | 2 + server/datastore/mysql/hosts.go | 39 +++++ server/fleet/activities.go | 13 ++ server/fleet/datastore.go | 12 ++ server/fleet/service.go | 11 ++ server/mock/datastore_mock.go | 24 +++ server/mock/service/service_mock.go | 12 ++ server/service/handler.go | 1 + server/service/hosts.go | 158 ++++++++++++++++++ server/service/integration_core_test.go | 90 ++++++++++ 23 files changed, 623 insertions(+), 34 deletions(-) create mode 100644 changes/43895-host-my-device-link diff --git a/changes/43895-host-my-device-link b/changes/43895-host-my-device-link new file mode 100644 index 0000000000..30d25c3d94 --- /dev/null +++ b/changes/43895-host-my-device-link @@ -0,0 +1,3 @@ +- Added a "My device" button on the host details User card so global admins can open the host's end-user My device page in a new tab; Fleet refreshes or generates the device auth token as needed so the link is always valid. +- Showed the end user's IdP full name (e.g. "Jane Doe's device") on the My device page header and browser tab when available; falls back to "My device" otherwise. +- Updated self-service activity copy to passive voice without an "end user" actor (e.g. "GitHub Desktop was installed on this host (self-service).") on both the host activity feed and the dashboard global activity feed. diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index f0ca2d1533..25006b5904 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -773,6 +773,23 @@ This activity contains the following fields: } ``` +## retrieved_host_my_device_url + +Generated when a global admin retrieves a host's "My device" page URL (a credential-bearing link that opens the end user's device page). Fleet logs this for every retrieval, including reuse of an existing token. + +This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro" +} +``` + ## created_macos_profile Generated when a user adds a new macOS profile to a team (or no team). diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 385cd02386..f4a1573010 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -775,6 +775,53 @@ export const getInstallUninstallStatusPredicate = ( ); }; +// Passive-voice variants used for self-service activity rendering, where the +// activity reads " was installed on this host (self-service)." with +// no actor. +const INSTALL_STATUS_PREDICATES_PASSIVE: Record< + EnhancedSoftwareInstallUninstallStatus | "pending", + string +> = { + pending: "is pending", + installed: "was installed", + uninstalled: "was uninstalled", + pending_install: "is pending install", + failed_install: "installation failed", + pending_uninstall: "is pending uninstall", + failed_uninstall: "uninstallation failed", + ran_script: "was run", + failed_script: "run failed", + pending_script: "is pending run", +} as const; + +export const getInstallUninstallStatusPredicatePassive = ( + status: string | undefined, + isScriptPackage = false +) => { + if (!status) { + return INSTALL_STATUS_PREDICATES_PASSIVE.pending; + } + + if (isScriptPackage) { + switch (status.toLowerCase()) { + case "installed": + return INSTALL_STATUS_PREDICATES_PASSIVE.ran_script; + case "pending_install": + return INSTALL_STATUS_PREDICATES_PASSIVE.pending_script; + case "failed_install": + return INSTALL_STATUS_PREDICATES_PASSIVE.failed_script; + default: + break; + } + } + + return ( + INSTALL_STATUS_PREDICATES_PASSIVE[ + status.toLowerCase() as keyof typeof INSTALL_STATUS_PREDICATES_PASSIVE + ] || INSTALL_STATUS_PREDICATES_PASSIVE.pending + ); +}; + export const aggregateInstallStatusCounts = ( packageStatuses: ISoftwarePackage["status"] ) => ({ diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 0421e99486..7002cae6f6 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -1708,19 +1708,24 @@ describe("Activity Feed", () => { expect(screen.getByText("Test Admin")).toBeInTheDocument(); }); - it("renders the correct actor for a installed_software activity that was self_service", () => { + it("renders self_service installed_software in passive voice without an actor", () => { const activity = createMockActivity({ type: ActivityType.InstalledSoftware, actor_id: 1, + actor_full_name: "Test Admin", details: { software_title: "Foo Software", self_service: true, host_display_name: "Foo Host", + status: "installed", }, }); render(); - expect(screen.getByText("An end user")).toBeInTheDocument(); + expect(screen.queryByText("An end user")).toBeNull(); + expect(screen.queryByText("Test Admin")).toBeNull(); + expect(screen.getByText(/was installed on/)).toBeInTheDocument(); + expect(screen.getByText(/\(self-service\)\./)).toBeInTheDocument(); }); it("renders the correct actor for a installed_app_store_app activity without self_service", () => { @@ -1738,19 +1743,24 @@ describe("Activity Feed", () => { expect(screen.getByText("Test Admin")).toBeInTheDocument(); }); - it("renders the correct actor for a installed_app_store_app activity that was self_service", () => { + it("renders self_service installed_app_store_app in passive voice without an actor", () => { const activity = createMockActivity({ type: ActivityType.InstalledAppStoreApp, actor_id: 1, + actor_full_name: "Test Admin", details: { software_title: "Foo Software", self_service: true, host_display_name: "Foo Host", + status: "installed", }, }); render(); - expect(screen.getByText("An end user")).toBeInTheDocument(); + expect(screen.queryByText("An end user")).toBeNull(); + expect(screen.queryByText("Test Admin")).toBeNull(); + expect(screen.getByText(/was installed on/)).toBeInTheDocument(); + expect(screen.getByText(/\(self-service\)\./)).toBeInTheDocument(); }); it("renders script package ran status in InstalledSoftware activity", () => { diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 59cce29899..98e0c1c51d 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -15,6 +15,7 @@ import { } from "interfaces/platform"; import { getInstallUninstallStatusPredicate, + getInstallUninstallStatusPredicatePassive, SCRIPT_PACKAGE_SOURCES, } from "interfaces/software"; import { @@ -1458,6 +1459,7 @@ const TAGGED_TEMPLATES = { software_title: title, status, source, + self_service, from_setup_experience, } = details; @@ -1465,6 +1467,27 @@ const TAGGED_TEMPLATES = { !!details.software_package && activity.type === ActivityType.InstalledSoftware; const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || ""); + + // Self-service actions: drop the actor and switch to passive voice so the + // sentence reads " was installed on <host> (self-service)." without + // misattributing the action. + if (self_service) { + return ( + <> + {" "} + <b>{title}</b> + {showSoftwarePackage && ` (${details.software_package})`}{" "} + {getInstallUninstallStatusPredicatePassive( + status, + isScriptPackageSource + )}{" "} + on <b>{hostName}</b> + {from_setup_experience ? " during setup experience" : ""}{" "} + (self-service). + </> + ); + } + return ( <> {" "} @@ -1482,7 +1505,11 @@ const TAGGED_TEMPLATES = { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } - const { host_display_name: hostName, software_title: title } = details; + const { + host_display_name: hostName, + software_title: title, + self_service, + } = details; const status = details.status === "failed" ? "failed_uninstall" : details.status; @@ -1490,6 +1517,18 @@ const TAGGED_TEMPLATES = { !!details.software_package && activity.type === ActivityType.InstalledSoftware; + if (self_service) { + return ( + <> + {" "} + <b>{title}</b> + {showSoftwarePackage && ` (${details.software_package})`}{" "} + {getInstallUninstallStatusPredicatePassive(status)} on{" "} + <b>{hostName}</b> (self-service). + </> + ); + } + return ( <> {" "} @@ -2559,11 +2598,10 @@ const GlobalActivityItem = ({ case ActivityType.InstalledSoftware: case ActivityType.UninstalledSoftware: case ActivityType.InstalledAppStoreApp: - return activity.details?.self_service ? ( - <span>An end user</span> - ) : ( - DEFAULT_ACTOR_DISPLAY - ); + // Self-service activities render as a passive-voice sentence in the + // template (e.g. "<title> was installed on <host> (self-service).") + // without an actor prefix. + return activity.details?.self_service ? null : DEFAULT_ACTOR_DISPLAY; // these activities have more complicated logic to // determine if we display the actor name so we will handle that in the // template function diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 253405eb59..f4ca833e22 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -522,10 +522,13 @@ const DeviceUserPage = ({ } }; + const idpFullName = host?.end_users?.[0]?.idp_full_name; + const pageHeader = idpFullName ? `${idpFullName}'s device` : "My device"; + // Updates title that shows up on browser tabs useEffect(() => { - document.title = `My device | ${DOCUMENT_TITLE_SUFFIX}`; - }, [location.pathname, host]); + document.title = `${pageHeader} | ${DOCUMENT_TITLE_SUFFIX}`; + }, [location.pathname, host, pageHeader]); const renderActionButtons = () => { return ( @@ -715,6 +718,7 @@ const DeviceUserPage = ({ onRefetchHost={onRefetchHost} renderActionsDropdown={renderActionButtons} deviceUser + deviceUserHeader={pageHeader} /> <TabNav className={`${baseClass}__tab-nav`}> <Tabs diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 4fa80b756e..35da42a547 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1070,6 +1070,24 @@ const HostDetailsPage = ({ } }; + const onClickMyDevice = async () => { + if (!host) return; + try { + const { device_url } = await hostAPI.getDeviceURL(host.id); + // window.open returns null when the browser blocks the popup, so an + // explicit check is needed — the promise will not reject. + const opened = window.open(device_url, "_blank", "noopener,noreferrer"); + if (!opened) { + renderFlash( + "error", + "Couldn't open My device page. Please allow pop-ups and try again." + ); + } + } catch (e) { + renderFlash("error", "Couldn't open My device page. Please try again."); + } + }; + const onUpdateEndUser = async (username: string) => { setIsUpdating(true); try { @@ -1236,6 +1254,13 @@ const HostDetailsPage = ({ isHostTeamMaintainer || isHostTeamTechnician); + // "My device" link points to that host's end-user My device page. The URL + // embeds the device auth token so it acts as a credential, hence global + // admin only. The endpoint guarantees a valid link on every fetch — it + // refreshes an expired token or generates one for a host that has never + // had one — so we don't gate visibility on orbit/MDM state. + const canViewMyDeviceLink = isGlobalAdmin; + const showSoftwareLibraryTab = isPremiumTier; const showReportsEmptyState = host.mdm?.enrollment_status === "Pending"; const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; @@ -1498,6 +1523,7 @@ const HostDetailsPage = ({ isGlobalAdmin || isGlobalMaintainer } + canViewMyDeviceLink={canViewMyDeviceLink} onClickUpdateUser={( e: | React.MouseEvent<HTMLButtonElement> @@ -1506,6 +1532,14 @@ const HostDetailsPage = ({ e.preventDefault(); setShowUpdateEndUserModal(true); }} + onClickMyDevice={( + e: + | React.MouseEvent<HTMLButtonElement> + | React.KeyboardEvent<HTMLButtonElement> + ) => { + e.preventDefault(); + onClickMyDevice(); + }} /> <LabelsCard className={defaultCardClass} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index c36067b57f..68ba9fec6e 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -2,6 +2,7 @@ import React from "react"; import { getInstallUninstallStatusPredicate, + getInstallUninstallStatusPredicatePassive, SCRIPT_PACKAGE_SOURCES, } from "interfaces/software"; @@ -30,11 +31,30 @@ const InstalledSoftwareActivityItem = ({ details.status === "failed" ? "failed_uninstall" : details.status; const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || ""); - const actorDisplayName = self_service ? ( - <span>End user</span> - ) : ( - <b>{actorName ?? "Fleet"}</b> - ); + // Self-service installs/uninstalls can be triggered by anyone who opens the + // host's My device page, including admins. Drop the actor and switch to + // passive voice so the activity reads "<software> was installed on this + // host (self-service)." without misrepresenting who initiated it. + if (self_service) { + const passivePrefix = getInstallUninstallStatusPredicatePassive( + status, + isScriptPackageSource + ); + return ( + <ActivityItem + className={baseClass} + activity={activity} + hideCancel={hideCancel} + onShowDetails={onShowDetails} + onCancel={onCancel} + isSoloActivity={isSoloActivity} + > + <b>{title}</b> {passivePrefix} on this host + {from_setup_experience ? " during setup experience" : ""} (self-service) + . + </ActivityItem> + ); + } let installedSoftwarePrefix = getInstallUninstallStatusPredicate( status, @@ -54,10 +74,9 @@ const InstalledSoftwareActivityItem = ({ onCancel={onCancel} isSoloActivity={isSoloActivity} > - <>{actorDisplayName}</> {installedSoftwarePrefix} <b>{title}</b> on this - host - {from_setup_experience ? " during setup experience" : ""} - {self_service && " (self-service)"}. + <b>{actorName ?? "Fleet"}</b> {installedSoftwarePrefix} <b>{title}</b> on + this host + {from_setup_experience ? " during setup experience" : ""}. </ActivityItem> ); }; diff --git a/frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx b/frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx index 5413c97e9d..8c84d3bb3e 100644 --- a/frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx +++ b/frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx @@ -73,6 +73,9 @@ interface IHostSummaryProps { ) => void; renderActionsDropdown: () => JSX.Element | null; deviceUser?: boolean; + /** Optional override for the title shown when `deviceUser` is true. + * Falls back to "My device" if not provided. */ + deviceUserHeader?: string; hostMdmDeviceStatus?: HostMdmDeviceStatusUIState; hostMdmEnrollmentStatus?: MdmEnrollmentStatus; } @@ -83,6 +86,7 @@ const HostHeader = ({ onRefetchHost, renderActionsDropdown, deviceUser, + deviceUserHeader, hostMdmDeviceStatus, hostMdmEnrollmentStatus, }: IHostSummaryProps) => { @@ -186,7 +190,7 @@ const HostHeader = ({ disableTooltip={!isTruncated} tipContent={ deviceUser - ? "My device" + ? deviceUserHeader || "My device" : summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE } underline={false} @@ -195,7 +199,7 @@ const HostHeader = ({ > <h1 className="display-name" ref={hostDisplayName}> {deviceUser - ? "My device" + ? deviceUserHeader || "My device" : summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE} </h1> </TooltipWrapper> diff --git a/frontend/pages/hosts/details/cards/User/User.tests.tsx b/frontend/pages/hosts/details/cards/User/User.tests.tsx index fe65d038b1..40c52eb6d8 100644 --- a/frontend/pages/hosts/details/cards/User/User.tests.tsx +++ b/frontend/pages/hosts/details/cards/User/User.tests.tsx @@ -47,6 +47,25 @@ describe("User card", () => { }); }); + describe("My device button", () => { + it("does not render the 'My device' button by default", () => { + render(<User endUsers={[]} onClickUpdateUser={noop} />); + expect(screen.queryByText("My device")).toBeNull(); + }); + + it("renders the 'My device' button when canViewMyDeviceLink is true", () => { + render( + <User + endUsers={[]} + canViewMyDeviceLink + onClickMyDevice={noop} + onClickUpdateUser={noop} + /> + ); + expect(screen.getByText("My device")).toBeInTheDocument(); + }); + }); + it("renders the chrome profiles field when has chrome profile values", () => { const endUsers = [ createMockHostEndUser({ diff --git a/frontend/pages/hosts/details/cards/User/User.tsx b/frontend/pages/hosts/details/cards/User/User.tsx index 7d662065fb..af38693726 100644 --- a/frontend/pages/hosts/details/cards/User/User.tsx +++ b/frontend/pages/hosts/details/cards/User/User.tsx @@ -27,6 +27,7 @@ interface IUserProps { /** There will be at most 1 end user */ endUsers: IHostEndUser[]; canWriteEndUser?: boolean; + canViewMyDeviceLink?: boolean; disableFullNameTooltip?: boolean; disableGroupsTooltip?: boolean; className?: string; @@ -35,15 +36,22 @@ interface IUserProps { | React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement> ) => void; + onClickMyDevice?: ( + e: + | React.MouseEvent<HTMLButtonElement> + | React.KeyboardEvent<HTMLButtonElement> + ) => void; } const User = ({ endUsers, canWriteEndUser = false, + canViewMyDeviceLink = false, disableFullNameTooltip = false, disableGroupsTooltip = false, className, onClickUpdateUser, + onClickMyDevice, }: IUserProps) => { const classNames = classnames(baseClass, className); @@ -73,17 +81,25 @@ const User = ({ > <div className={`${baseClass}__header`}> <CardHeader header="User" /> - {canWriteEndUser && ( - <Button - className={`${baseClass}__add-user-btn`} - variant="inverse" - onClick={onClickUpdateUser} - size="small" - > - <Icon name={writeButtonIcon} /> - {writeButtonText} - </Button> - )} + <div className={`${baseClass}__header-actions`}> + {canViewMyDeviceLink && ( + <Button variant="inverse" onClick={onClickMyDevice} size="small"> + My device + <Icon name="external-link" /> + </Button> + )} + {canWriteEndUser && ( + <Button + className={`${baseClass}__add-user-btn`} + variant="inverse" + onClick={onClickUpdateUser} + size="small" + > + {writeButtonText} + <Icon name={writeButtonIcon} /> + </Button> + )} + </div> </div> <div className={`${baseClass}__content`}> diff --git a/frontend/pages/hosts/details/cards/User/_styles.scss b/frontend/pages/hosts/details/cards/User/_styles.scss index ff235d9f1d..62ee0e49f7 100644 --- a/frontend/pages/hosts/details/cards/User/_styles.scss +++ b/frontend/pages/hosts/details/cards/User/_styles.scss @@ -7,6 +7,12 @@ justify-content: space-between; } + &__header-actions { + display: flex; + align-items: center; + gap: $pad-small; + } + &__content { display: inline-grid; gap: $pad-medium $pad-xxlarge; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index b0e368f10e..4c65ce601d 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -256,6 +256,11 @@ export interface IGetHostCertsRequestParams extends IListOptions { host_id: number; } +export interface IGetHostDeviceURLResponse { + host_id: number; + device_url: string; +} + export interface IGetHostCertificatesResponse { certificates: IHostCertificate[]; meta: { @@ -671,6 +676,11 @@ export default { return sendRequest("POST", HOST_RECOVERY_LOCK_PASSWORD_ROTATE(id)); }, + getDeviceURL: (id: number): Promise<IGetHostDeviceURLResponse> => { + const { HOST_DEVICE_URL } = endpoints; + return sendRequest("GET", HOST_DEVICE_URL(id)); + }, + getManagedAccountPassword: (id: number) => { const { HOST_MANAGED_ACCOUNT_PASSWORD } = endpoints; return sendRequest("GET", HOST_MANAGED_ACCOUNT_PASSWORD(id)); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 90e5e68088..6cf2abd3e5 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -202,6 +202,8 @@ export default { `/${API_VERSION}/fleet/hosts/${id}/recovery_lock_password`, HOST_RECOVERY_LOCK_PASSWORD_ROTATE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/recovery_lock_password/rotate`, + HOST_DEVICE_URL: (id: number) => + `/${API_VERSION}/fleet/hosts/${id}/device_url`, HOST_MANAGED_ACCOUNT_PASSWORD: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/managed_account_password`, HOST_MANAGED_LOCAL_ACCOUNT_ROTATE: (id: number) => diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 4f2ddfd86d..3c9c4c3bd3 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -3022,6 +3022,45 @@ func (ds *Datastore) SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint return nil } +// HostIDByDeviceAuthToken returns the host_id of the row owning the given +// token, ignoring the TTL. Used by HostDeviceURL's collision pre-check — +// the LoadHostByDeviceAuthToken path drops expired rows but the upsert in +// SetOrUpdateDeviceAuthToken still trips the UNIQUE constraint on them. +// Reads from the primary for read-after-write consistency with concurrent +// orbit token writes. +func (ds *Datastore) HostIDByDeviceAuthToken(ctx context.Context, authToken string) (uint, error) { + const stmt = `SELECT host_id FROM host_device_auth WHERE token = ?` //nolint:gosec // G101 false positive, this is a SQL query + + var hostID uint + switch err := sqlx.GetContext(ctx, ds.writer(ctx), &hostID, stmt, authToken); { + case err == nil: + return hostID, nil + case errors.Is(err, sql.ErrNoRows): + return 0, ctxerr.Wrap(ctx, notFound("Host")) + default: + return 0, ctxerr.Wrap(ctx, err, "look up host id by device auth token") + } +} + +// GetDeviceAuthTokenIfFresh returns the host's current device auth token +// only if its updated_at is within the given TTL. Returns a NotFoundError +// when the row is missing or the token would be rejected by +// LoadHostByDeviceAuthToken. Reads from the primary so admin-issued links +// see SetOrUpdate/orbit writes immediately (no replica-lag rotations). +func (ds *Datastore) GetDeviceAuthTokenIfFresh(ctx context.Context, hostID uint, tokenTTL time.Duration) (string, error) { + const stmt = `SELECT token FROM host_device_auth WHERE host_id = ? AND updated_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)` //nolint:gosec // G101 false positive, this is a SQL query + + var token string + switch err := sqlx.GetContext(ctx, ds.writer(ctx), &token, stmt, hostID, tokenTTL.Seconds()); { + case err == nil: + return token, nil + case errors.Is(err, sql.ErrNoRows): + return "", ctxerr.Wrap(ctx, notFound("Host")) + default: + return "", ctxerr.Wrap(ctx, err, "get fresh device auth token") + } +} + // GetDeviceAuthToken returns the current auth token for a given host. // Returns a NotFoundError if the host has no device auth token. func (ds *Datastore) GetDeviceAuthToken(ctx context.Context, hostID uint) (string, error) { diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 54c0bea142..d9031d1ca6 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -515,6 +515,19 @@ func (a ActivityTypeViewedHostRecoveryLockPassword) HostIDs() []uint { return []uint{a.HostID} } +type ActivityTypeRetrievedHostMyDeviceURL struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeRetrievedHostMyDeviceURL) ActivityName() string { + return "retrieved_host_my_device_url" +} + +func (a ActivityTypeRetrievedHostMyDeviceURL) HostIDs() []uint { + return []uint{a.HostID} +} + type ActivityTypeCreatedMacosProfile struct { ProfileName string `json:"profile_name"` ProfileIdentifier string `json:"profile_identifier"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index be3baad15e..ca212e70df 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -395,6 +395,18 @@ type Datastore interface { SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint, authToken string) error // GetDeviceAuthToken returns the current auth token for a given host GetDeviceAuthToken(ctx context.Context, hostID uint) (string, error) + // GetDeviceAuthTokenIfFresh returns the host's current device auth token + // only if it is still within the TTL window (i.e., it would resolve + // successfully via LoadHostByDeviceAuthToken). Returns a NotFoundError + // if the host has no token row or the existing token is expired. + GetDeviceAuthTokenIfFresh(ctx context.Context, hostID uint, tokenTTL time.Duration) (string, error) + // HostIDByDeviceAuthToken returns the host_id that owns the given device + // auth token, with no TTL filter. Used to defend against random-token + // collisions when minting a new token — unlike LoadHostByDeviceAuthToken + // this matches rows whose token is expired but still in the table, since + // expired rows still cause UNIQUE-key conflicts on the host_device_auth + // upsert. Returns a NotFoundError if no row owns the token. + HostIDByDeviceAuthToken(ctx context.Context, authToken string) (uint, error) // FailingPoliciesCount returns the number of failling policies for 'host' FailingPoliciesCount(ctx context.Context, host *Host) (uint, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index 569688295d..7d34976a8e 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -515,6 +515,17 @@ type Service interface { // Requires admin or maintainer role and MDM to be enabled. GetHostRecoveryLockPassword(ctx context.Context, hostID uint) (*HostRecoveryLockPassword, error) + // HostDeviceURL returns the full "My device" end-user URL for the + // specified host, embedding its device auth token. Global admin only — + // the URL is effectively a credential to that host's device-user page. + // If the host has no token, or its existing token is expired, a new + // token is generated on demand so the returned URL is always valid for + // the full TTL window. Each call also logs a + // ActivityTypeRetrievedHostMyDeviceURL admin activity for the audit + // trail. iOS and iPadOS hosts return a BadRequestError because device + // token authentication is not supported on those platforms. + HostDeviceURL(ctx context.Context, hostID uint) (string, error) + // ///////////////////////////////////////////////////////////////////////////// // AppConfigService provides methods for configuring the Fleet application diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1ea2023b35..5dba215ce5 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -303,6 +303,10 @@ type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, hostID uint, authT type GetDeviceAuthTokenFunc func(ctx context.Context, hostID uint) (string, error) +type GetDeviceAuthTokenIfFreshFunc func(ctx context.Context, hostID uint, tokenTTL time.Duration) (string, error) + +type HostIDByDeviceAuthTokenFunc func(ctx context.Context, authToken string) (uint, error) + type FailingPoliciesCountFunc func(ctx context.Context, host *fleet.Host) (uint, error) type ListPoliciesForHostFunc func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) @@ -2406,6 +2410,12 @@ type DataStore struct { GetDeviceAuthTokenFunc GetDeviceAuthTokenFunc GetDeviceAuthTokenFuncInvoked bool + GetDeviceAuthTokenIfFreshFunc GetDeviceAuthTokenIfFreshFunc + GetDeviceAuthTokenIfFreshFuncInvoked bool + + HostIDByDeviceAuthTokenFunc HostIDByDeviceAuthTokenFunc + HostIDByDeviceAuthTokenFuncInvoked bool + FailingPoliciesCountFunc FailingPoliciesCountFunc FailingPoliciesCountFuncInvoked bool @@ -5912,6 +5922,20 @@ func (s *DataStore) GetDeviceAuthToken(ctx context.Context, hostID uint) (string return s.GetDeviceAuthTokenFunc(ctx, hostID) } +func (s *DataStore) GetDeviceAuthTokenIfFresh(ctx context.Context, hostID uint, tokenTTL time.Duration) (string, error) { + s.mu.Lock() + s.GetDeviceAuthTokenIfFreshFuncInvoked = true + s.mu.Unlock() + return s.GetDeviceAuthTokenIfFreshFunc(ctx, hostID, tokenTTL) +} + +func (s *DataStore) HostIDByDeviceAuthToken(ctx context.Context, authToken string) (uint, error) { + s.mu.Lock() + s.HostIDByDeviceAuthTokenFuncInvoked = true + s.mu.Unlock() + return s.HostIDByDeviceAuthTokenFunc(ctx, authToken) +} + func (s *DataStore) FailingPoliciesCount(ctx context.Context, host *fleet.Host) (uint, error) { s.mu.Lock() s.FailingPoliciesCountFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 60a4a13750..184f8b49de 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -304,6 +304,8 @@ type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet. type GetHostRecoveryLockPasswordFunc func(ctx context.Context, hostID uint) (*fleet.HostRecoveryLockPassword, error) +type HostDeviceURLFunc func(ctx context.Context, hostID uint) (string, error) + type NewAppConfigFunc func(ctx context.Context, p fleet.AppConfig) (info *fleet.AppConfig, err error) type AppConfigObfuscatedFunc func(ctx context.Context) (info *fleet.AppConfig, err error) @@ -1353,6 +1355,9 @@ type Service struct { GetHostRecoveryLockPasswordFunc GetHostRecoveryLockPasswordFunc GetHostRecoveryLockPasswordFuncInvoked bool + HostDeviceURLFunc HostDeviceURLFunc + HostDeviceURLFuncInvoked bool + NewAppConfigFunc NewAppConfigFunc NewAppConfigFuncInvoked bool @@ -3283,6 +3288,13 @@ func (s *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint) return s.GetHostRecoveryLockPasswordFunc(ctx, hostID) } +func (s *Service) HostDeviceURL(ctx context.Context, hostID uint) (string, error) { + s.mu.Lock() + s.HostDeviceURLFuncInvoked = true + s.mu.Unlock() + return s.HostDeviceURLFunc(ctx, hostID) +} + func (s *Service) NewAppConfig(ctx context.Context, p fleet.AppConfig) (info *fleet.AppConfig, err error) { s.mu.Lock() s.NewAppConfigFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 75fa42bc1c..1acb05527e 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -486,6 +486,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates/{template_id:[0-9]+}/resend", resendHostCertificateTemplateEndpoint, resendHostCertificateTemplateRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/recovery_lock_password", getHostRecoveryLockPasswordEndpoint, getHostRecoveryLockPasswordRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_url", getHostDeviceURLEndpoint, getHostDeviceURLRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index fe210e7399..a990832fe4 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2461,6 +2461,164 @@ func (svc *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint return depAssignment, depDevice, nil } +//////////////////////////////////////////////////////////////////////////////// +// Get host device URL (end-user "My device" page link) +//////////////////////////////////////////////////////////////////////////////// + +type getHostDeviceURLRequest struct { + ID uint `url:"id"` +} + +type getHostDeviceURLResponse struct { + HostID uint `json:"host_id"` + DeviceURL string `json:"device_url"` + Err error `json:"error,omitempty"` +} + +func (r getHostDeviceURLResponse) Error() error { return r.Err } + +func getHostDeviceURLEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*getHostDeviceURLRequest) + url, err := svc.HostDeviceURL(ctx, req.ID) + if err != nil { + return getHostDeviceURLResponse{Err: err}, nil + } + return getHostDeviceURLResponse{HostID: req.ID, DeviceURL: url}, nil +} + +// hostDeviceAuthTokenTTL mirrors the TTL applied by LoadHostByDeviceAuthToken +// (server/service/devices.go) — any change there should match here so the +// generated link's lifetime stays consistent with the validator's window. +const hostDeviceAuthTokenTTL = time.Hour + +// HostDeviceURL returns the "My device" page URL for the given host. The +// returned URL is guaranteed to be valid for the full TTL window: +// - If the host already has a fresh token (within TTL), it's reused as-is +// so any link the end user is already holding keeps working. +// - If the host's token is expired or no row exists yet, Fleet generates +// a fresh token and upserts it via SetOrUpdateDeviceAuthToken. That +// helper's expiry-aware previous_token logic immediately invalidates +// stale links while still tolerating a concurrent orbit check-in +// during the brief transition window. +// +// Rejects iOS and iPadOS hosts up front because device-token authentication +// is unsupported on those platforms (AuthenticateDevice errors with +// "must use certificate authentication"), so an admin-issued URL could +// never load anyway. +// +// Every successful call also emits an ActivityTypeRetrievedHostMyDeviceURL +// admin activity so the audit trail records who minted the credential. +// +// The URL is effectively a credential to act as the device's end user, so +// access is restricted to global admins regardless of team-scoped +// permissions. +func (svc *Service) HostDeviceURL(ctx context.Context, hostID uint) (string, error) { + // First-pass authz so the middleware is satisfied; we apply a stricter + // global-admin check below. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return "", err + } + + vc, ok := viewer.FromContext(ctx) + if !ok { + return "", fleet.ErrNoContext + } + if vc.User == nil || vc.User.GlobalRole == nil || *vc.User.GlobalRole != fleet.RoleAdmin { + return "", fleet.NewPermissionError("only global admins can retrieve a host's device URL") + } + + // Confirm the host exists; 404 cleanly if not. + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "get host for device url") + } + + if host.Platform == "ios" || host.Platform == "ipados" { + return "", &fleet.BadRequestError{ + Message: "My device URL is not available for iOS or iPadOS hosts; those platforms use certificate authentication instead.", + } + } + + // Reuse the existing token if it's still within the TTL — saves us from + // invalidating a link a user may already be holding. + token, err := svc.ds.GetDeviceAuthTokenIfFresh(ctx, host.ID, hostDeviceAuthTokenTTL) + switch { + case err == nil: + // fresh token in hand + case fleet.IsNotFound(err): + // No token row, or existing token is expired. Generate a new one + // and upsert via the same helper orbit uses on its check-in. The + // host_device_auth.token column is UNIQUE — defend against the + // (vanishingly rare) random collision by checking for an existing + // row with the same token on a different host before upserting. + token, err = svc.mintHostDeviceAuthToken(ctx, host.ID) + if err != nil { + return "", err + } + default: + return "", ctxerr.Wrap(ctx, err, "check fresh device auth token") + } + + ac, err := svc.ds.AppConfig(ctx) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "get app config for server url") + } + + if err := svc.NewActivity( + ctx, + vc.User, + fleet.ActivityTypeRetrievedHostMyDeviceURL{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }, + ); err != nil { + return "", ctxerr.Wrap(ctx, err, "create activity for retrieved host my device url") + } + + base := strings.TrimRight(ac.ServerSettings.ServerURL, "/") + return fmt.Sprintf("%s/device/%s", base, token), nil +} + +// mintHostDeviceAuthToken generates a fresh URL-safe random token, defends +// against a token-uniqueness collision with another host (astronomically +// unlikely given the entropy but cheap to check), and upserts via +// SetOrUpdateDeviceAuthToken — the same helper orbit calls — so the +// previous_token transition-window semantics behave consistently with the +// agent path. +func (svc *Service) mintHostDeviceAuthToken(ctx context.Context, hostID uint) (string, error) { + // Two attempts is enough headroom: 24 random bytes is 192 bits of + // entropy, so a collision on the first try implies a second one is + // astronomically improbable. + const maxAttempts = 2 + for range maxAttempts { + newToken, err := server.GenerateRandomURLSafeText(24) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "generate new device auth token") + } + + // Pre-check: does this token already belong to a different host? + // host_device_auth.token is UNIQUE, so an upsert that hits another + // host's row would silently mangle that row (ON DUPLICATE KEY + // UPDATE suppresses the duplicate-key error). Use the TTL-agnostic + // lookup here — an expired-but-present row still trips the unique + // constraint on insert. + ownerHostID, lookupErr := svc.ds.HostIDByDeviceAuthToken(ctx, newToken) + switch { + case lookupErr == nil && ownerHostID != hostID: + // Collision with a different host — retry. + continue + case lookupErr != nil && !fleet.IsNotFound(lookupErr): + return "", ctxerr.Wrap(ctx, lookupErr, "check device auth token collision") + } + + if err := svc.ds.SetOrUpdateDeviceAuthToken(ctx, hostID, newToken); err != nil { + return "", ctxerr.Wrap(ctx, err, "set device auth token") + } + return newToken, nil + } + return "", ctxerr.New(ctx, "exhausted device auth token generation retries") +} + //////////////////////////////////////////////////////////////////////////////// // MDM //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 1f40f0c181..24d47f1a44 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -17205,3 +17205,93 @@ func (s *integrationTestSuite) TestOrbitDebugLoggingOnEnroll() { require.NoError(t, err) require.Nil(t, unstampedHost.OrbitDebugUntil) } + +func (s *integrationTestSuite) TestHostDeviceURL() { + t := s.T() + ctx := context.Background() + + // Pin the server URL so the assertion below is stable. Restore the original + // on cleanup so other tests aren't affected. + origAC, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + origServerURL := origAC.ServerSettings.ServerURL + // Trailing slash exercises the TrimRight in HostDeviceURL. + origAC.ServerSettings.ServerURL = "https://fleet.example.com/" + require.NoError(t, s.ds.SaveAppConfig(ctx, origAC)) + t.Cleanup(func() { + ac, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + ac.ServerSettings.ServerURL = origServerURL + require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) + }) + + const freshToken = "my-device-link-fresh-token" //nolint:gosec // G101 false positive, test fixture value + host := createOrbitEnrolledHost(t, "linux", "device-url-host", s.ds) + createDeviceTokenForHost(t, s.ds, host.ID, freshToken) + + retrievedActivity := fleet.ActivityTypeRetrievedHostMyDeviceURL{}.ActivityName() + expectedActivityDetails := fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()) + + // Case 1: host has a fresh token → it should be reused as-is. + var resp getHostDeviceURLResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID), nil, http.StatusOK, &resp) + require.Equal(t, host.ID, resp.HostID) + require.Equal(t, "https://fleet.example.com/device/"+freshToken, resp.DeviceURL) + // Each successful retrieval logs an audit activity tied to the host. + s.lastActivityOfTypeMatches(retrievedActivity, expectedActivityDetails, 0) + + // A second call also reuses the same token (still fresh). + var resp2 getHostDeviceURLResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID), nil, http.StatusOK, &resp2) + require.Equal(t, resp.DeviceURL, resp2.DeviceURL) + + // Case 2: host has a token but it's expired. Push updated_at into the + // past, then expect a freshly generated token (different from the stale + // one). + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE host_device_auth SET updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE host_id = ?`, host.ID) + return err + }) + + var respExpired getHostDeviceURLResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID), nil, http.StatusOK, &respExpired) + require.True(t, strings.HasPrefix(respExpired.DeviceURL, "https://fleet.example.com/device/"), "URL: %s", respExpired.DeviceURL) + require.NotEqual(t, resp.DeviceURL, respExpired.DeviceURL, "expected stale token to be rotated") + + regenToken, err := s.ds.GetDeviceAuthToken(ctx, host.ID) + require.NoError(t, err) + require.NotEqual(t, freshToken, regenToken) + require.Equal(t, "https://fleet.example.com/device/"+regenToken, respExpired.DeviceURL) + + // Case 3: host has never had a token row → Fleet generates one. + hostNoToken := createOrbitEnrolledHost(t, "linux", "device-url-no-token", s.ds) + var respNew getHostDeviceURLResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", hostNoToken.ID), nil, http.StatusOK, &respNew) + require.True(t, strings.HasPrefix(respNew.DeviceURL, "https://fleet.example.com/device/"), "URL: %s", respNew.DeviceURL) + newlyMintedToken, err := s.ds.GetDeviceAuthToken(ctx, hostNoToken.ID) + require.NoError(t, err) + require.Equal(t, "https://fleet.example.com/device/"+newlyMintedToken, respNew.DeviceURL) + + // Unknown host ID: 404. + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID+9999), nil, http.StatusNotFound, &resp) + + // iOS and iPadOS hosts can't use device-token auth, so the endpoint + // rejects them with 400 instead of minting an unusable URL. + iosHost := createOrbitEnrolledHost(t, "ios", "device-url-ios", s.ds) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", iosHost.ID), nil, http.StatusBadRequest, &resp) + ipadHost := createOrbitEnrolledHost(t, "ipados", "device-url-ipad", s.ds) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", ipadHost.ID), nil, http.StatusBadRequest, &resp) + + // Non-global-admin roles: 403. Switch tokens, then restore admin token at end. + defer func() { s.token = s.getTestAdminToken() }() + + t.Run("global maintainer is forbidden", func(t *testing.T) { + s.setTokenForTest(t, TestMaintainerUserEmail, test.GoodPassword) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID), nil, http.StatusForbidden, &resp) + }) + t.Run("global observer is forbidden", func(t *testing.T) { + s.setTokenForTest(t, TestObserverUserEmail, test.GoodPassword) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_url", host.ID), nil, http.StatusForbidden, &resp) + }) +}