From 7731566c688d635588010df4cd7a5252c3c8aaa4 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Thu, 30 Jul 2026 10:11:08 +0200 Subject: [PATCH] Add soft delete for revoked devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins could only revoke devices, so never-enrolled and revoked inventory rows piled up with no way to remove them. Soft-delete is limited to REVOKED devices (revoke first), and ITAM GC now hard-deletes PENDING/REVOKED orphans with no API key, postures, or valid enrollment token—including user tombstones without history. Signed-off-by: Ludovic Vielle --- apps/console/src/_locales/en-US.json | 2 +- apps/console/src/_locales/fr-FR.json | 12 +- .../TrackerPatternDetectedTrackersSection.tsx | 1 - .../organizations/devices/DeviceLayout.tsx | 32 ++- .../organizations/devices/DevicesPage.tsx | 7 +- .../devices/_components/DeviceRow.tsx | 42 +++- .../devices/_components/PostureValueBadge.tsx | 5 +- .../devices/_lib/deviceDisplay.ts | 5 + .../devices/_lib/useDeleteDevice.ts | 115 ++++++++++ .../devices/_lib/useRevokeDevice.ts | 49 +--- .../_components/DevicePostureReportList.tsx | 1 - e2e/console/device_enrollment_test.go | 93 ++++++++ packages/ui/src/Atoms/Icons/index.tsx | 1 + pkg/coredata/device.go | 104 ++++++++- pkg/coredata/device_enrollment_token.go | 27 +++ pkg/coredata/device_orphans_test.go | 217 ++++++++++++++++++ pkg/coredata/migrations/20260729T171926Z.sql | 35 +++ pkg/itam/actions.go | 1 + pkg/itam/gc.go | 8 + pkg/itam/policies.go | 2 +- pkg/itam/service.go | 41 ++++ pkg/server/api/console/v1/device_resolvers.go | 25 ++ .../api/console/v1/graphql/device.graphql | 9 + 23 files changed, 773 insertions(+), 61 deletions(-) create mode 100644 apps/console/src/pages/organizations/devices/_lib/useDeleteDevice.ts create mode 100644 pkg/coredata/device_orphans_test.go create mode 100644 pkg/coredata/migrations/20260729T171926Z.sql 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!