diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json
index 4a67715f9..3083d31bb 100644
--- a/apps/console/src/_locales/en-US.json
+++ b/apps/console/src/_locales/en-US.json
@@ -2513,7 +2513,7 @@
"auth": { "actions": { "signIn": "Sign in" } },
"authError": { "enterpriseAccountRequired": { "title": "Enterprise account required", "description": "Personal Google and Microsoft accounts cannot be used to sign in. Please use your work or school account instead." }, "emailNotVerified": { "title": "Email not verified", "description": "Your email address is not verified with the identity provider. Please verify it, then try signing in again." }, "signInSessionExpired": { "title": "Sign-in session expired", "description": "This sign-in attempt is no longer valid. Please start again from the sign-in page." }, "magicLinkExpired": { "title": "Link Expired", "description": "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one." }, "magicLinkAlreadyUsed": { "title": "Link Already Used", "description": "This magic link has already been used. Please request a new one." }, "invalidLink": { "title": "Invalid link", "description": "This magic link is invalid. Please request a new one." }, "default": { "title": "Authentication failed", "description": "We could not complete your sign-in. Please try again." } },
"accessReviewSource": { "documentation": "Documentation", "regions": { "label": "Region", "placeholder": "Select a region", "unitedStates": "United States", "europe": "Europe" } },
- "devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } },
+ "devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke", "delete": "Delete" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked", "deleted": "Device deleted" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device", "delete": "Failed to delete device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled.", "delete": "Delete device \"{{hostname}}\"? This removes it from the inventory." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } },
"employeeDevices": { "title": "Your devices" },
"deviceEnrollment": { "pageTitle": "Enroll device", "title": "Device enrollment", "setup": "Setup", "stepProgress": "Step {{current}} of {{total}}", "steps": { "privacy": { "title": "Privacy", "description": "Review collected data" }, "organization": { "title": "Organization", "description": "Choose destination workspace" }, "enroll": { "title": "Open and wait", "description": "Finish setup in the desktop agent" } }, "unavailable": { "title": "Enrollment unavailable", "description": "You do not have permission to enroll devices in any organization." }, "intro": { "title": "Before you start", "description": "Probo collects the following device metadata for inventory and posture reporting:" }, "privacy": { "identity": "Device identity: hardware UUID, hostname, and serial number (when available).", "systemDetails": "System details: platform, OS version, and Probo agent version.", "activitySignals": "Activity signals: enrollment time, heartbeats, and posture check results." }, "organization": { "title": "Choose organization", "description": "Pick which organization will own and manage this device." }, "openAgent": { "title": "Open the Probo agent", "description": "Open the desktop agent to finish setup, then keep this page open until enrollment is confirmed." }, "actions": { "backToOrganizations": "Back to organizations", "enrollNew": "Enroll new device", "openAgent": "Open Probo agent", "preparing": "Preparing…" }, "status": { "enrolledWithHostname": "{{hostname}} is enrolled.", "enrolled": "This device is enrolled.", "closeWindow": "You can close this window.", "waitingForCheckIn": "Waiting for the agent's first check-in…", "timedOut": "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again." }, "manual": { "cannotEnroll": "Can't enroll new device?", "tryCreating": "Try creating it manually", "title": "Manual enrollment", "creating": "Creating device…" }, "token": { "title": "Enrollment token generated", "description": "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", "manualInstall": "Manual install (CLI / MDM)", "installUnix": "Install on macOS or Linux (run from a shell with sudo access)", "installWindows": "Install on Windows (run from an elevated PowerShell session)", "securityNotice": "The token is passed as a CLI flag (not via curl-piped-to-shell or sudo env vars). Once installed, the agent self-updates from GitHub Releases with cosign signature verification." }, "messages": { "created": "Device created. Copy the enrollment token now — it will not be shown again." }, "errors": { "copyToClipboard": "Failed to copy to clipboard" } },
"thirdPartyRiskAssessmentRow": {
diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json
index bd23b8e66..b47fb640d 100644
--- a/apps/console/src/_locales/fr-FR.json
+++ b/apps/console/src/_locales/fr-FR.json
@@ -5958,7 +5958,8 @@
"add": "Ajouter un appareil",
"new": "Nouvel appareil",
"reassign": "Réattribuer",
- "revoke": "Révoquer"
+ "revoke": "Révoquer",
+ "delete": "Supprimer"
},
"fields": {
"organization": "Organisation",
@@ -5980,15 +5981,18 @@
},
"messages": {
"ownerUpdated": "Propriétaire de l’appareil mis à jour",
- "revoked": "Appareil révoqué"
+ "revoked": "Appareil révoqué",
+ "deleted": "Appareil supprimé"
},
"errors": {
"create": "Échec de la création de l’appareil",
"reassign": "Échec de la réattribution de l’appareil",
- "revoke": "Échec de la révocation de l’appareil"
+ "revoke": "Échec de la révocation de l’appareil",
+ "delete": "Échec de la suppression de l’appareil"
},
"confirmations": {
- "revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé."
+ "revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé.",
+ "delete": "Supprimer l’appareil « {{hostname}} » ? Il sera retiré de l’inventaire."
},
"postures": {
"currentTitle": "Postures actuelles",
diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx
index a657696b1..099ea5e16 100644
--- a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx
+++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx
@@ -49,7 +49,6 @@ export const trackerPatternDetectedTrackersSectionFragment = graphql`
before: $before
orderBy: $order
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
- __id
edges {
node {
id
diff --git a/apps/console/src/pages/organizations/devices/DeviceLayout.tsx b/apps/console/src/pages/organizations/devices/DeviceLayout.tsx
index f45f4d6d3..1aab27467 100644
--- a/apps/console/src/pages/organizations/devices/DeviceLayout.tsx
+++ b/apps/console/src/pages/organizations/devices/DeviceLayout.tsx
@@ -19,13 +19,13 @@
// SOFTWARE.
import { usePageTitle } from "@probo/hooks";
-import { Breadcrumb, Button, PageHeader } from "@probo/ui";
+import { Breadcrumb, Button, IconEject, IconTrashCan, PageHeader } from "@probo/ui";
import { useTranslation } from "react-i18next";
import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
-import { Outlet } from "react-router";
+import { Outlet, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql";
@@ -33,7 +33,8 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures";
import { DeviceDetailsCard } from "./_components/DeviceDetailsCard";
-import { displayValue } from "./_lib/deviceDisplay";
+import { displayValue, isDeviceDeletable } from "./_lib/deviceDisplay";
+import { useDeleteDevice } from "./_lib/useDeleteDevice";
import { useRevokeDevice } from "./_lib/useRevokeDevice";
export const deviceLayoutQuery = graphql`
@@ -52,6 +53,7 @@ export const deviceLayoutQuery = graphql`
__typename
... on Organization {
canRevokeDevice: permission(action: "itam:device:revoke")
+ canDeleteDevice: permission(action: "itam:device:delete")
}
}
}
@@ -63,6 +65,7 @@ interface DeviceLayoutProps {
export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
const { t } = useTranslation();
+ const navigate = useNavigate();
const organizationId = useOrganizationId();
const pendingLabel = t("devices.values.pending");
@@ -82,9 +85,16 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
const hostnameLabel = displayValue(device.hostname, pendingLabel);
const [confirmRevoke, isRevoking] = useRevokeDevice();
+ const [confirmDelete, isDeleting] = useDeleteDevice({
+ organizationId,
+ onDeleted: () => {
+ void navigate(`/organizations/${organizationId}/devices`, { replace: true });
+ },
+ });
- const isRevoked = device.state === "REVOKED";
+ const deletable = isDeviceDeletable(device.state);
const canRevokeDevice = organization.canRevokeDevice ?? false;
+ const canDeleteDevice = organization.canDeleteDevice ?? false;
return (
@@ -98,9 +108,10 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
]}
/>
- {!isRevoked && canRevokeDevice && (
+ {!deletable && canRevokeDevice && (
)}
+ {deletable && canDeleteDevice && (
+
+ )}
diff --git a/apps/console/src/pages/organizations/devices/DevicesPage.tsx b/apps/console/src/pages/organizations/devices/DevicesPage.tsx
index 3ec5bebfa..8d2b596a7 100644
--- a/apps/console/src/pages/organizations/devices/DevicesPage.tsx
+++ b/apps/console/src/pages/organizations/devices/DevicesPage.tsx
@@ -46,6 +46,7 @@ export const devicesPageQuery = graphql`
id
canAssignDevice: permission(action: "itam:device:assign")
canRevokeDevice: permission(action: "itam:device:revoke")
+ canDeleteDevice: permission(action: "itam:device:delete")
canCreateDevice: permission(action: "itam:device:create")
...DevicesPageFragment
}
@@ -72,7 +73,8 @@ const devicesPageFragment = graphql`
last: $last
before: $before
orderBy: $order
- ) @connection(key: "DevicesPage_devices", filters: ["orderBy"]) {
+ ) @connection(key: "DevicesPage_devices", filters: []) {
+ __id
edges {
node {
id
@@ -107,6 +109,7 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
>(devicesPageFragment, organization);
const devices = pagination.data.devices.edges.map(edge => edge.node);
+ const connectionId = pagination.data.devices.__id;
return (
@@ -148,8 +151,10 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
))}
diff --git a/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx b/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx
index da081c93f..60fd0eac7 100644
--- a/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx
+++ b/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx
@@ -23,6 +23,7 @@ import {
ActionDropdown,
Badge,
DropdownItem,
+ IconEject,
IconTrashCan,
IconUser,
Td,
@@ -36,7 +37,8 @@ import { graphql } from "relay-runtime";
import type { DeviceRowFragment$key } from "#/__generated__/core/DeviceRowFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
-import { displayValue, stateVariant } from "../_lib/deviceDisplay";
+import { displayValue, isDeviceDeletable, stateVariant } from "../_lib/deviceDisplay";
+import { useDeleteDevice } from "../_lib/useDeleteDevice";
import { useRevokeDevice } from "../_lib/useRevokeDevice";
import { ReassignDeviceDialog } from "../dialogs/ReassignDeviceDialog";
@@ -58,11 +60,19 @@ const deviceRowFragment = graphql`
interface DeviceRowProps {
canAssignDevice: boolean;
+ canDelete: boolean;
canRevoke: boolean;
+ connectionId: string;
fKey: DeviceRowFragment$key;
}
-export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps) {
+export function DeviceRow({
+ canAssignDevice,
+ canDelete,
+ canRevoke,
+ connectionId,
+ fKey,
+}: DeviceRowProps) {
const { i18n, t } = useTranslation();
const organizationId = useOrganizationId();
const reassignDialogRef = useDialogRef();
@@ -71,9 +81,16 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
const device = useFragment(deviceRowFragment, fKey);
const [confirmRevoke, isRevoking] = useRevokeDevice();
+ const [confirmDelete, isDeleting] = useDeleteDevice({
+ organizationId,
+ connectionId,
+ });
- const isRevoked = device.state === "REVOKED";
- const hasActions = !isRevoked && (canRevoke || canAssignDevice);
+ const deletable = isDeviceDeletable(device.state);
+ const showAssign = canAssignDevice && !deletable;
+ const showRevoke = canRevoke && !deletable;
+ const showDelete = canDelete && deletable;
+ const hasActions = showAssign || showRevoke || showDelete;
return (
<>
@@ -98,7 +115,7 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
{hasActions && (
- {canAssignDevice && (
+ {showAssign && (
reassignDialogRef.current?.open()}
@@ -106,17 +123,28 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
{t("devices.actions.reassign")}
)}
- {canRevoke && (
+ {showRevoke && (
confirmRevoke({ id: device.id, hostname: device.hostname })}
disabled={isRevoking}
variant="danger"
- icon={IconTrashCan}
+ icon={IconEject}
>
{t("devices.actions.revoke")}
)}
+ {showDelete && (
+
+ confirmDelete({ id: device.id, hostname: device.hostname })}
+ disabled={isDeleting}
+ variant="danger"
+ icon={IconTrashCan}
+ >
+ {t("devices.actions.delete")}
+
+ )}
)}
|
diff --git a/apps/console/src/pages/organizations/devices/_components/PostureValueBadge.tsx b/apps/console/src/pages/organizations/devices/_components/PostureValueBadge.tsx
index 04cfdc19b..a10a5867b 100644
--- a/apps/console/src/pages/organizations/devices/_components/PostureValueBadge.tsx
+++ b/apps/console/src/pages/organizations/devices/_components/PostureValueBadge.tsx
@@ -48,8 +48,9 @@ export function PostureValueBadge({
const { t } = useTranslation();
const posture = useFragment(postureFragment, postureFragmentRef);
- const label = postureValueLabel(t, posture.value);
- const variant = postureValueVariant(posture.value.kind, posture.checkKey);
+ const { kind, text, number } = posture.value;
+ const label = postureValueLabel(t, { kind, text, number });
+ const variant = postureValueVariant(kind, posture.checkKey);
if (!variant) {
return label;
diff --git a/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts
index 26ea09878..bff891521 100644
--- a/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts
+++ b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts
@@ -38,6 +38,11 @@ export function stateVariant(
}
}
+/** Soft-delete is only offered after the device has been revoked. */
+export function isDeviceDeletable(state: string): boolean {
+ return state === "REVOKED";
+}
+
type Translator = (key: string, options?: Record
) => string;
/** What a posture check observed, as returned by the DevicePostureValue type. */
diff --git a/apps/console/src/pages/organizations/devices/_lib/useDeleteDevice.ts b/apps/console/src/pages/organizations/devices/_lib/useDeleteDevice.ts
new file mode 100644
index 000000000..1e5501242
--- /dev/null
+++ b/apps/console/src/pages/organizations/devices/_lib/useDeleteDevice.ts
@@ -0,0 +1,115 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+import { useConfirm } from "@probo/ui";
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ConnectionHandler,
+ type DataID,
+ graphql,
+} from "relay-runtime";
+
+import type { useDeleteDeviceMutation } from "#/__generated__/core/useDeleteDeviceMutation.graphql";
+import { useMutation } from "#/lib/relay/useMutation";
+
+import { displayValue } from "./deviceDisplay";
+
+export const DevicesConnectionKey = "DevicesPage_devices";
+
+const deleteDeviceMutation = graphql`
+ mutation useDeleteDeviceMutation(
+ $input: DeleteDeviceInput!
+ $connections: [ID!]!
+ ) {
+ deleteDevice(input: $input) {
+ # @deleteEdge only unlinks from list connections — do not use
+ # @deleteRecord; DeviceLayout reads the node with @required(action: THROW).
+ deletedDeviceId @deleteEdge(connections: $connections)
+ }
+ }
+`;
+
+interface DeleteDeviceInput {
+ id: string;
+ hostname: string | null | undefined;
+}
+
+interface DeleteDeviceOptions {
+ organizationId: string;
+ connectionId?: DataID;
+ onDeleted?: () => void;
+}
+
+export function useDeleteDevice(options: DeleteDeviceOptions) {
+ const { t } = useTranslation();
+ const confirm = useConfirm();
+ const pendingLabel = t("devices.values.pending");
+ const organizationId = options.organizationId;
+ const connectionId = options.connectionId;
+ const onDeleted = options.onDeleted;
+
+ const [deleteDevice, isDeleting] = useMutation(
+ deleteDeviceMutation,
+ {
+ successMessage: t("devices.messages.deleted"),
+ errorToast: t("devices.errors.delete"),
+ },
+ );
+
+ const confirmDelete = useCallback(
+ (device: DeleteDeviceInput) => {
+ const connections = [
+ connectionId
+ ?? ConnectionHandler.getConnectionID(organizationId, DevicesConnectionKey),
+ ];
+
+ confirm(
+ async () => {
+ await deleteDevice({
+ variables: {
+ input: { deviceId: device.id },
+ connections,
+ },
+ });
+ onDeleted?.();
+ },
+ {
+ message: t("devices.confirmations.delete", {
+ hostname: displayValue(device.hostname, pendingLabel),
+ }),
+ variant: "danger",
+ label: t("devices.actions.delete"),
+ },
+ );
+ },
+ [
+ t,
+ confirm,
+ pendingLabel,
+ deleteDevice,
+ organizationId,
+ connectionId,
+ onDeleted,
+ ],
+ );
+
+ return [confirmDelete, isDeleting] as const;
+}
diff --git a/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts b/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts
index c028346fa..ce0d8d6db 100644
--- a/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts
+++ b/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts
@@ -18,14 +18,13 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-import { formatError } from "@probo/helpers";
-import { useConfirm, useToast } from "@probo/ui";
+import { useConfirm } from "@probo/ui";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
-import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { useRevokeDeviceMutation } from "#/__generated__/core/useRevokeDeviceMutation.graphql";
+import { useMutation } from "#/lib/relay/useMutation";
import { displayValue } from "./deviceDisplay";
@@ -49,58 +48,34 @@ interface RevokeDeviceInput {
export function useRevokeDevice() {
const { t } = useTranslation();
- const { toast } = useToast();
const confirm = useConfirm();
const pendingLabel = t("devices.values.pending");
const [revokeDevice, isRevoking] = useMutation(
revokeDeviceMutation,
+ {
+ successMessage: t("devices.messages.revoked"),
+ errorToast: t("devices.errors.revoke"),
+ },
);
const confirmRevoke = useCallback(
(device: RevokeDeviceInput) => {
confirm(
() =>
- new Promise((resolve) => {
- revokeDevice({
- variables: { input: { deviceId: device.id } },
- onCompleted(_, errors) {
- if (errors?.length) {
- toast({
- title: t("common.error"),
- description: errors[0].message,
- variant: "error",
- });
- } else {
- toast({
- title: t("common.success"),
- description: t("devices.messages.revoked"),
- variant: "success",
- });
- }
- resolve();
- },
- onError(error) {
- toast({
- title: t("common.error"),
- description: formatError(
- t("devices.errors.revoke"),
- error,
- ),
- variant: "error",
- });
- resolve();
- },
- });
+ revokeDevice({
+ variables: { input: { deviceId: device.id } },
}),
{
- message: t("devices.confirmations.revoke", { hostname: displayValue(device.hostname, pendingLabel) }),
+ message: t("devices.confirmations.revoke", {
+ hostname: displayValue(device.hostname, pendingLabel),
+ }),
variant: "danger",
label: t("devices.actions.revoke"),
},
);
},
- [t, confirm, pendingLabel, revokeDevice, toast],
+ [t, confirm, pendingLabel, revokeDevice],
);
return [confirmRevoke, isRevoking] as const;
diff --git a/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx
index 0ca9c95e8..c0b61cf93 100644
--- a/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx
+++ b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx
@@ -45,7 +45,6 @@ const deviceFragment = graphql`
) @connection(key: "DevicePostureReportListFragment_postureReports") {
edges {
node {
- createdAt
id
...DevicePostureReportListItemFragment
}
diff --git a/e2e/console/device_enrollment_test.go b/e2e/console/device_enrollment_test.go
index cdec30e37..5c3ee9ae6 100644
--- a/e2e/console/device_enrollment_test.go
+++ b/e2e/console/device_enrollment_test.go
@@ -64,6 +64,13 @@ const (
}
}`
+ deleteDeviceMutation = `
+ mutation DeleteDevice($input: DeleteDeviceInput!) {
+ deleteDevice(input: $input) {
+ deletedDeviceId
+ }
+ }`
+
devicePermissionQuery = `
query DevicePermission($orgId: ID!) {
node(id: $orgId) {
@@ -1012,6 +1019,92 @@ func TestDeviceEnrollment(t *testing.T) {
})
}
+func TestDeviceDelete(t *testing.T) {
+ t.Parallel()
+
+ t.Run("cannot delete pending device", func(t *testing.T) {
+ t.Parallel()
+
+ owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
+ created := createDevice(t, owner, orgID, nil)
+
+ _, err := owner.Do(deleteDeviceMutation, map[string]any{
+ "input": map[string]any{
+ "deviceId": created.CreateDevice.Device.ID,
+ },
+ })
+ testutil.RequireErrorCode(t, err, "CONFLICT", "pending device must be revoked before delete")
+ })
+
+ t.Run("owner can delete revoked device", func(t *testing.T) {
+ t.Parallel()
+
+ owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
+ created := createDevice(t, owner, orgID, nil)
+ deviceID := created.CreateDevice.Device.ID
+
+ owner.MustExecute(revokeDeviceMutation, map[string]any{
+ "input": map[string]any{"deviceId": deviceID},
+ }, &struct {
+ RevokeDevice struct {
+ Device struct {
+ State string `json:"state"`
+ } `json:"device"`
+ } `json:"revokeDevice"`
+ }{})
+
+ var deleteResult struct {
+ DeleteDevice struct {
+ DeletedDeviceID string `json:"deletedDeviceId"`
+ } `json:"deleteDevice"`
+ }
+ owner.MustExecute(deleteDeviceMutation, map[string]any{
+ "input": map[string]any{"deviceId": deviceID},
+ }, &deleteResult)
+ require.Equal(t, deviceID, deleteResult.DeleteDevice.DeletedDeviceID)
+
+ _, err := owner.Do(getDeviceQuery, map[string]any{"id": deviceID})
+ testutil.RequireErrorCode(t, err, "NOT_FOUND", "soft-deleted device must not be readable")
+ })
+
+ t.Run("cannot delete active device", func(t *testing.T) {
+ t.Parallel()
+
+ owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
+ enrolled, _ := enrollActivateAndAuthenticateDevice(t, employee, orgID)
+
+ _, err := owner.Do(deleteDeviceMutation, map[string]any{
+ "input": map[string]any{
+ "deviceId": enrolled.EnrollDevice.Device.ID,
+ },
+ })
+ testutil.RequireErrorCode(t, err, "CONFLICT", "active device must not be deletable")
+ })
+
+ t.Run("employee cannot delete device", func(t *testing.T) {
+ t.Parallel()
+
+ owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
+ created := createDevice(t, owner, orgID, nil)
+ deviceID := created.CreateDevice.Device.ID
+
+ owner.MustExecute(revokeDeviceMutation, map[string]any{
+ "input": map[string]any{"deviceId": deviceID},
+ }, &struct {
+ RevokeDevice struct {
+ Device struct {
+ State string `json:"state"`
+ } `json:"device"`
+ } `json:"revokeDevice"`
+ }{})
+
+ _, err := employee.Do(deleteDeviceMutation, map[string]any{
+ "input": map[string]any{"deviceId": deviceID},
+ })
+ testutil.RequireForbiddenError(t, err, "employee should not delete devices")
+ })
+}
+
func TestDeviceEnrollmentPermissionQueryShape(t *testing.T) {
t.Parallel()
diff --git a/packages/ui/src/Atoms/Icons/index.tsx b/packages/ui/src/Atoms/Icons/index.tsx
index 5d587e985..94733e478 100644
--- a/packages/ui/src/Atoms/Icons/index.tsx
+++ b/packages/ui/src/Atoms/Icons/index.tsx
@@ -112,6 +112,7 @@ export { IconBrandX } from "./IconBrandX";
export { IconGlobe } from "./IconGlobe";
export {
ArrowsClockwise as IconArrowsClockwise,
+ Eject as IconEject,
Envelope as IconEnvelope,
LockOpen as IconLockOpen,
User as IconUser,
diff --git a/pkg/coredata/device.go b/pkg/coredata/device.go
index 4768ef42c..0cd2eaa4d 100644
--- a/pkg/coredata/device.go
+++ b/pkg/coredata/device.go
@@ -56,6 +56,7 @@ type (
EnrolledAt *time.Time `db:"enrolled_at"`
LastSeenAt *time.Time `db:"last_seen_at"`
RevokedAt *time.Time `db:"revoked_at"`
+ DeletedAt *time.Time `db:"deleted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -102,6 +103,7 @@ FROM
devices
WHERE
id = ANY(@resource_ids::text[])
+ AND deleted_at IS NULL
`
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"resource_ids": resourceIDs})
@@ -195,6 +197,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -202,6 +205,7 @@ FROM
WHERE
%s
AND id = @device_id
+ AND deleted_at IS NULL
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -252,6 +256,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -259,6 +264,7 @@ FROM
WHERE
%s
AND id = @device_id
+ AND deleted_at IS NULL
LIMIT 1
FOR UPDATE;
`
@@ -311,6 +317,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -318,6 +325,7 @@ FROM
WHERE
api_key_hash = @api_key_hash
AND state != @revoked_state
+ AND deleted_at IS NULL
LIMIT 1;
`
@@ -370,6 +378,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -378,6 +387,7 @@ WHERE
%s
AND organization_id = @organization_id
AND hardware_uuid = @hardware_uuid
+ AND deleted_at IS NULL
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -504,6 +514,7 @@ WHERE %s
AND id = @device_id
AND state = @pending_state
AND api_key_hash IS NULL
+ AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{
@@ -556,6 +567,7 @@ SET
WHERE %s
AND id = @device_id
AND state = @pending_state
+ AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{
@@ -611,6 +623,7 @@ SET
WHERE %s
AND id = @device_id
AND state = @active_state
+ AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{
@@ -654,6 +667,7 @@ SET
updated_at = @now
WHERE %s
AND id = @device_id
+ AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{
@@ -697,6 +711,7 @@ SET
updated_at = @now
WHERE %s
AND id = @device_id
+ AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{
@@ -746,6 +761,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -753,6 +769,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
+ AND deleted_at IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -802,6 +819,7 @@ SELECT
enrolled_at,
last_seen_at,
revoked_at,
+ deleted_at,
created_at,
updated_at
FROM
@@ -811,6 +829,7 @@ WHERE
AND organization_id = @organization_id
AND owner_profile_id = @owner_profile_id
AND state = @active_state
+ AND deleted_at IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -846,7 +865,7 @@ func (ds *Devices) CountByOrganizationID(
) (int, error) {
q := fmt.Sprintf(`
SELECT COUNT(id) FROM devices
-WHERE %s AND organization_id = @organization_id
+WHERE %s AND organization_id = @organization_id AND deleted_at IS NULL
`, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
@@ -877,6 +896,7 @@ WHERE
AND organization_id = @organization_id
AND owner_profile_id = @owner_profile_id
AND state = @active_state
+ AND deleted_at IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -894,3 +914,85 @@ WHERE
return count, nil
}
+
+func (d *Device) SoftDelete(
+ ctx context.Context,
+ conn pg.Tx,
+ scope Scoper,
+) error {
+ now := time.Now()
+
+ q := fmt.Sprintf(`
+UPDATE devices
+SET
+ deleted_at = @deleted_at,
+ updated_at = @updated_at
+WHERE %s
+ AND id = @device_id
+ AND deleted_at IS NULL
+ AND state = @revoked_state
+`, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{
+ "device_id": d.ID,
+ "deleted_at": now,
+ "updated_at": now,
+ "revoked_state": DeviceStateRevoked,
+ }
+ maps.Copy(args, scope.SQLArguments())
+
+ result, err := conn.Exec(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot soft delete device: %w", err)
+ }
+
+ if result.RowsAffected() == 0 {
+ return ErrResourceNotFound
+ }
+
+ d.DeletedAt = &now
+ d.UpdatedAt = now
+
+ return nil
+}
+
+// DeleteOrphans hard-deletes PENDING and REVOKED devices that never received
+// an API key, have no posture history, and have no non-expired enrollment
+// token. Soft-deleted rows are included so user tombstones without history
+// can be reclaimed; soft-deleted rows that retain posture history are kept.
+func (d *Device) DeleteOrphans(
+ ctx context.Context,
+ conn pg.Tx,
+ now time.Time,
+) (int64, error) {
+ q := `
+DELETE FROM devices d
+WHERE
+ d.state IN (@pending_state, @revoked_state)
+ AND d.api_key_hash IS NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM device_postures p
+ WHERE p.device_id = d.id
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM device_enrollment_tokens t
+ WHERE t.device_id = d.id
+ AND t.expires_at >= @now
+ )
+`
+
+ args := pgx.StrictNamedArgs{
+ "pending_state": DeviceStatePending,
+ "revoked_state": DeviceStateRevoked,
+ "now": now,
+ }
+
+ result, err := conn.Exec(ctx, q, args)
+ if err != nil {
+ return 0, fmt.Errorf("cannot delete orphan devices: %w", err)
+ }
+
+ return result.RowsAffected(), nil
+}
diff --git a/pkg/coredata/device_enrollment_token.go b/pkg/coredata/device_enrollment_token.go
index cb275cef2..c1eba6760 100644
--- a/pkg/coredata/device_enrollment_token.go
+++ b/pkg/coredata/device_enrollment_token.go
@@ -24,6 +24,7 @@ import (
"context"
"errors"
"fmt"
+ "maps"
"time"
"github.com/jackc/pgx/v5"
@@ -167,3 +168,29 @@ WHERE
return nil
}
+
+func (t *DeviceEnrollmentToken) DeleteByDeviceID(
+ ctx context.Context,
+ conn pg.Tx,
+ scope Scoper,
+ deviceID gid.GID,
+) error {
+ q := `
+DELETE FROM device_enrollment_tokens
+WHERE
+ %s
+ AND device_id = @device_id
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"device_id": deviceID}
+ maps.Copy(args, scope.SQLArguments())
+
+ _, err := conn.Exec(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot delete device_enrollment_tokens by device: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/coredata/device_orphans_test.go b/pkg/coredata/device_orphans_test.go
new file mode 100644
index 000000000..f7a41b9ca
--- /dev/null
+++ b/pkg/coredata/device_orphans_test.go
@@ -0,0 +1,217 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package coredata_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.gearno.de/kit/pg"
+ "go.probo.inc/probo/internal/test"
+ "go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/gid"
+)
+
+func TestDevice_DeleteOrphans(t *testing.T) {
+ t.Parallel()
+
+ client := test.PGClient(t)
+ ctx := context.Background()
+ now := time.Now().UTC().Truncate(time.Microsecond)
+
+ tenantID := gid.NewTenantID()
+ scope := coredata.NewScope(tenantID)
+ organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
+
+ orphanPendingID := gid.New(tenantID, coredata.DeviceEntityType)
+ validTokenID := gid.New(tenantID, coredata.DeviceEntityType)
+ withKeyID := gid.New(tenantID, coredata.DeviceEntityType)
+ orphanRevokedID := gid.New(tenantID, coredata.DeviceEntityType)
+ softDeletedOrphanID := gid.New(tenantID, coredata.DeviceEntityType)
+ softDeletedWithPostureID := gid.New(tenantID, coredata.DeviceEntityType)
+
+ deviceIDs := []string{
+ orphanPendingID.String(),
+ validTokenID.String(),
+ withKeyID.String(),
+ orphanRevokedID.String(),
+ softDeletedOrphanID.String(),
+ softDeletedWithPostureID.String(),
+ }
+
+ require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
+ org := &coredata.Organization{
+ ID: organizationID,
+ TenantID: tenantID,
+ Name: "Orphan Devices GC Org",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := org.Insert(ctx, tx); err != nil {
+ return err
+ }
+
+ insertPending := func(id gid.GID, apiKeyHash []byte) error {
+ device := coredata.Device{
+ ID: id,
+ OrganizationID: organizationID,
+ State: coredata.DeviceStatePending,
+ APIKeyHash: apiKeyHash,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ return device.Insert(ctx, tx, scope)
+ }
+
+ if err := insertPending(orphanPendingID, nil); err != nil {
+ return err
+ }
+
+ if err := insertPending(validTokenID, nil); err != nil {
+ return err
+ }
+
+ if err := insertPending(withKeyID, []byte("orphan-gc-key-"+withKeyID.String())); err != nil {
+ return err
+ }
+
+ revokedAt := now
+
+ orphanRevoked := coredata.Device{
+ ID: orphanRevokedID,
+ OrganizationID: organizationID,
+ State: coredata.DeviceStateRevoked,
+ RevokedAt: &revokedAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := orphanRevoked.Insert(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ softDeletedOrphan := coredata.Device{
+ ID: softDeletedOrphanID,
+ OrganizationID: organizationID,
+ State: coredata.DeviceStateRevoked,
+ RevokedAt: &revokedAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := softDeletedOrphan.Insert(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ if err := softDeletedOrphan.SoftDelete(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ softDeletedWithPosture := coredata.Device{
+ ID: softDeletedWithPostureID,
+ OrganizationID: organizationID,
+ State: coredata.DeviceStateRevoked,
+ RevokedAt: &revokedAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := softDeletedWithPosture.Insert(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ if err := softDeletedWithPosture.SoftDelete(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ posture := coredata.DevicePosture{
+ ID: gid.New(tenantID, coredata.DevicePostureEntityType),
+ OrganizationID: organizationID,
+ DeviceID: softDeletedWithPostureID,
+ CorrelationID: gid.New(tenantID, coredata.DevicePostureReportEntityType),
+ CheckKey: "DISK_ENCRYPTION",
+ Status: coredata.DevicePostureStatusPass,
+ ObservedAt: now,
+ CreatedAt: now,
+ }
+ if err := posture.Insert(ctx, tx, scope); err != nil {
+ return err
+ }
+
+ token := coredata.DeviceEnrollmentToken{
+ ID: gid.New(tenantID, coredata.DeviceEnrollmentTokenEntityType),
+ DeviceID: validTokenID,
+ HashedValue: []byte("orphan-gc-token-" + validTokenID.String()),
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now,
+ }
+
+ return token.Insert(ctx, tx, scope)
+ }))
+
+ t.Cleanup(func() {
+ _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
+ _, _ = tx.Exec(ctx, `DELETE FROM device_postures WHERE device_id = ANY($1)`, deviceIDs)
+ _, _ = tx.Exec(ctx, `DELETE FROM device_enrollment_tokens WHERE device_id = ANY($1)`, deviceIDs)
+ _, _ = tx.Exec(ctx, `DELETE FROM devices WHERE id = ANY($1)`, deviceIDs)
+ _, _ = tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID)
+
+ return nil
+ })
+ })
+
+ require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
+ var device coredata.Device
+
+ deleted, err := device.DeleteOrphans(ctx, tx, now)
+ require.NoError(t, err)
+ require.Equal(t, int64(3), deleted)
+
+ var remaining coredata.Device
+ require.ErrorIs(t, remaining.LoadByID(ctx, tx, scope, orphanPendingID), coredata.ErrResourceNotFound)
+ require.ErrorIs(t, remaining.LoadByID(ctx, tx, scope, orphanRevokedID), coredata.ErrResourceNotFound)
+
+ require.NoError(t, remaining.LoadByID(ctx, tx, scope, validTokenID))
+ require.NoError(t, remaining.LoadByID(ctx, tx, scope, withKeyID))
+
+ var softDeletedOrphanCount int
+
+ err = tx.QueryRow(
+ ctx,
+ `SELECT COUNT(*) FROM devices WHERE id = $1`,
+ softDeletedOrphanID,
+ ).Scan(&softDeletedOrphanCount)
+ require.NoError(t, err)
+ require.Equal(t, 0, softDeletedOrphanCount)
+
+ var softDeletedWithHistory int
+
+ err = tx.QueryRow(
+ ctx,
+ `SELECT COUNT(*) FROM devices WHERE id = $1 AND deleted_at IS NOT NULL`,
+ softDeletedWithPostureID,
+ ).Scan(&softDeletedWithHistory)
+ require.NoError(t, err)
+ require.Equal(t, 1, softDeletedWithHistory)
+
+ return nil
+ }))
+}
diff --git a/pkg/coredata/migrations/20260729T171926Z.sql b/pkg/coredata/migrations/20260729T171926Z.sql
new file mode 100644
index 000000000..56688e7d1
--- /dev/null
+++ b/pkg/coredata/migrations/20260729T171926Z.sql
@@ -0,0 +1,35 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+-- SOFTWARE.
+
+ALTER TABLE devices
+ ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE;
+
+ALTER TABLE devices
+ ADD CONSTRAINT devices_deleted_at_check CHECK (
+ deleted_at IS NULL OR state = 'REVOKED'
+ );
+
+DROP INDEX devices_org_hardware_uuid_idx;
+
+CREATE UNIQUE INDEX devices_org_hardware_uuid_idx
+ ON devices (organization_id, hardware_uuid)
+ WHERE hardware_uuid IS NOT NULL
+ AND state != 'REVOKED'
+ AND deleted_at IS NULL;
diff --git a/pkg/itam/actions.go b/pkg/itam/actions.go
index fcd5ec804..a1b4358f4 100644
--- a/pkg/itam/actions.go
+++ b/pkg/itam/actions.go
@@ -31,6 +31,7 @@ const (
ActionDeviceCreate = "itam:device:create"
ActionDeviceEnroll = "itam:device:enroll"
ActionDeviceRevoke = "itam:device:revoke"
+ ActionDeviceDelete = "itam:device:delete"
ActionDeviceAssignOwner = "itam:device:assign"
// DevicePosture actions
diff --git a/pkg/itam/gc.go b/pkg/itam/gc.go
index 989aa3cef..8fdf94577 100644
--- a/pkg/itam/gc.go
+++ b/pkg/itam/gc.go
@@ -100,10 +100,18 @@ func (h *gcHandler) cleanup(ctx context.Context) error {
return fmt.Errorf("cannot delete expired device enrollment tokens: %w", err)
}
+ var device coredata.Device
+
+ devicesDeleted, err := device.DeleteOrphans(ctx, tx, now)
+ if err != nil {
+ return fmt.Errorf("cannot delete orphan devices: %w", err)
+ }
+
h.logger.InfoCtx(
ctx,
"itam garbage collector cleaned up",
log.Int64("device_enrollment_tokens_deleted", tokensDeleted),
+ log.Int64("orphan_devices_deleted", devicesDeleted),
)
return nil
diff --git a/pkg/itam/policies.go b/pkg/itam/policies.go
index fdcc26e7a..3a69e307f 100644
--- a/pkg/itam/policies.go
+++ b/pkg/itam/policies.go
@@ -37,7 +37,7 @@ var FullAccessPolicy = policy.NewPolicy(
"ITAM Full Access",
policy.Allow(
ActionDeviceList, ActionEmployeeDeviceList, ActionDeviceGet, ActionDeviceCreate,
- ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceAssignOwner,
+ ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceDelete, ActionDeviceAssignOwner,
ActionDevicePostureList,
).WithSID("itam-full-access").When(organizationCondition),
policy.Allow(ActionEmployeeDeviceGet).
diff --git a/pkg/itam/service.go b/pkg/itam/service.go
index 93c800d04..e218bbcdc 100644
--- a/pkg/itam/service.go
+++ b/pkg/itam/service.go
@@ -58,6 +58,10 @@ var (
// cannot be exchanged for the device.
ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid")
+ // ErrDeviceNotDeletable is returned when a device cannot be soft-deleted
+ // because it is not REVOKED.
+ ErrDeviceNotDeletable = errors.New("device cannot be deleted")
+
// ErrCorrelationIDRequired is returned when a posture result is
// missing a correlation ID.
ErrCorrelationIDRequired = errors.New("correlation_id is required")
@@ -553,6 +557,43 @@ func (s *Service) RevokeDevice(
return device, nil
}
+func (s *Service) DeleteDevice(
+ ctx context.Context,
+ scope coredata.Scoper,
+ deviceID gid.GID,
+) (*coredata.Device, error) {
+ device := &coredata.Device{}
+
+ err := s.pg.WithTx(
+ ctx,
+ func(ctx context.Context, conn pg.Tx) error {
+ if err := device.LoadByIDForUpdate(ctx, conn, scope, deviceID); err != nil {
+ return fmt.Errorf("cannot load device: %w", err)
+ }
+
+ if device.State != coredata.DeviceStateRevoked {
+ return ErrDeviceNotDeletable
+ }
+
+ if err := device.SoftDelete(ctx, conn, scope); err != nil {
+ return fmt.Errorf("cannot soft delete device: %w", err)
+ }
+
+ var token coredata.DeviceEnrollmentToken
+ if err := token.DeleteByDeviceID(ctx, conn, scope, device.ID); err != nil {
+ return fmt.Errorf("cannot delete device enrollment tokens: %w", err)
+ }
+
+ return nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return device, nil
+}
+
func (s *Service) SetDeviceOwner(
ctx context.Context,
scope coredata.Scoper,
diff --git a/pkg/server/api/console/v1/device_resolvers.go b/pkg/server/api/console/v1/device_resolvers.go
index 5590f854e..50ed0ad22 100644
--- a/pkg/server/api/console/v1/device_resolvers.go
+++ b/pkg/server/api/console/v1/device_resolvers.go
@@ -239,6 +239,31 @@ func (r *mutationResolver) RevokeDevice(ctx context.Context, input types.RevokeD
return &types.RevokeDevicePayload{Device: types.NewDevice(d)}, nil
}
+// DeleteDevice is the resolver for the deleteDevice field.
+func (r *mutationResolver) DeleteDevice(ctx context.Context, input types.DeleteDeviceInput) (*types.DeleteDevicePayload, error) {
+ scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceDelete)
+ if err != nil {
+ return nil, err
+ }
+
+ d, err := r.itam.DeleteDevice(ctx, scope, input.DeviceID)
+ if err != nil {
+ if errors.Is(err, coredata.ErrResourceNotFound) {
+ return nil, gqlutils.NotFound(ctx, err)
+ }
+
+ if errors.Is(err, itam.ErrDeviceNotDeletable) {
+ return nil, gqlutils.Conflict(ctx, err)
+ }
+
+ r.logger.ErrorCtx(ctx, "cannot delete device", log.Error(err))
+
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ return &types.DeleteDevicePayload{DeletedDeviceID: d.ID}, nil
+}
+
// SetDeviceOwner is the resolver for the setDeviceOwner field.
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
diff --git a/pkg/server/api/console/v1/graphql/device.graphql b/pkg/server/api/console/v1/graphql/device.graphql
index 64d727d8b..0b4d08acc 100644
--- a/pkg/server/api/console/v1/graphql/device.graphql
+++ b/pkg/server/api/console/v1/graphql/device.graphql
@@ -237,6 +237,10 @@ type RevokeDevicePayload {
device: Device!
}
+type DeleteDevicePayload {
+ deletedDeviceId: ID!
+}
+
type SetDeviceOwnerPayload {
device: Device!
}
@@ -254,6 +258,10 @@ input RevokeDeviceInput {
deviceId: ID!
}
+input DeleteDeviceInput {
+ deviceId: ID!
+}
+
input SetDeviceOwnerInput {
deviceId: ID!
ownerId: ID
@@ -263,6 +271,7 @@ extend type Mutation {
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
+ deleteDevice(input: DeleteDeviceInput!): DeleteDevicePayload!
setDeviceOwner(
input: SetDeviceOwnerInput!
): SetDeviceOwnerPayload!