fix(auth): delete the Kratos identity on account deletion
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:
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
||||
"github.com/treytartt/honeydue-api/internal/kratos"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
)
|
||||
@@ -165,6 +168,60 @@ func TestAuthService_DeleteAccount_UserNotFound(t *testing.T) {
|
||||
testutil.AssertAppError(t, err, http.StatusNotFound, "error.user_not_found")
|
||||
}
|
||||
|
||||
// Regression: deleting an account must also delete the user's Kratos
|
||||
// identity, or an orphaned, still-loginable identity is left behind.
|
||||
func TestAuthService_DeleteAccount_DeletesKratosIdentity(t *testing.T) {
|
||||
service, userRepo := setupAuthService(t)
|
||||
db := userRepo.DB()
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "kuser", "kuser@test.com", "")
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).
|
||||
Update("kratos_id", "kratos-uuid-123").Error)
|
||||
|
||||
var gotMethod, gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod, gotPath = r.Method, r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
service.SetKratosClient(kratos.NewClient("", srv.URL))
|
||||
|
||||
confirmation := "DELETE"
|
||||
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &confirmation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.MethodDelete, gotMethod)
|
||||
assert.Equal(t, "/admin/identities/kratos-uuid-123", gotPath)
|
||||
|
||||
// Local user row is gone.
|
||||
_, err = userRepo.WithContext(context.Background()).FindByID(user.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// Regression: if the Kratos identity delete fails, local data must NOT be
|
||||
// deleted — the operation must be retryable, not partially applied.
|
||||
func TestAuthService_DeleteAccount_KratosFailureAbortsDeletion(t *testing.T) {
|
||||
service, userRepo := setupAuthService(t)
|
||||
db := userRepo.DB()
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "kuser2", "kuser2@test.com", "")
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).
|
||||
Update("kratos_id", "kratos-uuid-456").Error)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
service.SetKratosClient(kratos.NewClient("", srv.URL))
|
||||
|
||||
confirmation := "DELETE"
|
||||
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &confirmation)
|
||||
require.Error(t, err)
|
||||
|
||||
// Local user row must still exist.
|
||||
_, ferr := userRepo.WithContext(context.Background()).FindByID(user.ID)
|
||||
assert.NoError(t, ferr)
|
||||
}
|
||||
|
||||
// === SetNotificationRepository ===
|
||||
|
||||
func TestAuthService_SetNotificationRepository(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user