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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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"
|
||||
ActionDeviceEnroll = "itam:device:enroll"
|
||||
ActionDeviceRevoke = "itam:device:revoke"
|
||||
ActionDeviceDelete = "itam:device:delete"
|
||||
ActionDeviceAssignOwner = "itam:device:assign"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
var device coredata.Device
|
||||
|
||||
devicesDeleted, err := device.DeleteOrphans(ctx, tx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete orphan devices: %w", err)
|
||||
}
|
||||
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"itam garbage collector cleaned up",
|
||||
log.Int64("device_enrollment_tokens_deleted", tokensDeleted),
|
||||
log.Int64("orphan_devices_deleted", devicesDeleted),
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -37,7 +37,7 @@ var FullAccessPolicy = policy.NewPolicy(
|
||||
"ITAM Full Access",
|
||||
policy.Allow(
|
||||
ActionDeviceList, ActionEmployeeDeviceList, ActionDeviceGet, ActionDeviceCreate,
|
||||
ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceAssignOwner,
|
||||
ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceDelete, ActionDeviceAssignOwner,
|
||||
ActionDevicePostureList,
|
||||
).WithSID("itam-full-access").When(organizationCondition),
|
||||
policy.Allow(ActionEmployeeDeviceGet).
|
||||
|
||||
@@ -58,6 +58,10 @@ var (
|
||||
// cannot be exchanged for the device.
|
||||
ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid")
|
||||
|
||||
// ErrDeviceNotDeletable is returned when a device cannot be soft-deleted
|
||||
// because it is not REVOKED.
|
||||
ErrDeviceNotDeletable = errors.New("device cannot be deleted")
|
||||
|
||||
// ErrCorrelationIDRequired is returned when a posture result is
|
||||
// missing a correlation ID.
|
||||
ErrCorrelationIDRequired = errors.New("correlation_id is required")
|
||||
@@ -553,6 +557,43 @@ func (s *Service) RevokeDevice(
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDevice(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
deviceID gid.GID,
|
||||
) (*coredata.Device, error) {
|
||||
device := &coredata.Device{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
if err := device.LoadByIDForUpdate(ctx, conn, scope, deviceID); err != nil {
|
||||
return fmt.Errorf("cannot load device: %w", err)
|
||||
}
|
||||
|
||||
if device.State != coredata.DeviceStateRevoked {
|
||||
return ErrDeviceNotDeletable
|
||||
}
|
||||
|
||||
if err := device.SoftDelete(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot soft delete device: %w", err)
|
||||
}
|
||||
|
||||
var token coredata.DeviceEnrollmentToken
|
||||
if err := token.DeleteByDeviceID(ctx, conn, scope, device.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete device enrollment tokens: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetDeviceOwner(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
|
||||
@@ -239,6 +239,31 @@ func (r *mutationResolver) RevokeDevice(ctx context.Context, input types.RevokeD
|
||||
return &types.RevokeDevicePayload{Device: types.NewDevice(d)}, nil
|
||||
}
|
||||
|
||||
// DeleteDevice is the resolver for the deleteDevice field.
|
||||
func (r *mutationResolver) DeleteDevice(ctx context.Context, input types.DeleteDeviceInput) (*types.DeleteDevicePayload, error) {
|
||||
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d, err := r.itam.DeleteDevice(ctx, scope, input.DeviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
if errors.Is(err, itam.ErrDeviceNotDeletable) {
|
||||
return nil, gqlutils.Conflict(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot delete device", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.DeleteDevicePayload{DeletedDeviceID: d.ID}, nil
|
||||
}
|
||||
|
||||
// SetDeviceOwner is the resolver for the setDeviceOwner field.
|
||||
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
|
||||
|
||||
@@ -237,6 +237,10 @@ type RevokeDevicePayload {
|
||||
device: Device!
|
||||
}
|
||||
|
||||
type DeleteDevicePayload {
|
||||
deletedDeviceId: ID!
|
||||
}
|
||||
|
||||
type SetDeviceOwnerPayload {
|
||||
device: Device!
|
||||
}
|
||||
@@ -254,6 +258,10 @@ input RevokeDeviceInput {
|
||||
deviceId: ID!
|
||||
}
|
||||
|
||||
input DeleteDeviceInput {
|
||||
deviceId: ID!
|
||||
}
|
||||
|
||||
input SetDeviceOwnerInput {
|
||||
deviceId: ID!
|
||||
ownerId: ID
|
||||
@@ -263,6 +271,7 @@ extend type Mutation {
|
||||
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
|
||||
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
|
||||
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
|
||||
deleteDevice(input: DeleteDeviceInput!): DeleteDevicePayload!
|
||||
setDeviceOwner(
|
||||
input: SetDeviceOwnerInput!
|
||||
): SetDeviceOwnerPayload!
|
||||
|
||||
Reference in New Issue
Block a user