Add soft delete for revoked devices
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 <ludovic@probo.com>
This commit is contained in:
@@ -2513,7 +2513,7 @@
|
|||||||
"auth": { "actions": { "signIn": "Sign in" } },
|
"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." } },
|
"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" } },
|
"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" },
|
"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" } },
|
"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": {
|
"thirdPartyRiskAssessmentRow": {
|
||||||
|
|||||||
@@ -5958,7 +5958,8 @@
|
|||||||
"add": "Ajouter un appareil",
|
"add": "Ajouter un appareil",
|
||||||
"new": "Nouvel appareil",
|
"new": "Nouvel appareil",
|
||||||
"reassign": "Réattribuer",
|
"reassign": "Réattribuer",
|
||||||
"revoke": "Révoquer"
|
"revoke": "Révoquer",
|
||||||
|
"delete": "Supprimer"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"organization": "Organisation",
|
"organization": "Organisation",
|
||||||
@@ -5980,15 +5981,18 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"ownerUpdated": "Propriétaire de l’appareil mis à jour",
|
"ownerUpdated": "Propriétaire de l’appareil mis à jour",
|
||||||
"revoked": "Appareil révoqué"
|
"revoked": "Appareil révoqué",
|
||||||
|
"deleted": "Appareil supprimé"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"create": "Échec de la création de l’appareil",
|
"create": "Échec de la création de l’appareil",
|
||||||
"reassign": "Échec de la réattribution 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": {
|
"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": {
|
"postures": {
|
||||||
"currentTitle": "Postures actuelles",
|
"currentTitle": "Postures actuelles",
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const trackerPatternDetectedTrackersSectionFragment = graphql`
|
|||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
|
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
|
||||||
__id
|
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet, useNavigate } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql";
|
import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql";
|
||||||
@@ -33,7 +33,8 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
|||||||
|
|
||||||
import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures";
|
import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures";
|
||||||
import { DeviceDetailsCard } from "./_components/DeviceDetailsCard";
|
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";
|
import { useRevokeDevice } from "./_lib/useRevokeDevice";
|
||||||
|
|
||||||
export const deviceLayoutQuery = graphql`
|
export const deviceLayoutQuery = graphql`
|
||||||
@@ -52,6 +53,7 @@ export const deviceLayoutQuery = graphql`
|
|||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canRevokeDevice: permission(action: "itam:device:revoke")
|
canRevokeDevice: permission(action: "itam:device:revoke")
|
||||||
|
canDeleteDevice: permission(action: "itam:device:delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,7 @@ interface DeviceLayoutProps {
|
|||||||
|
|
||||||
export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const pendingLabel = t("devices.values.pending");
|
const pendingLabel = t("devices.values.pending");
|
||||||
|
|
||||||
@@ -82,9 +85,16 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
|||||||
const hostnameLabel = displayValue(device.hostname, pendingLabel);
|
const hostnameLabel = displayValue(device.hostname, pendingLabel);
|
||||||
|
|
||||||
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
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 canRevokeDevice = organization.canRevokeDevice ?? false;
|
||||||
|
const canDeleteDevice = organization.canDeleteDevice ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
@@ -98,9 +108,10 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PageHeader title={hostnameLabel}>
|
<PageHeader title={hostnameLabel}>
|
||||||
{!isRevoked && canRevokeDevice && (
|
{!deletable && canRevokeDevice && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
icon={IconEject}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
||||||
disabled={isRevoking}
|
disabled={isRevoking}
|
||||||
@@ -108,6 +119,17 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
|||||||
{t("devices.actions.revoke")}
|
{t("devices.actions.revoke")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{deletable && canDeleteDevice && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
onClick={() =>
|
||||||
|
confirmDelete({ id: device.id, hostname: device.hostname })}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t("devices.actions.delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<DeviceDetailsCard deviceFragmentRef={device} />
|
<DeviceDetailsCard deviceFragmentRef={device} />
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const devicesPageQuery = graphql`
|
|||||||
id
|
id
|
||||||
canAssignDevice: permission(action: "itam:device:assign")
|
canAssignDevice: permission(action: "itam:device:assign")
|
||||||
canRevokeDevice: permission(action: "itam:device:revoke")
|
canRevokeDevice: permission(action: "itam:device:revoke")
|
||||||
|
canDeleteDevice: permission(action: "itam:device:delete")
|
||||||
canCreateDevice: permission(action: "itam:device:create")
|
canCreateDevice: permission(action: "itam:device:create")
|
||||||
...DevicesPageFragment
|
...DevicesPageFragment
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,8 @@ const devicesPageFragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
) @connection(key: "DevicesPage_devices", filters: ["orderBy"]) {
|
) @connection(key: "DevicesPage_devices", filters: []) {
|
||||||
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -107,6 +109,7 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
|
|||||||
>(devicesPageFragment, organization);
|
>(devicesPageFragment, organization);
|
||||||
|
|
||||||
const devices = pagination.data.devices.edges.map(edge => edge.node);
|
const devices = pagination.data.devices.edges.map(edge => edge.node);
|
||||||
|
const connectionId = pagination.data.devices.__id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -148,8 +151,10 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
|
|||||||
<DeviceRow
|
<DeviceRow
|
||||||
key={device.id}
|
key={device.id}
|
||||||
fKey={device}
|
fKey={device}
|
||||||
|
connectionId={connectionId}
|
||||||
canAssignDevice={organization.canAssignDevice ?? false}
|
canAssignDevice={organization.canAssignDevice ?? false}
|
||||||
canRevoke={organization.canRevokeDevice ?? false}
|
canRevoke={organization.canRevokeDevice ?? false}
|
||||||
|
canDelete={organization.canDeleteDevice ?? false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
Badge,
|
Badge,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
IconEject,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
IconUser,
|
IconUser,
|
||||||
Td,
|
Td,
|
||||||
@@ -36,7 +37,8 @@ import { graphql } from "relay-runtime";
|
|||||||
import type { DeviceRowFragment$key } from "#/__generated__/core/DeviceRowFragment.graphql";
|
import type { DeviceRowFragment$key } from "#/__generated__/core/DeviceRowFragment.graphql";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
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 { useRevokeDevice } from "../_lib/useRevokeDevice";
|
||||||
import { ReassignDeviceDialog } from "../dialogs/ReassignDeviceDialog";
|
import { ReassignDeviceDialog } from "../dialogs/ReassignDeviceDialog";
|
||||||
|
|
||||||
@@ -58,11 +60,19 @@ const deviceRowFragment = graphql`
|
|||||||
|
|
||||||
interface DeviceRowProps {
|
interface DeviceRowProps {
|
||||||
canAssignDevice: boolean;
|
canAssignDevice: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
|
connectionId: string;
|
||||||
fKey: DeviceRowFragment$key;
|
fKey: DeviceRowFragment$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps) {
|
export function DeviceRow({
|
||||||
|
canAssignDevice,
|
||||||
|
canDelete,
|
||||||
|
canRevoke,
|
||||||
|
connectionId,
|
||||||
|
fKey,
|
||||||
|
}: DeviceRowProps) {
|
||||||
const { i18n, t } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const reassignDialogRef = useDialogRef();
|
const reassignDialogRef = useDialogRef();
|
||||||
@@ -71,9 +81,16 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
|||||||
const device = useFragment(deviceRowFragment, fKey);
|
const device = useFragment(deviceRowFragment, fKey);
|
||||||
|
|
||||||
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
||||||
|
const [confirmDelete, isDeleting] = useDeleteDevice({
|
||||||
|
organizationId,
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
|
|
||||||
const isRevoked = device.state === "REVOKED";
|
const deletable = isDeviceDeletable(device.state);
|
||||||
const hasActions = !isRevoked && (canRevoke || canAssignDevice);
|
const showAssign = canAssignDevice && !deletable;
|
||||||
|
const showRevoke = canRevoke && !deletable;
|
||||||
|
const showDelete = canDelete && deletable;
|
||||||
|
const hasActions = showAssign || showRevoke || showDelete;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -98,7 +115,7 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
|||||||
<Td noLink width={50} className="text-end">
|
<Td noLink width={50} className="text-end">
|
||||||
{hasActions && (
|
{hasActions && (
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
{canAssignDevice && (
|
{showAssign && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
icon={IconUser}
|
icon={IconUser}
|
||||||
onClick={() => reassignDialogRef.current?.open()}
|
onClick={() => reassignDialogRef.current?.open()}
|
||||||
@@ -106,17 +123,28 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
|||||||
{t("devices.actions.reassign")}
|
{t("devices.actions.reassign")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
{canRevoke && (
|
{showRevoke && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
confirmRevoke({ id: device.id, hostname: device.hostname })}
|
||||||
disabled={isRevoking}
|
disabled={isRevoking}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={IconTrashCan}
|
icon={IconEject}
|
||||||
>
|
>
|
||||||
{t("devices.actions.revoke")}
|
{t("devices.actions.revoke")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
|
{showDelete && (
|
||||||
|
<DropdownItem
|
||||||
|
onClick={() =>
|
||||||
|
confirmDelete({ id: device.id, hostname: device.hostname })}
|
||||||
|
disabled={isDeleting}
|
||||||
|
variant="danger"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
>
|
||||||
|
{t("devices.actions.delete")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
)}
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ export function PostureValueBadge({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const posture = useFragment(postureFragment, postureFragmentRef);
|
const posture = useFragment(postureFragment, postureFragmentRef);
|
||||||
|
|
||||||
const label = postureValueLabel(t, posture.value);
|
const { kind, text, number } = posture.value;
|
||||||
const variant = postureValueVariant(posture.value.kind, posture.checkKey);
|
const label = postureValueLabel(t, { kind, text, number });
|
||||||
|
const variant = postureValueVariant(kind, posture.checkKey);
|
||||||
|
|
||||||
if (!variant) {
|
if (!variant) {
|
||||||
return label;
|
return label;
|
||||||
|
|||||||
@@ -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, unknown>) => string;
|
type Translator = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
/** What a posture check observed, as returned by the DevicePostureValue type. */
|
/** What a posture check observed, as returned by the DevicePostureValue type. */
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<useDeleteDeviceMutation>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -18,14 +18,13 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { formatError } from "@probo/helpers";
|
import { useConfirm } from "@probo/ui";
|
||||||
import { useConfirm, useToast } from "@probo/ui";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation } from "react-relay";
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { useRevokeDeviceMutation } from "#/__generated__/core/useRevokeDeviceMutation.graphql";
|
import type { useRevokeDeviceMutation } from "#/__generated__/core/useRevokeDeviceMutation.graphql";
|
||||||
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
|
|
||||||
import { displayValue } from "./deviceDisplay";
|
import { displayValue } from "./deviceDisplay";
|
||||||
|
|
||||||
@@ -49,58 +48,34 @@ interface RevokeDeviceInput {
|
|||||||
|
|
||||||
export function useRevokeDevice() {
|
export function useRevokeDevice() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const pendingLabel = t("devices.values.pending");
|
const pendingLabel = t("devices.values.pending");
|
||||||
|
|
||||||
const [revokeDevice, isRevoking] = useMutation<useRevokeDeviceMutation>(
|
const [revokeDevice, isRevoking] = useMutation<useRevokeDeviceMutation>(
|
||||||
revokeDeviceMutation,
|
revokeDeviceMutation,
|
||||||
|
{
|
||||||
|
successMessage: t("devices.messages.revoked"),
|
||||||
|
errorToast: t("devices.errors.revoke"),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmRevoke = useCallback(
|
const confirmRevoke = useCallback(
|
||||||
(device: RevokeDeviceInput) => {
|
(device: RevokeDeviceInput) => {
|
||||||
confirm(
|
confirm(
|
||||||
() =>
|
() =>
|
||||||
new Promise<void>((resolve) => {
|
revokeDevice({
|
||||||
revokeDevice({
|
variables: { input: { deviceId: device.id } },
|
||||||
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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
message: t("devices.confirmations.revoke", { hostname: displayValue(device.hostname, pendingLabel) }),
|
message: t("devices.confirmations.revoke", {
|
||||||
|
hostname: displayValue(device.hostname, pendingLabel),
|
||||||
|
}),
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
label: t("devices.actions.revoke"),
|
label: t("devices.actions.revoke"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[t, confirm, pendingLabel, revokeDevice, toast],
|
[t, confirm, pendingLabel, revokeDevice],
|
||||||
);
|
);
|
||||||
|
|
||||||
return [confirmRevoke, isRevoking] as const;
|
return [confirmRevoke, isRevoking] as const;
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const deviceFragment = graphql`
|
|||||||
) @connection(key: "DevicePostureReportListFragment_postureReports") {
|
) @connection(key: "DevicePostureReportListFragment_postureReports") {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
createdAt
|
|
||||||
id
|
id
|
||||||
...DevicePostureReportListItemFragment
|
...DevicePostureReportListItemFragment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ const (
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
deleteDeviceMutation = `
|
||||||
|
mutation DeleteDevice($input: DeleteDeviceInput!) {
|
||||||
|
deleteDevice(input: $input) {
|
||||||
|
deletedDeviceId
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
devicePermissionQuery = `
|
devicePermissionQuery = `
|
||||||
query DevicePermission($orgId: ID!) {
|
query DevicePermission($orgId: ID!) {
|
||||||
node(id: $orgId) {
|
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) {
|
func TestDeviceEnrollmentPermissionQueryShape(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export { IconBrandX } from "./IconBrandX";
|
|||||||
export { IconGlobe } from "./IconGlobe";
|
export { IconGlobe } from "./IconGlobe";
|
||||||
export {
|
export {
|
||||||
ArrowsClockwise as IconArrowsClockwise,
|
ArrowsClockwise as IconArrowsClockwise,
|
||||||
|
Eject as IconEject,
|
||||||
Envelope as IconEnvelope,
|
Envelope as IconEnvelope,
|
||||||
LockOpen as IconLockOpen,
|
LockOpen as IconLockOpen,
|
||||||
User as IconUser,
|
User as IconUser,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type (
|
|||||||
EnrolledAt *time.Time `db:"enrolled_at"`
|
EnrolledAt *time.Time `db:"enrolled_at"`
|
||||||
LastSeenAt *time.Time `db:"last_seen_at"`
|
LastSeenAt *time.Time `db:"last_seen_at"`
|
||||||
RevokedAt *time.Time `db:"revoked_at"`
|
RevokedAt *time.Time `db:"revoked_at"`
|
||||||
|
DeletedAt *time.Time `db:"deleted_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,7 @@ FROM
|
|||||||
devices
|
devices
|
||||||
WHERE
|
WHERE
|
||||||
id = ANY(@resource_ids::text[])
|
id = ANY(@resource_ids::text[])
|
||||||
|
AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"resource_ids": resourceIDs})
|
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"resource_ids": resourceIDs})
|
||||||
@@ -195,6 +197,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -202,6 +205,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
@@ -252,6 +256,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -259,6 +264,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE;
|
FOR UPDATE;
|
||||||
`
|
`
|
||||||
@@ -311,6 +317,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -318,6 +325,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
api_key_hash = @api_key_hash
|
api_key_hash = @api_key_hash
|
||||||
AND state != @revoked_state
|
AND state != @revoked_state
|
||||||
|
AND deleted_at IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -370,6 +378,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -378,6 +387,7 @@ WHERE
|
|||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND hardware_uuid = @hardware_uuid
|
AND hardware_uuid = @hardware_uuid
|
||||||
|
AND deleted_at IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
@@ -504,6 +514,7 @@ WHERE %s
|
|||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
AND state = @pending_state
|
AND state = @pending_state
|
||||||
AND api_key_hash IS NULL
|
AND api_key_hash IS NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
`, scope.SQLFragment())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -556,6 +567,7 @@ SET
|
|||||||
WHERE %s
|
WHERE %s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
AND state = @pending_state
|
AND state = @pending_state
|
||||||
|
AND deleted_at IS NULL
|
||||||
`, scope.SQLFragment())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -611,6 +623,7 @@ SET
|
|||||||
WHERE %s
|
WHERE %s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
AND state = @active_state
|
AND state = @active_state
|
||||||
|
AND deleted_at IS NULL
|
||||||
`, scope.SQLFragment())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -654,6 +667,7 @@ SET
|
|||||||
updated_at = @now
|
updated_at = @now
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
`, scope.SQLFragment())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -697,6 +711,7 @@ SET
|
|||||||
updated_at = @now
|
updated_at = @now
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND id = @device_id
|
AND id = @device_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
`, scope.SQLFragment())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -746,6 +761,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -753,6 +769,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
@@ -802,6 +819,7 @@ SELECT
|
|||||||
enrolled_at,
|
enrolled_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
|
deleted_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -811,6 +829,7 @@ WHERE
|
|||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND owner_profile_id = @owner_profile_id
|
AND owner_profile_id = @owner_profile_id
|
||||||
AND state = @active_state
|
AND state = @active_state
|
||||||
|
AND deleted_at IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
@@ -846,7 +865,7 @@ func (ds *Devices) CountByOrganizationID(
|
|||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := fmt.Sprintf(`
|
q := fmt.Sprintf(`
|
||||||
SELECT COUNT(id) FROM devices
|
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())
|
`, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
@@ -877,6 +896,7 @@ WHERE
|
|||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND owner_profile_id = @owner_profile_id
|
AND owner_profile_id = @owner_profile_id
|
||||||
AND state = @active_state
|
AND state = @active_state
|
||||||
|
AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
@@ -894,3 +914,85 @@ WHERE
|
|||||||
|
|
||||||
return count, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -167,3 +168,29 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
217
pkg/coredata/device_orphans_test.go
Normal file
217
pkg/coredata/device_orphans_test.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}))
|
||||||
|
}
|
||||||
35
pkg/coredata/migrations/20260729T171926Z.sql
Normal file
35
pkg/coredata/migrations/20260729T171926Z.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
--
|
||||||
|
-- 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;
|
||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
ActionDeviceCreate = "itam:device:create"
|
ActionDeviceCreate = "itam:device:create"
|
||||||
ActionDeviceEnroll = "itam:device:enroll"
|
ActionDeviceEnroll = "itam:device:enroll"
|
||||||
ActionDeviceRevoke = "itam:device:revoke"
|
ActionDeviceRevoke = "itam:device:revoke"
|
||||||
|
ActionDeviceDelete = "itam:device:delete"
|
||||||
ActionDeviceAssignOwner = "itam:device:assign"
|
ActionDeviceAssignOwner = "itam:device:assign"
|
||||||
|
|
||||||
// DevicePosture actions
|
// DevicePosture actions
|
||||||
|
|||||||
@@ -100,10 +100,18 @@ func (h *gcHandler) cleanup(ctx context.Context) error {
|
|||||||
return fmt.Errorf("cannot delete expired device enrollment tokens: %w", err)
|
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(
|
h.logger.InfoCtx(
|
||||||
ctx,
|
ctx,
|
||||||
"itam garbage collector cleaned up",
|
"itam garbage collector cleaned up",
|
||||||
log.Int64("device_enrollment_tokens_deleted", tokensDeleted),
|
log.Int64("device_enrollment_tokens_deleted", tokensDeleted),
|
||||||
|
log.Int64("orphan_devices_deleted", devicesDeleted),
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var FullAccessPolicy = policy.NewPolicy(
|
|||||||
"ITAM Full Access",
|
"ITAM Full Access",
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
ActionDeviceList, ActionEmployeeDeviceList, ActionDeviceGet, ActionDeviceCreate,
|
ActionDeviceList, ActionEmployeeDeviceList, ActionDeviceGet, ActionDeviceCreate,
|
||||||
ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceAssignOwner,
|
ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceDelete, ActionDeviceAssignOwner,
|
||||||
ActionDevicePostureList,
|
ActionDevicePostureList,
|
||||||
).WithSID("itam-full-access").When(organizationCondition),
|
).WithSID("itam-full-access").When(organizationCondition),
|
||||||
policy.Allow(ActionEmployeeDeviceGet).
|
policy.Allow(ActionEmployeeDeviceGet).
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ var (
|
|||||||
// cannot be exchanged for the device.
|
// cannot be exchanged for the device.
|
||||||
ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid")
|
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
|
// ErrCorrelationIDRequired is returned when a posture result is
|
||||||
// missing a correlation ID.
|
// missing a correlation ID.
|
||||||
ErrCorrelationIDRequired = errors.New("correlation_id is required")
|
ErrCorrelationIDRequired = errors.New("correlation_id is required")
|
||||||
@@ -553,6 +557,43 @@ func (s *Service) RevokeDevice(
|
|||||||
return device, nil
|
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(
|
func (s *Service) SetDeviceOwner(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
|
|||||||
@@ -239,6 +239,31 @@ func (r *mutationResolver) RevokeDevice(ctx context.Context, input types.RevokeD
|
|||||||
return &types.RevokeDevicePayload{Device: types.NewDevice(d)}, nil
|
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.
|
// SetDeviceOwner is the resolver for the setDeviceOwner field.
|
||||||
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
|
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
|
||||||
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
|
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ type RevokeDevicePayload {
|
|||||||
device: Device!
|
device: Device!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteDevicePayload {
|
||||||
|
deletedDeviceId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
type SetDeviceOwnerPayload {
|
type SetDeviceOwnerPayload {
|
||||||
device: Device!
|
device: Device!
|
||||||
}
|
}
|
||||||
@@ -254,6 +258,10 @@ input RevokeDeviceInput {
|
|||||||
deviceId: ID!
|
deviceId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input DeleteDeviceInput {
|
||||||
|
deviceId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
input SetDeviceOwnerInput {
|
input SetDeviceOwnerInput {
|
||||||
deviceId: ID!
|
deviceId: ID!
|
||||||
ownerId: ID
|
ownerId: ID
|
||||||
@@ -263,6 +271,7 @@ extend type Mutation {
|
|||||||
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
|
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
|
||||||
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
|
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
|
||||||
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
|
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
|
||||||
|
deleteDevice(input: DeleteDeviceInput!): DeleteDevicePayload!
|
||||||
setDeviceOwner(
|
setDeviceOwner(
|
||||||
input: SetDeviceOwnerInput!
|
input: SetDeviceOwnerInput!
|
||||||
): SetDeviceOwnerPayload!
|
): SetDeviceOwnerPayload!
|
||||||
|
|||||||
Reference in New Issue
Block a user