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

@@ -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
}

View File

@@ -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
}

View 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
}))
}

View 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;