fix(auth): delete the Kratos identity on account deletion
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:

- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
  and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
  as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
  call fails it aborts before touching local data, so the operation is
  retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
  :4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
  (ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
  aborts the deletion instead of orphaning local data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-18 21:55:33 -05:00
parent 81578f6e27
commit 3d3ba84df0
7 changed files with 150 additions and 12 deletions
+24 -1
View File
@@ -3,12 +3,14 @@ package services
import (
"context"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/kratos"
"github.com/treytartt/honeydue-api/internal/repositories"
)
@@ -18,6 +20,7 @@ type AuthService struct {
userRepo *repositories.UserRepository
notificationRepo *repositories.NotificationRepository
cache *CacheService
kratos *kratos.Client
}
// NewAuthService creates a new auth service.
@@ -38,6 +41,13 @@ func (s *AuthService) SetCacheService(cache *CacheService) {
s.cache = cache
}
// SetKratosClient wires the Ory Kratos client so account deletion also
// removes the user's Kratos identity. When nil (e.g. in tests) the Kratos
// teardown is skipped.
func (s *AuthService) SetKratosClient(k *kratos.Client) {
s.kratos = k
}
// GetCurrentUser returns the current authenticated user with profile.
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
@@ -102,12 +112,17 @@ func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *reque
// DeleteAccount deletes a user's account and all associated data.
// Kratos owns credentials; confirmation string "DELETE" is required from the
// caller since we can no longer verify a password here.
//
// The Kratos identity is deleted FIRST: Kratos owns the credentials, so if
// that call fails the local data is left intact and an error is returned for
// the caller to retry. Deleting local data first would risk an orphaned
// Kratos identity that can still authenticate against a half-deleted account.
func (s *AuthService) DeleteAccount(ctx context.Context, userID uint, _ *string, confirmation *string) ([]string, error) {
if confirmation == nil || *confirmation != "DELETE" {
return nil, apperrors.BadRequest("error.confirmation_required")
}
_, err := s.userRepo.WithContext(ctx).FindByID(userID)
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) {
return nil, apperrors.NotFound("error.user_not_found")
@@ -115,6 +130,14 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID uint, _ *string,
return nil, apperrors.Internal(err)
}
// Tear down the Kratos identity before touching local data. DeleteIdentity
// treats a 404 as success, so a retry after a partial failure is safe.
if s.kratos != nil && user.KratosID != "" {
if err := s.kratos.DeleteIdentity(ctx, user.KratosID); err != nil {
return nil, apperrors.Internal(fmt.Errorf("delete kratos identity: %w", err))
}
}
var fileURLs []string
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
urls, err := txRepo.DeleteUserCascade(userID)