feat: add device deletion endpoint with cascade cleanup and admin UI integration

Add DELETE /admin/devices/{id} endpoint with cascade deletion of device records, WireGuard peers, IP allocations, and device access profile settings. Update device status to 'deleted' and set deleted_at timestamp while preserving revoked_at if already set.

Add deleteDevice API method and delete button to devices page with query invalidation for both devices and device-profile lists. Record admin.device.deleted audit
This commit is contained in:
2026-03-19 22:59:07 +01:00
parent a8a88140af
commit b199b58840
7 changed files with 82 additions and 0 deletions

View File

@@ -225,6 +225,10 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({}) body: JSON.stringify({})
}), }),
deleteDevice: (deviceId: string) =>
request<{ ok: boolean }>(`/admin/devices/${deviceId}`, {
method: "DELETE"
}),
rotateDevice: (deviceId: string) => rotateDevice: (deviceId: string) =>
request<{ ok: boolean }>(`/admin/devices/${deviceId}/rotate`, { request<{ ok: boolean }>(`/admin/devices/${deviceId}/rotate`, {
method: "POST", method: "POST",

View File

@@ -46,6 +46,13 @@ export function DevicesPage() {
mutationFn: api.revokeDevice, mutationFn: api.revokeDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
}); });
const deleteMutation = useMutation({
mutationFn: api.deleteDevice,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["device-profile"] });
}
});
const rotateMutation = useMutation({ const rotateMutation = useMutation({
mutationFn: api.rotateDevice, mutationFn: api.rotateDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
@@ -85,6 +92,7 @@ export function DevicesPage() {
<div className="action-row"> <div className="action-row">
<button className="button" onClick={() => rotateMutation.mutate(rows[0].id)}>Rotate profile</button> <button className="button" onClick={() => rotateMutation.mutate(rows[0].id)}>Rotate profile</button>
<button className="ghost-button" onClick={() => revokeMutation.mutate(rows[0].id)}>Revoke device</button> <button className="ghost-button" onClick={() => revokeMutation.mutate(rows[0].id)}>Revoke device</button>
<button className="ghost-button" onClick={() => deleteMutation.mutate(rows[0].id)}>Delete device</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -196,3 +196,26 @@ func (h *Handler) Rotate(w http.ResponseWriter, r *http.Request) {
} }
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
deviceID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id")
return
}
if err := h.service.Delete(r.Context(), deviceID); err != nil {
apiutil.Error(w, http.StatusInternalServerError, "device_delete_failed", "unable to delete device")
return
}
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &claims.UserID,
EntityType: "device",
EntityID: &deviceID,
EventType: "admin.device.deleted",
Status: "success",
Message: "admin deleted device",
})
}
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
}

View File

@@ -22,6 +22,7 @@ type Repository interface {
GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error) GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error)
SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID uuid.UUID) error SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID uuid.UUID) error
Revoke(ctx context.Context, deviceID uuid.UUID) error Revoke(ctx context.Context, deviceID uuid.UUID) error
Delete(ctx context.Context, deviceID uuid.UUID) error
Rotate(ctx context.Context, deviceID uuid.UUID) error Rotate(ctx context.Context, deviceID uuid.UUID) error
} }
@@ -292,6 +293,44 @@ func (r *PGRepository) Revoke(ctx context.Context, deviceID uuid.UUID) error {
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (r *PGRepository) Delete(ctx context.Context, deviceID uuid.UUID) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
update devices
set status = 'deleted', deleted_at = now(), revoked_at = coalesce(revoked_at, now()), updated_at = now()
where id = $1 and deleted_at is null
`, deviceID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
update wireguard_peers
set deleted_at = now(), updated_at = now()
where device_id = $1 and deleted_at is null
`, deviceID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
update ip_allocations
set status = 'released', released_at = coalesce(released_at, now()), updated_at = now()
where device_id = $1 and status = 'allocated'
`, deviceID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
delete from settings
where category = 'device_access_profile' and key = $1
`, deviceID.String()); err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *PGRepository) Rotate(ctx context.Context, deviceID uuid.UUID) error { func (r *PGRepository) Rotate(ctx context.Context, deviceID uuid.UUID) error {
_, err := r.db.Exec(ctx, ` _, err := r.db.Exec(ctx, `
update wireguard_peers update wireguard_peers

View File

@@ -189,6 +189,10 @@ func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error {
return s.repo.Revoke(ctx, deviceID) return s.repo.Revoke(ctx, deviceID)
} }
func (s *Service) Delete(ctx context.Context, deviceID uuid.UUID) error {
return s.repo.Delete(ctx, deviceID)
}
func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error { func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
return s.repo.Rotate(ctx, deviceID) return s.repo.Rotate(ctx, deviceID)
} }

View File

@@ -67,6 +67,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID) r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
r.Post("/devices/{id}/revoke", handlers.Device.Revoke) r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
r.Post("/devices/{id}/rotate", handlers.Device.Rotate) r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
r.Delete("/devices/{id}", handlers.Device.Delete)
r.Get("/groups", handlers.Group.List) r.Get("/groups", handlers.Group.List)
r.Post("/groups", handlers.Group.Create) r.Post("/groups", handlers.Group.Create)
r.Patch("/groups/{id}", handlers.Group.Update) r.Patch("/groups/{id}", handlers.Group.Update)

View File

@@ -119,6 +119,9 @@ EOF
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} udp dport 53 accept" echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} udp dport 53 accept"
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} tcp dport 53 accept" echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} tcp dport 53 accept"
done done
printf '%s' "${peer}" | jq -r '.allowed_destinations[]?' | while read -r destination; do
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${destination} accept"
done
printf '%s' "${peer}" | jq -c '.allowed_services[]?' | while read -r service; do printf '%s' "${peer}" | jq -c '.allowed_services[]?' | while read -r service; do
SERVICE_PROXY_IP="$(printf '%s' "${service}" | jq -r '.access_proxy_ip')" SERVICE_PROXY_IP="$(printf '%s' "${service}" | jq -r '.access_proxy_ip')"
printf '%s' "${service}" | jq -r '.ports[]?' | while read -r service_port; do printf '%s' "${service}" | jq -r '.ports[]?' | while read -r service_port; do