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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user