Skip to content

Adding my device link on host details page with backend logic to generate it if needed#45659

Open
georgekarrv wants to merge 2 commits into
mainfrom
gkarr-43895-my-device-link
Open

Adding my device link on host details page with backend logic to generate it if needed#45659
georgekarrv wants to merge 2 commits into
mainfrom
gkarr-43895-my-device-link

Conversation

@georgekarrv
Copy link
Copy Markdown
Member

@georgekarrv georgekarrv commented May 15, 2026

Related issue: Resolves #43895

Checklist for submitter

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

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • 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.

  • Timeouts are implemented and retries are limited to avoid infinite loops

  • If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes

Testing

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
  • 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)
  • 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)

Summary by CodeRabbit

  • New Features

    • Self-service software activities now render in passive voice and omit an actor name
    • Added a "My device" button for global admins to open an end‑user device page in a new tab; link is refreshed/generated as needed
    • Device user page and browser tab header now show the end‑user's name when available (falls back to "My device")
  • Tests

    • Updated coverage for self‑service activity rendering and the "My device" flow

Review Change Stack

Copilot AI review requested due to automatic review settings May 15, 2026 22:33
@georgekarrv georgekarrv requested review from a team as code owners May 15, 2026 22:33
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a global-admin-only way to open a host’s My device page from host details, with backend support to return or generate a device auth URL when needed. It also adjusts self-service activity text to avoid attributing those actions to “End user” when an admin may have opened the device page.

Changes:

  • Added /hosts/{id}/device_url service, route, datastore helpers, mocks, and integration coverage.
  • Added frontend host API wiring and a My device button on the User card for global admins.
  • Updated My device page header/title and self-service activity rendering to passive voice.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
server/service/integration_core_test.go Adds integration tests for host device URL generation and authorization.
server/service/hosts.go Implements backend HostDeviceURL logic.
server/service/handler.go Registers the new host device URL route.
server/mock/service/service_mock.go Adds service mock support for HostDeviceURL.
server/mock/datastore_mock.go Adds datastore mock support for fresh/rotated device tokens.
server/fleet/service.go Extends the service interface.
server/fleet/datastore.go Extends the datastore interface.
server/datastore/mysql/hosts.go Adds MySQL helpers for fresh token lookup and token rotation.
frontend/utilities/endpoints.ts Adds the host device URL endpoint.
frontend/services/entities/hosts.ts Adds frontend API method and response type.
frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx Wires My device button behavior into host details.
frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx Updates device page title/header based on end user name.
frontend/pages/hosts/details/cards/User/User.tsx Adds My device button rendering.
frontend/pages/hosts/details/cards/User/User.tests.tsx Adds User card button visibility tests.
frontend/pages/hosts/details/cards/User/_styles.scss Styles User card header actions.
frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx Allows device-user header override.
frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx Renders self-service host activity in passive voice.
frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx Renders self-service global activity in passive voice.
frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx Updates self-service activity tests.
frontend/interfaces/software.ts Adds passive install/uninstall status predicates.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/service/hosts.go
}

// Confirm the host exists; 404 cleanly if not.
host, err := svc.ds.HostLite(ctx, hostID)
Comment on lines +3048 to +3062
const stmt = `
INSERT INTO
host_device_auth ( host_id, token )
VALUES
(?, ?)
ON DUPLICATE KEY UPDATE
token = VALUES(token),
previous_token = NULL
`
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, newToken); err != nil {
if IsDuplicate(err) {
return fleet.ConflictError{Message: "auth token conflicts with another host"}
}
return ctxerr.Wrap(ctx, err, "rotate host's device auth token")
}
Comment thread server/fleet/service.go
// 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.
// Returns a NotFoundError if the host has no device auth token yet.
Comment thread server/service/handler.go
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{})
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

Walkthrough

Adds a "My device" feature and passive-voice self-service activity rendering. Frontend: new passive predicate helper, updated activity components/tests to render self-service events without actor context, HostDetailsPage handler to fetch/open a device URL, a guarded "My device" button on the user card, client API for device URLs, and device-user page header customization. Backend: datastore methods for TTL-bounded token lookup and token rotation, a Service.HostDeviceURL implementation and HTTP endpoint that reuses or rotates tokens, and integration tests for token reuse/rotation and access control.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a 'My device' link on the host details page with backend logic to generate it if needed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description includes a related issue reference and completed the provided checklist template with checkmarks for relevant items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gkarr-43895-my-device-link

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
frontend/pages/hosts/details/cards/User/User.tests.tsx (1)

56-66: ⚡ Quick win

Add a click-behavior assertion for the new button callback.

At Line 56, this verifies rendering only; it should also assert onClickMyDevice fires on click so the new prop contract is covered.

Suggested test addition
 import React from "react";
 import { screen, render } from "`@testing-library/react`";
+import userEvent from "`@testing-library/user-event`";
 import { noop } from "lodash";
@@
   describe("My device button", () => {
@@
     it("renders the 'My device' button when canViewMyDeviceLink is true", () => {
@@
       expect(screen.getByText("My device")).toBeInTheDocument();
     });
+
+    it("calls onClickMyDevice when clicked", async () => {
+      const onClickMyDevice = jest.fn();
+      const user = userEvent.setup();
+      render(
+        <User
+          endUsers={[]}
+          canViewMyDeviceLink
+          onClickMyDevice={onClickMyDevice}
+          onClickUpdateUser={noop}
+        />
+      );
+
+      await user.click(screen.getByRole("button", { name: /my device/i }));
+      expect(onClickMyDevice).toHaveBeenCalledTimes(1);
+    });
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/hosts/details/cards/User/User.tests.tsx` around lines 56 - 66,
The test currently only checks rendering; update the "renders the 'My device'
button when canViewMyDeviceLink is true" spec to also assert the callback fires
by replacing the noop with a mock (e.g., jest.fn()) passed to the User
component's onClickMyDevice prop, simulate a click on the "My device" button
using fireEvent or userEvent, and expect the mock to have been called; locate
the test for User and the onClickMyDevice prop to make this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx`:
- Around line 1073-1084: The onClickMyDevice handler uses window.open(...) which
can return null when the browser blocks popups; update onClickMyDevice to
capture the return value from window.open (after awaiting
hostAPI.getDeviceURL(host.id)) and explicitly handle a null result by calling
renderFlash("error", "Couldn't open My device page. The host may not have
checked in yet or your browser blocked popups.") (or a similar message) so users
get feedback when popups are blocked; reference the onClickMyDevice function,
hostAPI.getDeviceURL, window.open return value, and renderFlash when making this
change.

In `@server/datastore/mysql/hosts.go`:
- Line 3033: The token freshness check currently reads from a replica via
sqlx.GetContext(ctx, ds.reader(ctx), &token, stmt, hostID, tokenTTL.Seconds());
which can return stale data; change the DB handle to the primary by using
ds.writer(ctx) instead of ds.reader(ctx) in that sqlx.GetContext call (keeping
the same parameters: &token, stmt, hostID, tokenTTL.Seconds()) so token
lifecycle checks use the writer for read-after-write consistency.

In `@server/fleet/service.go`:
- Around line 518-522: The comment for HostDeviceURL is inaccurate about error
behavior: update the public contract comment for HostDeviceURL(ctx
context.Context, hostID uint) to state that it will create or rotate the host's
device auth token on demand (so it does not return a NotFoundError when no token
exists), keep that it is global-admin only and that the returned URL is a
credential to the host's device page, and instead note the actual error cases it
may return (e.g., permission or internal errors) so callers and mocks are not
misled.

In `@server/service/hosts.go`:
- Around line 2512-2564: HostDeviceURL returns a credential-like URL but doesn't
record who retrieved it; after you successfully obtain or generate the token
(i.e. after the block that sets token and before returning the formatted URL in
HostDeviceURL), emit a durable admin activity record including the acting user
(from viewer.FromContext's vc.User), the target host ID (host.ID) and an action
like "host_device_url_retrieved" (do not log the raw token). Use the service's
existing activity/audit function (call the project’s activity logging method
used elsewhere — e.g. svc.<activityLogMethod>(ctx, actorID, host.ID,
"host_device_url_retrieved", additional metadata)) and handle/log any error from
that call without preventing the URL from being returned.
- Around line 2536-2552: The current read-then-rotate flow using
svc.ds.GetDeviceAuthTokenIfFresh + server.GenerateRandomURLSafeText +
svc.ds.RotateDeviceAuthToken is racy: two concurrent callers can both see
NotFound and generate different tokens so one rotation immediately invalidates
the other; fix by moving the freshness-check-and-rotate logic into a single
atomic datastore operation (or DB transaction/row lock) so that a single helper
on svc.ds will return either the existing fresh token or perform exactly one
rotation and return the new token; implement e.g. a new method like
RotateOrGetDeviceAuthToken(ctx, hostID, ttl) on svc.ds that performs the
SELECT/UPDATE in one transaction and replace the GetDeviceAuthTokenIfFresh +
RotateDeviceAuthToken sequence with a single call to that method.

In `@server/service/integration_core_test.go`:
- Around line 17221-17227: The cleanup currently swallows errors from
s.ds.AppConfig and s.ds.SaveAppConfig; update the t.Cleanup closure to check
both errors and fail the test if either operation returns an error (e.g., call
t.Fatalf or t.Error with the error), so that failures restoring
ac.ServerSettings.ServerURL (and any SaveAppConfig failure) are surfaced instead
of ignored; specifically modify the t.Cleanup block that calls
s.ds.AppConfig(ctx) and s.ds.SaveAppConfig(ctx, ac) and validate/handle errors
for both while restoring ac.ServerSettings.ServerURL = origServerURL.

---

Nitpick comments:
In `@frontend/pages/hosts/details/cards/User/User.tests.tsx`:
- Around line 56-66: The test currently only checks rendering; update the
"renders the 'My device' button when canViewMyDeviceLink is true" spec to also
assert the callback fires by replacing the noop with a mock (e.g., jest.fn())
passed to the User component's onClickMyDevice prop, simulate a click on the "My
device" button using fireEvent or userEvent, and expect the mock to have been
called; locate the test for User and the onClickMyDevice prop to make this
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6b996f2f-f80c-4a10-b4c5-1ff44ec2d243

📥 Commits

Reviewing files that changed from the base of the PR and between 9c6039e and cfc8966.

📒 Files selected for processing (20)
  • frontend/interfaces/software.ts
  • frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
  • frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
  • frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
  • frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx
  • frontend/pages/hosts/details/cards/User/User.tests.tsx
  • frontend/pages/hosts/details/cards/User/User.tsx
  • frontend/pages/hosts/details/cards/User/_styles.scss
  • frontend/services/entities/hosts.ts
  • frontend/utilities/endpoints.ts
  • server/datastore/mysql/hosts.go
  • server/fleet/datastore.go
  • server/fleet/service.go
  • server/mock/datastore_mock.go
  • server/mock/service/service_mock.go
  • server/service/handler.go
  • server/service/hosts.go
  • server/service/integration_core_test.go

Comment on lines +1073 to +1084
const onClickMyDevice = async () => {
if (!host) return;
try {
const { device_url } = await hostAPI.getDeviceURL(host.id);
window.open(device_url, "_blank", "noopener,noreferrer");
} catch (e) {
renderFlash(
"error",
"Couldn't open My device page. The host may not have checked in yet."
);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle popup-blocked opens explicitly.

At Line 1077, window.open(...) can return null without throwing, so users may get no feedback when popups are blocked.

Suggested fix
   try {
     const { device_url } = await hostAPI.getDeviceURL(host.id);
-    window.open(device_url, "_blank", "noopener,noreferrer");
+    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."
+      );
+      return;
+    }
   } catch (e) {
     renderFlash(
       "error",
       "Couldn't open My device page. The host may not have checked in yet."
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onClickMyDevice = async () => {
if (!host) return;
try {
const { device_url } = await hostAPI.getDeviceURL(host.id);
window.open(device_url, "_blank", "noopener,noreferrer");
} catch (e) {
renderFlash(
"error",
"Couldn't open My device page. The host may not have checked in yet."
);
}
};
const onClickMyDevice = async () => {
if (!host) return;
try {
const { device_url } = await hostAPI.getDeviceURL(host.id);
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."
);
return;
}
} catch (e) {
renderFlash(
"error",
"Couldn't open My device page. The host may not have checked in yet."
);
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx` around
lines 1073 - 1084, The onClickMyDevice handler uses window.open(...) which can
return null when the browser blocks popups; update onClickMyDevice to capture
the return value from window.open (after awaiting hostAPI.getDeviceURL(host.id))
and explicitly handle a null result by calling renderFlash("error", "Couldn't
open My device page. The host may not have checked in yet or your browser
blocked popups.") (or a similar message) so users get feedback when popups are
blocked; reference the onClickMyDevice function, hostAPI.getDeviceURL,
window.open return value, and renderFlash when making this change.

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.reader(ctx), &token, stmt, hostID, tokenTTL.Seconds()); {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use primary DB for token freshness checks in auth flow.

Line 3033 reads from ds.reader(ctx). For device-auth token lifecycle, replica lag can return stale/missing data and cause unnecessary rotations or inconsistent link behavior. Use ds.writer(ctx) for read-after-write consistency.

Suggested patch
-	switch err := sqlx.GetContext(ctx, ds.reader(ctx), &token, stmt, hostID, tokenTTL.Seconds()); {
+	switch err := sqlx.GetContext(ctx, ds.writer(ctx), &token, stmt, hostID, tokenTTL.Seconds()); {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/datastore/mysql/hosts.go` at line 3033, The token freshness check
currently reads from a replica via sqlx.GetContext(ctx, ds.reader(ctx), &token,
stmt, hostID, tokenTTL.Seconds()); which can return stale data; change the DB
handle to the primary by using ds.writer(ctx) instead of ds.reader(ctx) in that
sqlx.GetContext call (keeping the same parameters: &token, stmt, hostID,
tokenTTL.Seconds()) so token lifecycle checks use the writer for
read-after-write consistency.

Comment thread server/fleet/service.go
Comment on lines +518 to +522
// 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.
// Returns a NotFoundError if the host has no device auth token yet.
HostDeviceURL(ctx context.Context, hostID uint) (string, error)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the public contract comment.

HostDeviceURL no longer returns NotFoundError when a host lacks a token; the implementation now creates or rotates one on demand. Leaving this comment as-is will mislead callers and mock implementations.

📝 Suggested doc fix
- // Returns a NotFoundError if the host has no device auth token yet.
+ // Generates a device auth token if needed and returns the resulting URL.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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.
// Returns a NotFoundError if the host has no device auth token yet.
HostDeviceURL(ctx context.Context, hostID uint) (string, 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.
// Generates a device auth token if needed and returns the resulting URL.
HostDeviceURL(ctx context.Context, hostID uint) (string, error)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/fleet/service.go` around lines 518 - 522, The comment for
HostDeviceURL is inaccurate about error behavior: update the public contract
comment for HostDeviceURL(ctx context.Context, hostID uint) to state that it
will create or rotate the host's device auth token on demand (so it does not
return a NotFoundError when no token exists), keep that it is global-admin only
and that the returned URL is a credential to the host's device page, and instead
note the actual error cases it may return (e.g., permission or internal errors)
so callers and mocks are not misled.

Comment thread server/service/hosts.go
Comment on lines +2512 to +2564
// 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")
}

// 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):
// Either no token row exists yet, or the existing one is expired.
// Generate a fresh token and upsert it, clearing previous_token.
newToken, genErr := server.GenerateRandomURLSafeText(24)
if genErr != nil {
return "", ctxerr.Wrap(ctx, genErr, "generate new device auth token")
}
if rotErr := svc.ds.RotateDeviceAuthToken(ctx, host.ID, newToken); rotErr != nil {
return "", ctxerr.Wrap(ctx, rotErr, "rotate device auth token")
}
token = newToken
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")
}

base := strings.TrimRight(ac.ServerSettings.ServerURL, "/")
return fmt.Sprintf("%s/device/%s", base, token), nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Audit device-URL issuance.

This method returns a credential-equivalent URL but never records who retrieved it. Unlike the other secret-retrieval paths in this file, that leaves no durable activity trail if a link is shared or misused. Please emit an admin activity whenever a device URL is returned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/hosts.go` around lines 2512 - 2564, HostDeviceURL returns a
credential-like URL but doesn't record who retrieved it; after you successfully
obtain or generate the token (i.e. after the block that sets token and before
returning the formatted URL in HostDeviceURL), emit a durable admin activity
record including the acting user (from viewer.FromContext's vc.User), the target
host ID (host.ID) and an action like "host_device_url_retrieved" (do not log the
raw token). Use the service's existing activity/audit function (call the
project’s activity logging method used elsewhere — e.g.
svc.<activityLogMethod>(ctx, actorID, host.ID, "host_device_url_retrieved",
additional metadata)) and handle/log any error from that call without preventing
the URL from being returned.

Comment thread server/service/hosts.go
Comment on lines +2536 to +2552
// 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):
// Either no token row exists yet, or the existing one is expired.
// Generate a fresh token and upsert it, clearing previous_token.
newToken, genErr := server.GenerateRandomURLSafeText(24)
if genErr != nil {
return "", ctxerr.Wrap(ctx, genErr, "generate new device auth token")
}
if rotErr := svc.ds.RotateDeviceAuthToken(ctx, host.ID, newToken); rotErr != nil {
return "", ctxerr.Wrap(ctx, rotErr, "rotate device auth token")
}
token = newToken
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make token reuse/rotation atomic.

This read-then-rotate sequence is racy. If two admins request a URL for the same host while the token is missing or expired, both calls can observe NotFound, both generate a new token, and the second RotateDeviceAuthToken invalidates the first URL immediately. This needs to be a single datastore operation or transaction that reuses the fresh token or rotates exactly once.

🔒 Suggested direction
- token, err := svc.ds.GetDeviceAuthTokenIfFresh(ctx, host.ID, hostDeviceAuthTokenTTL)
- switch {
- case err == nil:
- case fleet.IsNotFound(err):
- 	newToken, genErr := server.GenerateRandomURLSafeText(24)
- 	if genErr != nil {
- 		return "", ctxerr.Wrap(ctx, genErr, "generate new device auth token")
- 	}
- 	if rotErr := svc.ds.RotateDeviceAuthToken(ctx, host.ID, newToken); rotErr != nil {
- 		return "", ctxerr.Wrap(ctx, rotErr, "rotate device auth token")
- 	}
- 	token = newToken
- default:
- 	return "", ctxerr.Wrap(ctx, err, "check fresh device auth token")
- }
+ token, err := svc.ds.GetOrRotateDeviceAuthToken(ctx, host.ID, hostDeviceAuthTokenTTL)
+ if err != nil {
+ 	return "", ctxerr.Wrap(ctx, err, "get or rotate device auth token")
+ }

That helper should perform the freshness check and rotation under one transaction or row lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/hosts.go` around lines 2536 - 2552, The current
read-then-rotate flow using svc.ds.GetDeviceAuthTokenIfFresh +
server.GenerateRandomURLSafeText + svc.ds.RotateDeviceAuthToken is racy: two
concurrent callers can both see NotFound and generate different tokens so one
rotation immediately invalidates the other; fix by moving the
freshness-check-and-rotate logic into a single atomic datastore operation (or DB
transaction/row lock) so that a single helper on svc.ds will return either the
existing fresh token or perform exactly one rotation and return the new token;
implement e.g. a new method like RotateOrGetDeviceAuthToken(ctx, hostID, ttl) on
svc.ds that performs the SELECT/UPDATE in one transaction and replace the
GetDeviceAuthTokenIfFresh + RotateDeviceAuthToken sequence with a single call to
that method.

Comment on lines +17221 to +17227
t.Cleanup(func() {
ac, err := s.ds.AppConfig(ctx)
if err != nil {
return
}
ac.ServerSettings.ServerURL = origServerURL
_ = s.ds.SaveAppConfig(ctx, ac)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t swallow app-config restore failures in cleanup.

At Line 17223 and Line 17227, cleanup errors are ignored, which can leak test state into later tests and hide the root cause of flakiness. Fail explicitly if restore cannot complete.

Proposed fix
 	t.Cleanup(func() {
 		ac, err := s.ds.AppConfig(ctx)
-		if err != nil {
-			return
-		}
+		require.NoError(t, err)
 		ac.ServerSettings.ServerURL = origServerURL
-		_ = s.ds.SaveAppConfig(ctx, ac)
+		require.NoError(t, s.ds.SaveAppConfig(ctx, ac))
 	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
t.Cleanup(func() {
ac, err := s.ds.AppConfig(ctx)
if err != nil {
return
}
ac.ServerSettings.ServerURL = origServerURL
_ = s.ds.SaveAppConfig(ctx, ac)
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))
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/integration_core_test.go` around lines 17221 - 17227, The
cleanup currently swallows errors from s.ds.AppConfig and s.ds.SaveAppConfig;
update the t.Cleanup closure to check both errors and fail the test if either
operation returns an error (e.g., call t.Fatalf or t.Error with the error), so
that failures restoring ac.ServerSettings.ServerURL (and any SaveAppConfig
failure) are surfaced instead of ignored; specifically modify the t.Cleanup
block that calls s.ds.AppConfig(ctx) and s.ds.SaveAppConfig(ctx, ac) and
validate/handle errors for both while restoring ac.ServerSettings.ServerURL =
origServerURL.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 54.62185% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.74%. Comparing base (9afdb43) to head (8685c16).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
frontend/interfaces/software.ts 36.84% 12 Missing ⚠️
server/service/hosts.go 65.71% 7 Missing and 5 partials ⚠️
.../hosts/details/HostDetailsPage/HostDetailsPage.tsx 0.00% 10 Missing ⚠️
...wareActivityItem/InstalledSoftwareActivityItem.tsx 0.00% 6 Missing ⚠️
server/datastore/mysql/hosts.go 76.00% 5 Missing and 1 partial ⚠️
...vityFeed/GlobalActivityItem/GlobalActivityItem.tsx 55.55% 4 Missing ⚠️
frontend/services/entities/hosts.ts 0.00% 3 Missing ⚠️
frontend/utilities/endpoints.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #45659      +/-   ##
==========================================
- Coverage   66.74%   66.74%   -0.01%     
==========================================
  Files        2744     2744              
  Lines      219316   219421     +105     
  Branches    10831    10975     +144     
==========================================
+ Hits       146393   146448      +55     
- Misses      59697    59738      +41     
- Partials    13226    13235       +9     
Flag Coverage Δ
backend 68.58% <70.49%> (-0.01%) ⬇️
frontend 55.56% <37.93%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Get My device link for host details

2 participants