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

@@ -169,6 +169,43 @@ func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
return c.JSON(report)
}
func (h *AdminHandler) ListOrphanUserLoginIDs(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.IntegrityChecker == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
}
items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"total": len(items),
})
}
func (h *AdminHandler) DeleteOrphanUserLoginIDs(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.IntegrityChecker == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
}
var req struct {
IDs []string `json:"ids"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -14,9 +15,14 @@ import (
)
type fakeDataIntegrityChecker struct {
calls int
report domain.DataIntegrityReport
err error
calls int
listCalls int
deleteCalls int
deletedIDs []string
report domain.DataIntegrityReport
orphans []domain.OrphanUserLoginID
deleteResult domain.DeleteOrphanUserLoginIDsResult
err error
}
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
@@ -24,6 +30,17 @@ func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (doma
return f.report, f.err
}
func (f *fakeDataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
f.listCalls++
return f.orphans, f.err
}
func (f *fakeDataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
f.deleteCalls++
f.deletedIDs = append([]string(nil), ids...)
return f.deleteResult, f.err
}
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
checker := &fakeDataIntegrityChecker{}
h := &AdminHandler{IntegrityChecker: checker}
@@ -90,3 +107,90 @@ func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
require.Len(t, body.Sections, 1)
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
}
func TestAdminHandler_ListOrphanUserLoginIDsReturnsTargetsForSuperAdmin(t *testing.T) {
checker := &fakeDataIntegrityChecker{
orphans: []domain.OrphanUserLoginID{
{
ID: "login-id-1",
UserID: "user-1",
TenantID: "tenant-1",
FieldKey: "emp_id",
LoginID: "EMP001",
Reasons: []string{"missing_tenant"},
},
},
}
h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/integrity/orphan-user-login-ids", h.ListOrphanUserLoginIDs)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity/orphan-user-login-ids", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, checker.listCalls)
var body struct {
Items []domain.OrphanUserLoginID `json:"items"`
Total int `json:"total"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, 1, body.Total)
require.Equal(t, "login-id-1", body.Items[0].ID)
require.Equal(t, []string{"missing_tenant"}, body.Items[0].Reasons)
}
func TestAdminHandler_DeleteOrphanUserLoginIDsRequiresSuperAdminAndDeletesSelectedTargets(t *testing.T) {
checker := &fakeDataIntegrityChecker{
deleteResult: domain.DeleteOrphanUserLoginIDsResult{
DeletedCount: 1,
Deleted: []domain.OrphanUserLoginID{
{ID: "login-id-1", LoginID: "EMP001", Reasons: []string{"missing_user"}},
},
SkippedIDs: []string{"valid-login-id"},
},
}
h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1","valid-login-id"]}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, checker.deleteCalls)
require.Equal(t, []string{"login-id-1", "valid-login-id"}, checker.deletedIDs)
var body domain.DeleteOrphanUserLoginIDsResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, int64(1), body.DeletedCount)
require.Equal(t, []string{"valid-login-id"}, body.SkippedIDs)
}
func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
checker := &fakeDataIntegrityChecker{}
h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
return c.Next()
})
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1"]}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, 0, checker.deleteCalls)
}

View File

@@ -210,6 +210,14 @@ func roleFromTraits(traits map[string]interface{}) string {
return domain.RoleUser
}
func normalizeAssignableSystemRole(value string) (string, bool) {
role, ok := domain.NormalizeRoleAlias(value)
if !ok {
return "", false
}
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
}
func gradeFromTraits(traits map[string]interface{}) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
@@ -661,9 +669,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
role := domain.NormalizeRole(req.Role)
if role == "" {
role = domain.RoleUser
role := domain.RoleUser
if strings.TrimSpace(req.Role) != "" {
normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
if normalizedRole == domain.RoleSuperAdmin {
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
}
}
role = normalizedRole
}
attributes := map[string]interface{}{
@@ -1532,7 +1550,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
role, ok := normalizeAssignableSystemRole(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
@@ -1841,7 +1859,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
role, ok := normalizeAssignableSystemRole(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}

View File

@@ -1020,6 +1020,21 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
})
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
payload := map[string]interface{}{
"userIds": []string{"u-1"},
"role": role,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
})
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
app := fiber.New()
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
@@ -1141,6 +1156,33 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
})
}
func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser},
State: "active",
}, nil).Once()
payload := map[string]interface{}{"role": role}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()