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>
218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
// 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
|
|
}))
|
|
}
|