forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -11,22 +11,44 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adminHydraClientLister interface {
|
||||
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
|
||||
}
|
||||
|
||||
type identityCacheAdmin interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error)
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserProjectionSyncer service.UserProjectionReconciler
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
DB *gorm.DB
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
IdentityCache identityCacheAdmin
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
}
|
||||
|
||||
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
|
||||
|
||||
type globalCustomClaimDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
ValueType string `json:"valueType"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type globalCustomClaimDefinitionsResponse struct {
|
||||
Items []globalCustomClaimDefinition `json:"items"`
|
||||
}
|
||||
|
||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||
@@ -110,6 +132,154 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var setting domain.SystemSetting
|
||||
if err := h.DB.WithContext(c.Context()).First(&setting, "key = ?", globalCustomClaimsSettingKey).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: []globalCustomClaimDefinition{}})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{
|
||||
Items: normalizeGlobalCustomClaimDefinitions(setting.Value["items"]),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateGlobalCustomClaimDefinitions(c *fiber.Ctx) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "settings store unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
var req globalCustomClaimDefinitionsResponse
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
items, err := validateGlobalCustomClaimDefinitions(req.Items)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
setting := domain.SystemSetting{
|
||||
Key: globalCustomClaimsSettingKey,
|
||||
Value: domain.JSONMap{"items": globalCustomClaimDefinitionsToJSON(items)},
|
||||
}
|
||||
if err := h.DB.WithContext(c.Context()).Save(&setting).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(globalCustomClaimDefinitionsResponse{Items: items})
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimDefinitions(value any) []globalCustomClaimDefinition {
|
||||
rawItems, ok := value.([]any)
|
||||
if !ok {
|
||||
return []globalCustomClaimDefinition{}
|
||||
}
|
||||
items := make([]globalCustomClaimDefinition, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
raw, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
def := globalCustomClaimDefinition{
|
||||
Key: strings.TrimSpace(stringValue(raw["key"])),
|
||||
Label: strings.TrimSpace(stringValue(raw["label"])),
|
||||
ValueType: normalizeGlobalCustomClaimType(stringValue(raw["valueType"])),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(stringValue(raw["readPermission"])),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(stringValue(raw["writePermission"])),
|
||||
Description: strings.TrimSpace(stringValue(raw["description"])),
|
||||
}
|
||||
if def.Key != "" {
|
||||
items = append(items, def)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func validateGlobalCustomClaimDefinitions(items []globalCustomClaimDefinition) ([]globalCustomClaimDefinition, error) {
|
||||
seen := map[string]struct{}{}
|
||||
normalized := make([]globalCustomClaimDefinition, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !isValidCustomClaimKey(key) {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "claim key must use letters, numbers, underscore, dot, or hyphen")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "duplicate claim key: "+key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, globalCustomClaimDefinition{
|
||||
Key: key,
|
||||
Label: strings.TrimSpace(item.Label),
|
||||
ValueType: normalizeGlobalCustomClaimType(item.ValueType),
|
||||
ReadPermission: adminNormalizeCustomClaimPermission(item.ReadPermission),
|
||||
WritePermission: adminNormalizeCustomClaimPermission(item.WritePermission),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func globalCustomClaimDefinitionsToJSON(items []globalCustomClaimDefinition) []any {
|
||||
values := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, map[string]any{
|
||||
"key": item.Key,
|
||||
"label": item.Label,
|
||||
"valueType": item.ValueType,
|
||||
"readPermission": item.ReadPermission,
|
||||
"writePermission": item.WritePermission,
|
||||
"description": item.Description,
|
||||
})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func normalizeGlobalCustomClaimType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "number", "boolean", "array", "object", "date", "datetime":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "text"
|
||||
}
|
||||
}
|
||||
|
||||
func adminNormalizeCustomClaimPermission(value string) string {
|
||||
if strings.TrimSpace(value) == "user_and_admin" {
|
||||
return "user_and_admin"
|
||||
}
|
||||
return "admin_only"
|
||||
}
|
||||
|
||||
func isValidCustomClaimKey(value string) bool {
|
||||
for _, r := range value {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
@@ -133,26 +303,48 @@ func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
return c.JSON(status)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error {
|
||||
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionSyncer == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"})
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
count, err := h.UserProjectionSyncer.Reconcile(c.Context())
|
||||
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cacheStatus := domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
RedisReady: false,
|
||||
LastError: "identity cache service unavailable",
|
||||
}
|
||||
if h.IdentityCache != nil {
|
||||
cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "success",
|
||||
"syncedUsers": count,
|
||||
"updatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"userProjection": projectionStatus,
|
||||
"identityCache": cacheStatus,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error {
|
||||
return h.ReconcileUserProjection(c)
|
||||
func (h *AdminHandler) FlushIdentityCache(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity cache service unavailable"})
|
||||
}
|
||||
result, err := h.IdentityCache.FlushIdentityCache(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -78,6 +77,10 @@ func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, te
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
return nil
|
||||
}
|
||||
@@ -90,15 +93,22 @@ func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.Use
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
type fakeAdminUserProjectionSyncer struct {
|
||||
count int
|
||||
err error
|
||||
calls int
|
||||
type fakeIdentityCacheAdmin struct {
|
||||
status domain.IdentityCacheStatus
|
||||
flush domain.IdentityCacheFlushResult
|
||||
err error
|
||||
statusHit int
|
||||
flushCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserProjectionSyncer) Reconcile(ctx context.Context) (int, error) {
|
||||
f.calls++
|
||||
return f.count, f.err
|
||||
func (f *fakeIdentityCacheAdmin) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
f.statusHit++
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func (f *fakeIdentityCacheAdmin) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
f.flushCalls++
|
||||
return f.flush, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
|
||||
@@ -199,42 +209,81 @@ func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t
|
||||
require.Equal(t, int64(152), body.ProjectedUsers)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ReconcileUserProjectionRequiresSuperAdminAndRunsSyncer(t *testing.T) {
|
||||
syncer := &fakeAdminUserProjectionSyncer{count: 4}
|
||||
h := &AdminHandler{UserProjectionSyncer: syncer}
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
status: domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
ObservedCount: 151,
|
||||
KeyCount: 153,
|
||||
LastRefreshedAt: &syncedAt,
|
||||
UpdatedAt: &syncedAt,
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
IdentityCache: cache,
|
||||
}
|
||||
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.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
|
||||
app.Get("/api/v1/admin/ory/ssot", h.GetOrySSOTSystemStatus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ory/ssot", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, syncer.calls)
|
||||
|
||||
var body map[string]any
|
||||
var body struct {
|
||||
UserProjection domain.UserProjectionStatus `json:"userProjection"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, "success", body["status"])
|
||||
require.Equal(t, float64(4), body["syncedUsers"])
|
||||
require.Equal(t, int64(152), body.UserProjection.ProjectedUsers)
|
||||
require.True(t, body.IdentityCache.RedisReady)
|
||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||
require.Equal(t, 1, cache.statusHit)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ReconcileUserProjectionReturnsServiceUnavailableOnSyncFailure(t *testing.T) {
|
||||
syncer := &fakeAdminUserProjectionSyncer{err: errors.New("kratos down")}
|
||||
h := &AdminHandler{UserProjectionSyncer: syncer}
|
||||
func TestAdminHandler_FlushIdentityCacheRequiresSuperAdminAndFlushesCacheOnly(t *testing.T) {
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
flush: domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: 7,
|
||||
UpdatedAt: time.Date(2026, 5, 11, 3, 2, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
IdentityCache: cache,
|
||||
}
|
||||
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.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
|
||||
app.Post("/api/v1/admin/ory/ssot/identity-cache/flush", h.FlushIdentityCache)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/ory/ssot/identity-cache/flush", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body domain.IdentityCacheFlushResult
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(7), body.FlushedKeys)
|
||||
require.Equal(t, 1, cache.flushCalls)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
|
||||
|
||||
@@ -776,13 +776,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Normalize Phone (E.164 형태로 보관)
|
||||
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
|
||||
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
||||
if strings.HasPrefix(normalizedPhone, "010") {
|
||||
normalizedPhone = "+82" + normalizedPhone[1:]
|
||||
} else if strings.HasPrefix(normalizedPhone, "82") {
|
||||
normalizedPhone = "+" + normalizedPhone
|
||||
}
|
||||
normalizedPhone := domain.NormalizePhoneNumber(req.Phone)
|
||||
|
||||
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
||||
|
||||
@@ -1092,15 +1086,7 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
||||
|
||||
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
||||
func normalizePhoneForLoginID(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
|
||||
@@ -1226,7 +1212,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
|
||||
// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
|
||||
for k, v := range traits {
|
||||
if k == "metadata" {
|
||||
if k == "metadata" || k == "global_custom_claims" || k == "global_custom_claim_types" || k == "global_custom_claim_permissions" {
|
||||
continue
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
@@ -1242,7 +1228,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
claims["tenants"] = allTenants
|
||||
}
|
||||
|
||||
return claims
|
||||
return applyGlobalCustomClaims(claims, traits)
|
||||
}
|
||||
|
||||
func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any {
|
||||
@@ -1263,6 +1249,39 @@ func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any,
|
||||
return withOidcSessionMetadata(claims, sessionID)
|
||||
}
|
||||
|
||||
func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any {
|
||||
if baseClaims == nil {
|
||||
baseClaims = map[string]any{}
|
||||
}
|
||||
if traits == nil {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
rawClaims, ok := traits["global_custom_claims"]
|
||||
if !ok || rawClaims == nil {
|
||||
return baseClaims
|
||||
}
|
||||
customClaims, ok := rawClaims.(map[string]any)
|
||||
if !ok {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
for key, value := range customClaims {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || value == nil {
|
||||
continue
|
||||
}
|
||||
if key == "rp_claims" || key == "rp_profiles" {
|
||||
continue
|
||||
}
|
||||
if _, exists := baseClaims[key]; exists {
|
||||
continue
|
||||
}
|
||||
baseClaims[key] = value
|
||||
}
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims map[string]any, traits map[string]any, scopes []string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
@@ -4666,7 +4685,7 @@ func extractFirstString(data map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
func sanitizePhoneForSms(phone string) string {
|
||||
sanitized := strings.TrimSpace(phone)
|
||||
sanitized := domain.NormalizePhoneNumber(phone)
|
||||
if strings.HasPrefix(sanitized, "+82") {
|
||||
sanitized = "0" + sanitized[3:]
|
||||
}
|
||||
@@ -4685,11 +4704,7 @@ func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
phone = strings.ReplaceAll(phone, "-", "")
|
||||
if strings.HasPrefix(phone, "010") && len(phone) == 11 {
|
||||
return "+8210" + phone[3:]
|
||||
}
|
||||
return phone
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
// GetMe - Returns current user's profile with enriched data from local DB
|
||||
@@ -5920,6 +5935,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
slog.Error("failed to revoke hydra consent sessions", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
|
||||
}
|
||||
if h.ConsentRepo != nil {
|
||||
if err := h.ConsentRepo.Delete(c.Context(), subject, clientID); err != nil {
|
||||
slog.Error("failed to delete local consent after hydra revoke", "error", err, "subject", subject, "client_id", clientID)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke local consent")
|
||||
}
|
||||
}
|
||||
|
||||
if h.AuditRepo != nil {
|
||||
detailsMap := map[string]any{
|
||||
@@ -7611,35 +7632,6 @@ func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]any) error {
|
||||
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
||||
if kratosAdminURL == "" {
|
||||
kratosAdminURL = "http://kratos:4434"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
|
||||
intro, err := h.Hydra.IntrospectToken(ctx, token)
|
||||
if err != nil {
|
||||
@@ -7952,10 +7944,17 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.updateKratosIdentity(identityID, traits); err != nil {
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
updatedIdentity, err := h.KratosAdmin.UpdateIdentity(c.Context(), identityID, traits, "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to update profile in Kratos", "error", err)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
|
||||
}
|
||||
if updatedIdentity != nil && updatedIdentity.Traits != nil {
|
||||
traits = updatedIdentity.Traits
|
||||
}
|
||||
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
|
||||
if h.UserRepo != nil {
|
||||
|
||||
@@ -28,6 +28,8 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
}
|
||||
// 2. Hydra Revoke
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
assert.Equal(t, "user-123", r.URL.Query().Get("subject"))
|
||||
assert.Equal(t, "app-1", r.URL.Query().Get("client"))
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
@@ -40,12 +42,22 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "app-1",
|
||||
Subject: "user-123",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
app := fiber.New()
|
||||
@@ -67,6 +79,9 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
|
||||
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
|
||||
remaining, err := consentRepo.Find(req.Context(), "app-1", "user-123")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, remaining)
|
||||
}
|
||||
|
||||
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
||||
|
||||
@@ -696,6 +696,31 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
||||
}
|
||||
|
||||
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"global_custom_claims": map[string]any{
|
||||
"contract_date": "2026-06-09",
|
||||
"approved_at": "2026-06-09T09:30:00+09:00",
|
||||
"email": "override@test.com",
|
||||
"rp_claims": "reserved",
|
||||
},
|
||||
"global_custom_claim_permissions": map[string]any{
|
||||
"contract_date": map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
}, []string{"openid", "profile", "email"}, "")
|
||||
|
||||
assert.Equal(t, "2026-06-09", claims["contract_date"])
|
||||
assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"])
|
||||
assert.Equal(t, "user@test.com", claims["email"])
|
||||
assert.NotEqual(t, "reserved", claims["rp_claims"])
|
||||
assert.NotContains(t, claims, "global_custom_claim_permissions")
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -31,6 +32,28 @@ func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, user
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingUpdateMeKratosAdmin struct {
|
||||
MockKratosAdminService
|
||||
updatedIdentityID string
|
||||
updatedTraits map[string]any
|
||||
updatedState string
|
||||
storedTraits map[string]any
|
||||
}
|
||||
|
||||
func (r *recordingUpdateMeKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
|
||||
r.updatedIdentityID = identityID
|
||||
r.updatedTraits = maps.Clone(traits)
|
||||
r.updatedState = state
|
||||
if r.storedTraits != nil {
|
||||
maps.Copy(r.storedTraits, traits)
|
||||
}
|
||||
return &service.KratosIdentity{
|
||||
ID: identityID,
|
||||
Traits: traits,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
token := "token-abc"
|
||||
identityID := "user-1"
|
||||
@@ -79,8 +102,10 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
@@ -113,6 +138,8 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, "New Dept", traits["department"])
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Dept", kratosAdmin.updatedTraits["department"])
|
||||
|
||||
// 3) 새로고침 재조회 시 New Dept가 보여야 함(캐시 무효화 회귀 방지)
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
@@ -177,9 +204,11 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
"verify_update_phone:" + identityID + ":+821087654321": "verified",
|
||||
}}
|
||||
userRepo := &recordingUpdateMeUserRepo{}
|
||||
kratosAdmin := &recordingUpdateMeKratosAdmin{storedTraits: traits}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
UserRepo: userRepo,
|
||||
KratosAdmin: kratosAdmin,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||
@@ -199,6 +228,9 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, identityID, kratosAdmin.updatedIdentityID)
|
||||
require.Equal(t, "New Name", kratosAdmin.updatedTraits["name"])
|
||||
require.Equal(t, "+821087654321", kratosAdmin.updatedTraits["phone_number"])
|
||||
|
||||
require.NotNil(t, userRepo.updated)
|
||||
require.Equal(t, identityID, userRepo.updated.ID)
|
||||
|
||||
@@ -196,7 +196,17 @@ func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error { return nil }
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
for _, consent := range m.consents {
|
||||
if consent.Subject == subject && (clientID == "" || consent.ClientID == clientID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, consent)
|
||||
}
|
||||
m.consents = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
|
||||
@@ -176,17 +176,18 @@ type clientRelationUpsertRequest struct {
|
||||
}
|
||||
|
||||
type consentSummary struct {
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
RPMetadata domain.JSONMap `json:"rpMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type consentListResponse struct {
|
||||
@@ -217,10 +218,12 @@ type clientUpsertRequest struct {
|
||||
}
|
||||
|
||||
type normalizedIDTokenClaim struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
ReadPermission string `json:"readPermission"`
|
||||
WritePermission string `json:"writePermission"`
|
||||
}
|
||||
|
||||
var protectedSystemClientIDs = map[string]struct{}{
|
||||
@@ -1535,19 +1538,202 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
|
||||
if req.Metadata == nil {
|
||||
req.Metadata = map[string]any{}
|
||||
}
|
||||
normalizedMetadata, err := normalizeRPUserMetadataForClient(req.Metadata, summary.Metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
row := &domain.RPUserMetadata{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Metadata: domain.JSONMap(req.Metadata),
|
||||
Metadata: normalizedMetadata,
|
||||
}
|
||||
if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, normalizedMetadata); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(row)
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load kratos identity for rp user metadata: %w", err)
|
||||
}
|
||||
if identity == nil {
|
||||
return errors.New("kratos identity not found for rp user metadata")
|
||||
}
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = map[string]any{}
|
||||
}
|
||||
rawRPClaims, _ := traits["rp_custom_claims"].(map[string]any)
|
||||
if rawRPClaims == nil {
|
||||
rawRPClaims = map[string]any{}
|
||||
}
|
||||
rawRPClaims[clientID] = metadata
|
||||
traits["rp_custom_claims"] = rawRPClaims
|
||||
_, err = h.KratosAdmin.UpdateIdentity(ctx, identity.ID, traits, identity.State)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update kratos rp user metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rpUserMetadataClaimSchema struct {
|
||||
Key string
|
||||
ValueType string
|
||||
ReadPermission string
|
||||
WritePermission string
|
||||
}
|
||||
|
||||
func normalizeCustomClaimPermission(value any) string {
|
||||
permission := strings.TrimSpace(readInterfaceString(value, ""))
|
||||
switch permission {
|
||||
case "user_and_admin":
|
||||
return "user_and_admin"
|
||||
default:
|
||||
return "admin_only"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCustomClaimPermissions(value any, fallbackRead string, fallbackWrite string) map[string]any {
|
||||
var record map[string]any
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
record = typed
|
||||
case domain.JSONMap:
|
||||
record = map[string]any(typed)
|
||||
}
|
||||
return map[string]any{
|
||||
"readPermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "readPermission", fallbackRead)),
|
||||
"writePermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "writePermission", fallbackWrite)),
|
||||
}
|
||||
}
|
||||
|
||||
func readMapValueOrFallback(values map[string]any, key string, fallback string) any {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
if value, ok := values[key]; ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata map[string]any) (domain.JSONMap, error) {
|
||||
schemas, err := rpUserMetadataClaimSchemas(clientMetadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalized := domain.JSONMap{}
|
||||
for rawKey, rawValue := range metadata {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" || isEmptyRPUserMetadataValue(rawValue) {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(key, "_permissions") {
|
||||
claimKey := strings.TrimSuffix(key, "_permissions")
|
||||
schema, ok := schemas[claimKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata claim is not configured: %s", claimKey)
|
||||
}
|
||||
normalized[key] = normalizeCustomClaimPermissions(rawValue, schema.ReadPermission, schema.WritePermission)
|
||||
continue
|
||||
}
|
||||
schema, ok := schemas[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key)
|
||||
}
|
||||
textValue, err := stringifyRPUserMetadataValue(rawValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err)
|
||||
}
|
||||
parsed, err := parseConfiguredClaimValue(textValue, schema.ValueType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err)
|
||||
}
|
||||
normalized[key] = parsed
|
||||
permissionKey := key + "_permissions"
|
||||
if _, exists := normalized[permissionKey]; !exists {
|
||||
normalized[permissionKey] = map[string]any{
|
||||
"readPermission": schema.ReadPermission,
|
||||
"writePermission": schema.WritePermission,
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) {
|
||||
rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == nil {
|
||||
return map[string]rpUserMetadataClaimSchema{}, nil
|
||||
}
|
||||
|
||||
claims, err := normalizeIDTokenClaimsForDevConsole(rawClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas := make(map[string]rpUserMetadataClaimSchema, len(claims))
|
||||
for _, claim := range claims {
|
||||
if claim.Namespace != "rp_claims" {
|
||||
continue
|
||||
}
|
||||
schemas[claim.Key] = rpUserMetadataClaimSchema{
|
||||
Key: claim.Key,
|
||||
ValueType: claim.ValueType,
|
||||
ReadPermission: claim.ReadPermission,
|
||||
WritePermission: claim.WritePermission,
|
||||
}
|
||||
}
|
||||
return schemas, nil
|
||||
}
|
||||
|
||||
func isEmptyRPUserMetadataValue(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringifyRPUserMetadataValue(value any) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed), nil
|
||||
case bool:
|
||||
return strconv.FormatBool(typed), nil
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64), nil
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32), nil
|
||||
case int:
|
||||
return strconv.Itoa(typed), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10), nil
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10), nil
|
||||
case json.Number:
|
||||
return typed.String(), nil
|
||||
default:
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) {
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
@@ -2262,6 +2448,13 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
var rpMetadata domain.JSONMap
|
||||
if h.RPUserMetadataRepo != nil {
|
||||
if row, err := h.RPUserMetadataRepo.Get(c.Context(), consent.ClientID, consent.Subject); err == nil && row != nil && len(row.Metadata) > 0 {
|
||||
rpMetadata = row.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, consentSummary{
|
||||
Subject: consent.Subject,
|
||||
UserName: userName,
|
||||
@@ -2273,6 +2466,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
Status: status,
|
||||
TenantID: consent.TenantID,
|
||||
TenantName: consent.TenantName,
|
||||
RPMetadata: rpMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3107,7 +3301,7 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, er
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
normalized, err := normalizeIDTokenClaims(rawClaims)
|
||||
normalized, err := normalizeIDTokenClaimsForDevConsole(rawClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3116,6 +3310,14 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, er
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
return normalizeIDTokenClaimsWithOptions(rawClaims, true)
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaimsForDevConsole(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
return normalizeIDTokenClaimsWithOptions(rawClaims, false)
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]normalizedIDTokenClaim, error) {
|
||||
rawList, ok := rawClaims.([]any)
|
||||
if !ok {
|
||||
if typedList, ok := rawClaims.([]map[string]any); ok {
|
||||
@@ -3154,6 +3356,9 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
if namespace != "top_level" && namespace != "rp_claims" {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace)
|
||||
}
|
||||
if !allowTopLevel && namespace == "top_level" {
|
||||
return nil, errors.New("metadata.id_token_claims top_level namespace is managed from admin user custom claims")
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(readInterfaceString(record["key"], ""))
|
||||
if key == "" {
|
||||
@@ -3168,7 +3373,7 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
valueType = "text"
|
||||
}
|
||||
switch valueType {
|
||||
case "text", "number", "boolean", "array", "object":
|
||||
case "text", "number", "boolean", "array", "object", "date", "datetime":
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
@@ -3185,10 +3390,12 @@ func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
|
||||
seen[signature] = struct{}{}
|
||||
|
||||
normalized = append(normalized, normalizedIDTokenClaim{
|
||||
Namespace: namespace,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
Namespace: namespace,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
ReadPermission: normalizeCustomClaimPermission(record["readPermission"]),
|
||||
WritePermission: normalizeCustomClaimPermission(record["writePermission"]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3258,6 +3465,25 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
return nil, errors.New("object value must be valid JSON object")
|
||||
}
|
||||
return parsed, nil
|
||||
case "date":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("date value is required")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", trimmed); err != nil {
|
||||
return nil, errors.New("date value must use YYYY-MM-DD")
|
||||
}
|
||||
return trimmed, nil
|
||||
case "datetime":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("datetime value is required")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
}
|
||||
return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type devMockRPUserMetadataRepo struct {
|
||||
@@ -40,6 +41,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
@@ -50,7 +59,9 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
|
||||
return row.ClientID == "client-1" &&
|
||||
row.UserID == "user-1" &&
|
||||
row.Metadata["approvalLevel"] == "A"
|
||||
row.Metadata["approvalLevel"] == "A" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
|
||||
})).Return(nil).Once()
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
@@ -74,7 +85,12 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
app.Get("/api/v1/dev/clients/:id/users/:userId/metadata", h.GetRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "A"},
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "A",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
})
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
putReq.Header.Set("Content-Type", "application/json")
|
||||
@@ -92,3 +108,171 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "A", got["metadata"].(map[string]any)["approvalLevel"])
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"valueType": "text",
|
||||
"value": "A",
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Once()
|
||||
kratos := new(MockKratosAdmin)
|
||||
kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User One",
|
||||
},
|
||||
}, nil).Once()
|
||||
var capturedTraits map[string]any
|
||||
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"approvalLevel": "B"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
rpClaims := capturedTraits["rp_custom_claims"].(map[string]any)
|
||||
clientClaims := rpClaims["client-1"].(domain.JSONMap)
|
||||
require.Equal(t, "B", clientClaims["approvalLevel"])
|
||||
require.Equal(t, map[string]any{
|
||||
"readPermission": "user_and_admin",
|
||||
"writePermission": "admin_only",
|
||||
}, clientClaims["approvalLevel_permissions"])
|
||||
repo.AssertExpectations(t)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"unknown_claim": "A"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "Client One",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{"contract_date": "2026/06/09"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
@@ -726,7 +726,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
"tenant_id": "tenant-1",
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []any{"tenant-1", "tenant-2"},
|
||||
"id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
||||
"id_token_claims": []any{map[string]any{"namespace": "rp_claims", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
||||
"headless_login_enabled": true,
|
||||
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
@@ -766,7 +766,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
"allowed_tenants": []string{"tenant-1", "tenant-2"},
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"namespace": "rp_claims",
|
||||
"key": "locale",
|
||||
"valueType": "text",
|
||||
"value": "ko-KR",
|
||||
@@ -2306,7 +2306,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"id": "claim-1",
|
||||
"namespace": "top_level",
|
||||
"namespace": "rp_claims",
|
||||
"key": "locale",
|
||||
"value": " ko-KR ",
|
||||
"valueType": "text",
|
||||
@@ -2331,7 +2331,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||
first, ok := claims[0].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "top_level", first["namespace"])
|
||||
assert.Equal(t, "rp_claims", first["namespace"])
|
||||
assert.Equal(t, "locale", first["key"])
|
||||
assert.Equal(t, "ko-KR", first["value"])
|
||||
assert.Equal(t, "text", first["valueType"])
|
||||
@@ -2393,7 +2393,7 @@ func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved")
|
||||
assert.Contains(t, string(bodyBytes), "top_level namespace is managed from admin user custom claims")
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
@@ -3134,6 +3134,147 @@ func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListConsents_IncludesRPUserMetadata(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Get", mock.Anything, "client-1", "subject-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
UserID: "subject-1",
|
||||
Metadata: domain.JSONMap{
|
||||
"approvalLevel": "A",
|
||||
"reviewedAt": "2026-06-09T09:30:00+09:00",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
ConsentRepo: &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "client-1",
|
||||
Subject: "subject-1",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/consents", h.ListConsents)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result consentListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, domain.JSONMap{
|
||||
"approvalLevel": "A",
|
||||
"reviewedAt": "2026-06-09T09:30:00+09:00",
|
||||
}, result.Items[0].RPMetadata)
|
||||
}
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestNormalizeIDTokenClaimsMetadata_AllowsDateAndDatetime(t *testing.T) {
|
||||
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
|
||||
domain.MetadataIDTokenClaims: []any{
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contract_date",
|
||||
"value": "2026-06-09",
|
||||
"valueType": "date",
|
||||
},
|
||||
map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approved_at",
|
||||
"value": "2026-06-09T09:30:00+09:00",
|
||||
"valueType": "datetime",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim)
|
||||
assert.Equal(t, "date", claims[0].ValueType)
|
||||
assert.Equal(t, "datetime", claims[1].ValueType)
|
||||
}
|
||||
|
||||
func TestUpdateClient_RejectsTopLevelIDTokenClaimsFromDevConsole(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"redirect_uris": []string{"http://localhost/cb"},
|
||||
"grant_types": []string{"authorization_code"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"status": "active"},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
t.Fatalf("hydra update should not be called for top-level id token claims")
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
domain.MetadataIDTokenClaims: []any{
|
||||
map[string]any{
|
||||
"namespace": "top_level",
|
||||
"key": "employee_id",
|
||||
"value": "EMP001",
|
||||
"valueType": "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +31,7 @@ type TenantHandler struct {
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
@@ -37,6 +41,11 @@ type TenantHandler struct {
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
}
|
||||
|
||||
type orgChartCacheStore interface {
|
||||
Get(key string) (string, error)
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
}
|
||||
|
||||
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
||||
}
|
||||
@@ -74,18 +83,19 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
||||
}
|
||||
|
||||
type tenantSummary struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type tenantListResponse struct {
|
||||
@@ -97,6 +107,18 @@ type tenantListResponse struct {
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type orgChartSnapshotCacheInfo struct {
|
||||
Source string `json:"source"`
|
||||
Hit bool `json:"hit"`
|
||||
TTLSeconds int `json:"ttlSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type orgChartSnapshotResponse struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
Cache orgChartSnapshotCacheInfo `json:"cache"`
|
||||
}
|
||||
|
||||
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
|
||||
ordered := append([]domain.Tenant(nil), tenants...)
|
||||
pagination.SortByKeyDesc(ordered, func(tenant domain.Tenant) (time.Time, string) {
|
||||
@@ -360,7 +382,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -369,6 +391,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
for _, t := range tenants {
|
||||
summary := mapTenantSummary(t)
|
||||
summary.MemberCount = memberCounts[t.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[t.ID]
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
@@ -1656,13 +1679,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
|
||||
summary := mapTenantSummary(tenant)
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
|
||||
return c.JSON(summary)
|
||||
}
|
||||
@@ -1748,6 +1772,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
|
||||
summary := mapTenantSummary(*tenant)
|
||||
summary.MemberCount = 0
|
||||
summary.TotalMemberCount = 0
|
||||
|
||||
if req.Config != nil {
|
||||
config, err := normalizeTenantConfig(req.Config)
|
||||
@@ -2658,25 +2683,33 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
||||
return build(rootID)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
return counts, counts, nil
|
||||
}
|
||||
if h.UserProjectionRepo == nil {
|
||||
return nil, errors.New("user projection is not configured")
|
||||
return nil, nil, errors.New("user projection is not configured")
|
||||
}
|
||||
ready, err := h.UserProjectionRepo.IsReady(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return nil, errors.New("user projection is not ready")
|
||||
return nil, nil, errors.New("user projection is not ready")
|
||||
}
|
||||
return h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return directCounts, totalCounts, nil
|
||||
}
|
||||
|
||||
func normalizeTenantStatus(value string) string {
|
||||
@@ -2736,6 +2769,230 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Share link deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||
ttl := orgChartSnapshotCacheTTL()
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
var cached orgChartSnapshotResponse
|
||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||
cached.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "HIT")
|
||||
return c.JSON(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
}
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := json.Marshal(snapshot); err == nil {
|
||||
_ = h.OrgChartCache.Set(cacheKey, string(raw), ttl)
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "MISS")
|
||||
} else {
|
||||
c.Set("X-Orgfront-Cache", "BYPASS")
|
||||
}
|
||||
|
||||
return c.JSON(snapshot)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
|
||||
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
tenantSummaries := make([]tenantSummary, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
summary := mapTenantSummary(tenant)
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
tenantSummaries = append(tenantSummaries, summary)
|
||||
}
|
||||
|
||||
users, err := h.listOrgChartUsers(ctx, profile, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
|
||||
return orgChartSnapshotResponse{
|
||||
Tenants: tenantSummaries,
|
||||
Users: users,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profile *domain.UserProfileResponse) ([]domain.Tenant, error) {
|
||||
if h.Service == nil {
|
||||
return nil, errors.New("tenant service is not configured")
|
||||
}
|
||||
role := ""
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
}
|
||||
if role == domain.RoleSuperAdmin {
|
||||
tenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
|
||||
return tenants, err
|
||||
}
|
||||
|
||||
allTenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
baseTenantIDs := make([]string, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+1)
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
baseTenantIDs = append(baseTenantIDs, tenant.ID)
|
||||
}
|
||||
for _, tenant := range profile.JoinedTenants {
|
||||
baseTenantIDs = append(baseTenantIDs, tenant.ID)
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
|
||||
}
|
||||
|
||||
parentMap := make(map[string]string)
|
||||
for _, tenant := range allTenants {
|
||||
if tenant.ParentID != nil {
|
||||
parentMap[tenant.ID] = *tenant.ParentID
|
||||
}
|
||||
}
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
for {
|
||||
parentID, exists := parentMap[curr]
|
||||
if !exists || parentID == "" {
|
||||
return curr
|
||||
}
|
||||
curr = parentID
|
||||
}
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
roots[findRoot(id)] = true
|
||||
}
|
||||
}
|
||||
|
||||
tenants := make([]domain.Tenant, 0, len(allTenants))
|
||||
for _, tenant := range allTenants {
|
||||
if roots[findRoot(tenant.ID)] {
|
||||
tenants = append(tenants, tenant)
|
||||
}
|
||||
}
|
||||
|
||||
return h.filterPrivateTenantsForProfile(ctx, tenants, profile)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
|
||||
if h.UserRepo == nil {
|
||||
return nil, errors.New("user repository is not configured")
|
||||
}
|
||||
role := ""
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
}
|
||||
tenantIDs := []string{}
|
||||
if role != domain.RoleSuperAdmin {
|
||||
tenantIDs = make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
|
||||
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summaries := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
summary := userSummary{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LoginID: user.Email,
|
||||
Name: user.Name,
|
||||
Phone: user.Phone,
|
||||
Role: domain.NormalizeRole(user.Role),
|
||||
Status: normalizeStatus(user.Status),
|
||||
TenantSlug: userTenantSlug(user),
|
||||
CompanyCode: userTenantSlug(user),
|
||||
Metadata: user.Metadata,
|
||||
Tenant: user.Tenant,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
UpdatedAt: formatTime(user.UpdatedAt),
|
||||
}
|
||||
if h.Service != nil {
|
||||
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
|
||||
role := "anonymous"
|
||||
userID := "anonymous"
|
||||
tenantID := strings.TrimSpace(tenantHeader)
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
userID = strings.TrimSpace(profile.ID)
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
if tenantID == "" {
|
||||
tenantID = "none"
|
||||
}
|
||||
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheTTL() time.Duration {
|
||||
const defaultTTL = 5 * time.Minute
|
||||
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
|
||||
if raw == "" {
|
||||
return defaultTTL
|
||||
}
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds <= 0 {
|
||||
return defaultTTL
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -190,6 +191,25 @@ type MockUserProjectionRepoForHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type mockOrgChartCache struct {
|
||||
mock.Mock
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (m *mockOrgChartCache) Get(key string) (string, error) {
|
||||
args := m.Called(key)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockOrgChartCache) Set(key string, value string, expiration time.Duration) error {
|
||||
if m.values == nil {
|
||||
m.values = make(map[string]string)
|
||||
}
|
||||
m.values[key] = value
|
||||
args := m.Called(key, value, expiration)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Bool(0), args.Error(1)
|
||||
@@ -208,6 +228,14 @@ func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Contex
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
args := m.Called(ctx, tenants)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
args := m.Called(ctx, users)
|
||||
return args.Error(0)
|
||||
@@ -278,6 +306,8 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -289,6 +319,135 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
|
||||
require.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
|
||||
app := fiber.New()
|
||||
cache := &mockOrgChartCache{}
|
||||
cached := `{"tenants":[{"id":"family","type":"COMPANY_GROUP","name":"한맥가족","slug":"hanmac-family","description":"","status":"active","memberCount":0,"totalMemberCount":2,"createdAt":"2026-06-09T00:00:00Z","updatedAt":"2026-06-09T00:00:00Z"}],"users":[],"cache":{"source":"redis","hit":true}}`
|
||||
cache.On("Get", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
})).Return(cached, nil).Once()
|
||||
|
||||
h := &TenantHandler{OrgChartCache: cache}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "HIT", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, "redis", body["cache"].(map[string]any)["source"])
|
||||
cache.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
samanID := "saman"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: samanID, Type: domain.TenantTypeCompany, Name: "삼안", Slug: "saman", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
users := []domain.User{
|
||||
{ID: "user-1", Email: "user@example.com", Name: "User One", Role: domain.RoleUser, Status: "active", TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
|
||||
cache.On("Set", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Tenants, 2)
|
||||
require.Len(t, body.Users, 1)
|
||||
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
parentID := "00000000-0000-0000-0000-000000000001"
|
||||
childID := "00000000-0000-0000-0000-000000000002"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: parentID, Name: "Parent", Slug: "parent"},
|
||||
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res tenantListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Len(t, res.Items, 2)
|
||||
assert.Equal(t, int64(1), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].MemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
|
||||
mockProjection.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -321,6 +480,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
|
||||
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
@@ -350,6 +510,8 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -399,6 +561,7 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -468,6 +631,9 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -517,6 +683,9 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
})).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -936,6 +1105,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
|
||||
Return(map[string]int64{"saman": 152}, nil).Maybe()
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OryProviderAPI defines the subset of Ory Provider used by UserHandler
|
||||
@@ -99,6 +100,76 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func userAppointmentSliceFromRaw(raw any) []any {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
return append([]any(nil), values...)
|
||||
case []map[string]any:
|
||||
appointments := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
appointments = append(appointments, value)
|
||||
}
|
||||
return appointments
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAppointmentTenantKey(raw any) string {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
|
||||
return "id:" + strings.ToLower(value)
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["tenantSlug"]); value != "" {
|
||||
return "slug:" + strings.ToLower(value)
|
||||
}
|
||||
if value := normalizeMetadataString(appointment["slug"]); value != "" {
|
||||
return "slug:" + strings.ToLower(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
|
||||
if tenant == nil {
|
||||
return metadata
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
|
||||
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
|
||||
appointments = incoming
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(appointments)+1)
|
||||
for _, appointment := range appointments {
|
||||
if key := userAppointmentTenantKey(appointment); key != "" {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
tenantIDKey := "id:" + strings.ToLower(strings.TrimSpace(tenant.ID))
|
||||
tenantSlugKey := "slug:" + strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||
if !seen[tenantIDKey] && !seen[tenantSlugKey] {
|
||||
appointments = append(appointments, map[string]any{
|
||||
"tenantId": tenant.ID,
|
||||
"tenantSlug": tenant.Slug,
|
||||
"tenantName": tenant.Name,
|
||||
"isPrimary": false,
|
||||
})
|
||||
}
|
||||
metadata["additionalAppointments"] = appointments
|
||||
return metadata
|
||||
}
|
||||
|
||||
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
|
||||
if tenantService == nil || metadata == nil {
|
||||
return false, nil
|
||||
@@ -534,6 +605,66 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.UserRepo != nil {
|
||||
var tenantIDs []string
|
||||
if tenantSlug != "" {
|
||||
if targetTenantID == "" {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
if requesterRole != domain.RoleSuperAdmin && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
tenantIDs = append(tenantIDs, targetTenantID)
|
||||
} else if requesterRole != domain.RoleSuperAdmin {
|
||||
for key := range manageableSlugs {
|
||||
if _, err := uuid.Parse(key); err == nil {
|
||||
tenantIDs = append(tenantIDs, key)
|
||||
}
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
|
||||
}
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, h.mapLocalUserSummary(c.Context(), user))
|
||||
}
|
||||
if cursorRaw != "" {
|
||||
offset = 0
|
||||
}
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
@@ -1615,11 +1746,18 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
|
||||
// 1. Fetch Users using Repo for efficiency
|
||||
var exportTenantIDs []string
|
||||
if tenantSlug != "" && h.TenantService != nil {
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && t != nil {
|
||||
exportTenantIDs = []string{t.ID}
|
||||
if tenantSlug != "" {
|
||||
if h.TenantService == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service unavailable for scoped export")
|
||||
}
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve tenant for export")
|
||||
}
|
||||
if t == nil || strings.TrimSpace(t.ID) == "" {
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found for export")
|
||||
}
|
||||
exportTenantIDs = []string{t.ID}
|
||||
}
|
||||
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "")
|
||||
if err != nil {
|
||||
@@ -2087,7 +2225,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
// All non-superadmins can only move users within tenants they can manage.
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
|
||||
if !req.IsRemoveTenant && req.CompanyCode != nil {
|
||||
targetSlug := strings.TrimSpace(*req.CompanyCode)
|
||||
targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
|
||||
if !targetAllowed && h.TenantService != nil && targetSlug != "" {
|
||||
@@ -2096,7 +2234,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if !targetAllowed {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot change user's tenant to an unmanageable one")
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot assign user's tenant to an unmanageable one")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2221,6 +2359,21 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
} else if h.TenantService != nil && code != "" {
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code)
|
||||
if err != nil || tenant == nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
||||
}
|
||||
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
|
||||
if h.KetoOutboxRepo != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
@@ -2775,6 +2928,45 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User) userSummary {
|
||||
tenantSlug := userTenantSlug(user)
|
||||
customLoginIDs := make([]string, 0, len(user.UserLoginIDs))
|
||||
for _, loginID := range user.UserLoginIDs {
|
||||
if strings.TrimSpace(loginID.LoginID) != "" {
|
||||
customLoginIDs = append(customLoginIDs, strings.TrimSpace(loginID.LoginID))
|
||||
}
|
||||
}
|
||||
|
||||
summary := userSummary{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LoginID: user.Email,
|
||||
CustomLoginIDs: customLoginIDs,
|
||||
Name: user.Name,
|
||||
Phone: user.Phone,
|
||||
Role: domain.NormalizeRole(user.Role),
|
||||
Status: normalizeStatus(user.Status),
|
||||
TenantSlug: tenantSlug,
|
||||
CompanyCode: tenantSlug,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
Metadata: user.Metadata,
|
||||
Tenant: user.Tenant,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
UpdatedAt: formatTime(user.UpdatedAt),
|
||||
}
|
||||
|
||||
if h.TenantService != nil {
|
||||
if joined, err := h.TenantService.ListJoinedTenants(ctx, user.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||
return normalizePhoneNumber(phone)
|
||||
}
|
||||
@@ -3302,18 +3494,7 @@ func normalizeKratosState(status *string) string {
|
||||
}
|
||||
|
||||
func normalizePhoneNumber(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, checkRequired bool) error {
|
||||
|
||||
@@ -320,7 +320,8 @@ func (m *MockTenantServiceForUser) RegisterTenant(ctx context.Context, name, slu
|
||||
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{UserRepo: mockRepo}
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
@@ -332,7 +333,11 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
|
||||
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string{tenantID}, "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "u-1",
|
||||
@@ -362,9 +367,34 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,tenant-uuid,test-tenant,책임,팀장")
|
||||
assert.NotContains(t, body, "Role")
|
||||
assert.NotContains(t, body, "Department")
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ExportUsersCSV_UnknownTenantSlugDoesNotFallbackToAllUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users/export", h.ExportUsersCSV)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "missing-tenant").Return(nil, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=missing-tenant&includeIds=true", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
@@ -951,10 +981,11 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) {
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
@@ -970,14 +1001,86 @@ func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testin
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 10, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), "", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertExpectations(t)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
kratosIdentities := make([]service.KratosIdentity, 250)
|
||||
for i := range kratosIdentities {
|
||||
kratosIdentities[i] = service.KratosIdentity{
|
||||
ID: "kratos-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Duration(i) * time.Second),
|
||||
Traits: map[string]any{"email": "kratos@example.com", "name": "Kratos"},
|
||||
}
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 50, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(2114), "next-local-cursor", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2114), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
require.Equal(t, "next-local-cursor", res.NextCursor)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
@@ -2363,6 +2466,157 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
|
||||
ID: "private-team-id",
|
||||
Name: "비공개 팀",
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "private",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
|
||||
ID: "primary-tenant-id",
|
||||
Name: "대표 조직",
|
||||
Slug: "primary-tenant",
|
||||
}, nil).Maybe()
|
||||
|
||||
var capturedTraits map[string]any
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
capturedTraits = args.Get(2).(map[string]any)
|
||||
}).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "primary-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "primary-tenant-id",
|
||||
"tenantSlug": "primary-tenant",
|
||||
"tenantName": "대표 조직",
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": "private-team-id",
|
||||
"tenantSlug": "private-team",
|
||||
"tenantName": "비공개 팀",
|
||||
"isPrimary": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
body := `{"tenantSlug":"private-team","isAddTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
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, "primary-tenant-id", capturedTraits["tenant_id"])
|
||||
appointments, ok := capturedTraits["additionalAppointments"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, appointments, 2)
|
||||
added := appointments[1].(map[string]any)
|
||||
require.Equal(t, "private-team-id", added["tenantId"])
|
||||
require.Equal(t, "private-team", added["tenantSlug"])
|
||||
require.Equal(t, "비공개 팀", added["tenantName"])
|
||||
require.Equal(t, false, added["isPrimary"])
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantRejectsUnmanageableTenantForTenantAdmin(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
allowedTenantID := "allowed-tenant-id"
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "tenant-admin-id",
|
||||
Role: "tenant_admin",
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: allowedTenantID, Slug: "allowed-team"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": allowedTenantID,
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "outside-team").Return(&domain.Tenant{
|
||||
ID: "outside-tenant-id",
|
||||
Name: "관리 외부 팀",
|
||||
Slug: "outside-team",
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"outside-team","isAddTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
@@ -72,11 +72,17 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
||||
|
||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
credentialRequest, err := parseWorksmobileCredentialRequest(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
job, err := h.Service.EnqueueUserSync(
|
||||
c.Context(),
|
||||
strings.TrimSpace(c.Params("tenantId")),
|
||||
userID,
|
||||
credentialRequest.CredentialBatchID,
|
||||
credentialRequest.InitialPassword,
|
||||
)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
||||
}
|
||||
@@ -158,21 +164,30 @@ func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error
|
||||
|
||||
type worksmobileCredentialBatchRequest struct {
|
||||
CredentialBatchID string `json:"credentialBatchId"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
|
||||
req, err := parseWorksmobileCredentialRequest(c)
|
||||
return req.CredentialBatchID, err
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialRequest(c *fiber.Ctx) (worksmobileCredentialBatchRequest, error) {
|
||||
batchID := strings.TrimSpace(c.Query("credentialBatchId"))
|
||||
req := worksmobileCredentialBatchRequest{CredentialBatchID: batchID}
|
||||
if len(bytes.TrimSpace(c.Body())) == 0 {
|
||||
return batchID, nil
|
||||
return req, nil
|
||||
}
|
||||
var req worksmobileCredentialBatchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return "", err
|
||||
return worksmobileCredentialBatchRequest{}, err
|
||||
}
|
||||
req.InitialPassword = strings.TrimSpace(req.InitialPassword)
|
||||
if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
|
||||
return bodyBatchID, nil
|
||||
req.CredentialBatchID = bodyBatchID
|
||||
return req, nil
|
||||
}
|
||||
return batchID, nil
|
||||
req.CredentialBatchID = batchID
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
||||
|
||||
@@ -97,13 +97,14 @@ func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1","initialPassword":"InputPass1!"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
|
||||
require.Equal(t, "InputPass1!", fakeService.syncUserInitialPassword)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
|
||||
@@ -199,6 +200,7 @@ type fakeWorksmobileAdminService struct {
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
syncUserCredentialBatchID string
|
||||
syncUserInitialPassword string
|
||||
resetPasswordCredentialBatchID string
|
||||
downloadCredentialBatchID string
|
||||
deletedCredentialBatchID string
|
||||
@@ -227,8 +229,9 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword string) (*domain.WorksmobileOutbox, error) {
|
||||
f.syncUserCredentialBatchID = credentialBatchID
|
||||
f.syncUserInitialPassword = initialPassword
|
||||
if f.syncUserErr != nil {
|
||||
return nil, f.syncUserErr
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user