1
0
forked from baron/baron-sso

정합성 위반사항 확인 및 조치기능 추가

This commit is contained in:
2026-05-14 09:04:33 +09:00
parent 9ca73e8774
commit df543d6203
17 changed files with 988 additions and 78 deletions

View File

@@ -3,6 +3,8 @@ package repository
import (
"baron-sso-backend/internal/domain"
"context"
"slices"
"strings"
"time"
"gorm.io/gorm"
@@ -10,6 +12,8 @@ import (
type DataIntegrityChecker interface {
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error)
DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error)
}
type dataIntegrityChecker struct {
@@ -24,6 +28,14 @@ func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.D
return CheckDataIntegrity(ctx, c.db)
}
func (c *dataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
return ListOrphanUserLoginIDs(ctx, c.db, nil)
}
func (c *dataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
return DeleteOrphanUserLoginIDs(ctx, c.db, ids)
}
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
tenantChecks := []domain.DataIntegrityCheck{
{
@@ -224,3 +236,145 @@ func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataI
}
return status
}
func ListOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) ([]domain.OrphanUserLoginID, error) {
type orphanRow struct {
ID string
UserID string
UserEmail string
UserDeletedAt *time.Time
TenantID string
TenantSlug string
TenantDeletedAt *time.Time
FieldKey string
LoginID string
MissingUser bool
DeletedUser bool
MissingTenant bool
DeletedTenant bool
}
query := `
SELECT
uli.id,
uli.user_id,
COALESCE(u.email, '') AS user_email,
u.deleted_at AS user_deleted_at,
uli.tenant_id,
COALESCE(t.slug, '') AS tenant_slug,
t.deleted_at AS tenant_deleted_at,
uli.field_key,
uli.login_id,
(u.id IS NULL) AS missing_user,
(u.id IS NOT NULL AND u.deleted_at IS NOT NULL) AS deleted_user,
(t.id IS NULL) AS missing_tenant,
(t.id IS NOT NULL AND t.deleted_at IS NOT NULL) AS deleted_tenant
FROM user_login_ids AS uli
LEFT JOIN users AS u ON u.id = uli.user_id
LEFT JOIN tenants AS t ON t.id = uli.tenant_id
WHERE (
u.id IS NULL
OR u.deleted_at IS NOT NULL
OR t.id IS NULL
OR t.deleted_at IS NOT NULL
)
`
args := []any{}
if len(ids) > 0 {
query += " AND uli.id IN ?\n"
args = append(args, ids)
}
query += "ORDER BY uli.login_id, uli.id"
var rows []orphanRow
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]domain.OrphanUserLoginID, 0, len(rows))
for _, row := range rows {
reasons := make([]string, 0, 4)
if row.MissingUser {
reasons = append(reasons, "missing_user")
}
if row.DeletedUser {
reasons = append(reasons, "deleted_user")
}
if row.MissingTenant {
reasons = append(reasons, "missing_tenant")
}
if row.DeletedTenant {
reasons = append(reasons, "deleted_tenant")
}
items = append(items, domain.OrphanUserLoginID{
ID: row.ID,
UserID: row.UserID,
UserEmail: row.UserEmail,
UserDeletedAt: row.UserDeletedAt,
TenantID: row.TenantID,
TenantSlug: row.TenantSlug,
TenantDeletedAt: row.TenantDeletedAt,
FieldKey: row.FieldKey,
LoginID: row.LoginID,
Reasons: reasons,
})
}
return items, nil
}
func DeleteOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
ids = normalizeIDList(ids)
result := domain.DeleteOrphanUserLoginIDsResult{
Deleted: []domain.OrphanUserLoginID{},
SkippedIDs: []string{},
}
if len(ids) == 0 {
return result, nil
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
items, err := ListOrphanUserLoginIDs(ctx, tx, ids)
if err != nil {
return err
}
deletableIDs := make([]string, 0, len(items))
deletableSet := make(map[string]bool, len(items))
for _, item := range items {
deletableIDs = append(deletableIDs, item.ID)
deletableSet[item.ID] = true
}
for _, id := range ids {
if !deletableSet[id] {
result.SkippedIDs = append(result.SkippedIDs, id)
}
}
if len(deletableIDs) == 0 {
return nil
}
deleteResult := tx.Exec("DELETE FROM user_login_ids WHERE id IN ?", deletableIDs)
if deleteResult.Error != nil {
return deleteResult.Error
}
result.Deleted = items
result.DeletedCount = deleteResult.RowsAffected
return nil
})
return result, err
}
func normalizeIDList(ids []string) []string {
normalized := make([]string, 0, len(ids))
seen := map[string]bool{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
normalized = append(normalized, id)
}
slices.Sort(normalized)
return normalized
}

View File

@@ -88,6 +88,110 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
}
func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) {
ctx := context.Background()
suffix := uuid.NewString()
validTenant := domain.Tenant{
ID: uuid.NewString(),
Name: "Valid Tenant " + suffix,
Slug: "valid-tenant-" + suffix,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
deletedTenant := domain.Tenant{
ID: uuid.NewString(),
Name: "Deleted Tenant " + suffix,
Slug: "deleted-tenant-" + suffix,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
require.NoError(t, testDB.Create(&validTenant).Error)
require.NoError(t, testDB.Create(&deletedTenant).Error)
validUser := domain.User{
ID: uuid.NewString(),
Email: "valid-login-" + suffix + "@example.com",
Name: "Valid Login User",
Role: domain.RoleUser,
TenantID: &validTenant.ID,
Status: domain.UserStatusActive,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
deletedUser := domain.User{
ID: uuid.NewString(),
Email: "deleted-login-" + suffix + "@example.com",
Name: "Deleted Login User",
Role: domain.RoleUser,
TenantID: &validTenant.ID,
Status: domain.UserStatusActive,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
require.NoError(t, testDB.Create(&validUser).Error)
require.NoError(t, testDB.Create(&deletedUser).Error)
validLogin := domain.UserLoginID{
ID: uuid.NewString(),
UserID: validUser.ID,
TenantID: validTenant.ID,
FieldKey: "emp_id",
LoginID: "VALID-" + suffix,
}
deletedTenantLogin := domain.UserLoginID{
ID: uuid.NewString(),
UserID: validUser.ID,
TenantID: deletedTenant.ID,
FieldKey: "emp_id",
LoginID: "DELETED-TENANT-" + suffix,
}
deletedUserLogin := domain.UserLoginID{
ID: uuid.NewString(),
UserID: deletedUser.ID,
TenantID: validTenant.ID,
FieldKey: "emp_id",
LoginID: "DELETED-USER-" + suffix,
}
require.NoError(t, testDB.Create(&validLogin).Error)
require.NoError(t, testDB.Create(&deletedTenantLogin).Error)
require.NoError(t, testDB.Create(&deletedUserLogin).Error)
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedUser.ID).Error)
items, err := ListOrphanUserLoginIDs(ctx, testDB, nil)
require.NoError(t, err)
orphanReasons := map[string][]string{}
for _, item := range items {
orphanReasons[item.ID] = item.Reasons
}
require.Equal(t, []string{"deleted_tenant"}, orphanReasons[deletedTenantLogin.ID])
require.Equal(t, []string{"deleted_user"}, orphanReasons[deletedUserLogin.ID])
require.NotContains(t, orphanReasons, validLogin.ID)
result, err := DeleteOrphanUserLoginIDs(ctx, testDB, []string{
deletedTenantLogin.ID,
validLogin.ID,
"00000000-0000-0000-0000-000000000000",
})
require.NoError(t, err)
require.Equal(t, int64(1), result.DeletedCount)
require.Len(t, result.Deleted, 1)
require.Equal(t, deletedTenantLogin.ID, result.Deleted[0].ID)
require.ElementsMatch(t, []string{
validLogin.ID,
"00000000-0000-0000-0000-000000000000",
}, result.SkippedIDs)
var deletedTenantLoginCount int64
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", deletedTenantLogin.ID).Count(&deletedTenantLoginCount).Error)
require.Equal(t, int64(0), deletedTenantLoginCount)
var validLoginCount int64
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", validLogin.ID).Count(&validLoginCount).Error)
require.Equal(t, int64(1), validLoginCount)
}
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
t.Helper()
for _, section := range report.Sections {