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:
Ludovic Vielle
2026-07-30 10:11:08 +02:00
parent 41da4bbad1
commit 7731566c68
23 changed files with 773 additions and 61 deletions

View File

@@ -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()