From b199b58840ccb24044293aae5b51954d1873664d Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 19 Mar 2026 22:59:07 +0100 Subject: [PATCH] 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 --- admin-web/src/api/client.ts | 4 ++ .../src/features/devices/DevicesPage.tsx | 8 ++++ backend/internal/device/handler.go | 23 +++++++++++ backend/internal/device/repository.go | 39 +++++++++++++++++++ backend/internal/device/service.go | 4 ++ backend/internal/httpserver/router.go | 1 + deploy/scripts/gateway-entrypoint.sh | 3 ++ 7 files changed, 82 insertions(+) diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 93fff36..e539f01 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -225,6 +225,10 @@ export const api = { method: "POST", body: JSON.stringify({}) }), + deleteDevice: (deviceId: string) => + request<{ ok: boolean }>(`/admin/devices/${deviceId}`, { + method: "DELETE" + }), rotateDevice: (deviceId: string) => request<{ ok: boolean }>(`/admin/devices/${deviceId}/rotate`, { method: "POST", diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx index ea621ba..5d4cc58 100644 --- a/admin-web/src/features/devices/DevicesPage.tsx +++ b/admin-web/src/features/devices/DevicesPage.tsx @@ -46,6 +46,13 @@ export function DevicesPage() { mutationFn: api.revokeDevice, 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({ mutationFn: api.rotateDevice, onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) @@ -85,6 +92,7 @@ export function DevicesPage() {
+
diff --git a/backend/internal/device/handler.go b/backend/internal/device/handler.go index 97cc16c..6241e49 100644 --- a/backend/internal/device/handler.go +++ b/backend/internal/device/handler.go @@ -196,3 +196,26 @@ func (h *Handler) Rotate(w http.ResponseWriter, r *http.Request) { } 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}) +} diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go index b9cffb5..851256f 100644 --- a/backend/internal/device/repository.go +++ b/backend/internal/device/repository.go @@ -22,6 +22,7 @@ type Repository interface { GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error) SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID 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 } @@ -292,6 +293,44 @@ func (r *PGRepository) Revoke(ctx context.Context, deviceID uuid.UUID) error { 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 { _, err := r.db.Exec(ctx, ` update wireguard_peers diff --git a/backend/internal/device/service.go b/backend/internal/device/service.go index a2fa16c..e54cc86 100644 --- a/backend/internal/device/service.go +++ b/backend/internal/device/service.go @@ -189,6 +189,10 @@ func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error { 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 { return s.repo.Rotate(ctx, deviceID) } diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 7d52883..1ea5444 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -67,6 +67,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID) r.Post("/devices/{id}/revoke", handlers.Device.Revoke) r.Post("/devices/{id}/rotate", handlers.Device.Rotate) + r.Delete("/devices/{id}", handlers.Device.Delete) r.Get("/groups", handlers.Group.List) r.Post("/groups", handlers.Group.Create) r.Patch("/groups/{id}", handlers.Group.Update) diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh index 536b99e..dfa266b 100644 --- a/deploy/scripts/gateway-entrypoint.sh +++ b/deploy/scripts/gateway-entrypoint.sh @@ -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} tcp dport 53 accept" 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 SERVICE_PROXY_IP="$(printf '%s' "${service}" | jq -r '.access_proxy_ip')" printf '%s' "${service}" | jq -r '.ports[]?' | while read -r service_port; do