1
0
forked from baron/baron-sso

kratos SSOT 재설계

This commit is contained in:
2026-06-12 18:36:18 +09:00
parent b96c8100e0
commit 8e9d015443
39 changed files with 3960 additions and 501 deletions

View File

@@ -307,13 +307,6 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.UserProjectionRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
}
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
cacheStatus := domain.IdentityCacheStatus{
Status: "unavailable",
@@ -321,6 +314,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
LastError: "identity cache service unavailable",
}
if h.IdentityCache != nil {
var err error
cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context())
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
@@ -328,8 +322,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{
"userProjection": projectionStatus,
"identityCache": cacheStatus,
"identityCache": cacheStatus,
})
}

View File

@@ -209,7 +209,7 @@ func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t
require.Equal(t, int64(152), body.ProjectedUsers)
}
func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) {
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
cache := &fakeIdentityCacheAdmin{
status: domain.IdentityCacheStatus{
@@ -222,15 +222,6 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t
},
}
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
LastSyncedAt: &syncedAt,
ProjectedUsers: 152,
},
},
IdentityCache: cache,
}
app := fiber.New()
@@ -246,11 +237,11 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
UserProjection domain.UserProjectionStatus `json:"userProjection"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, int64(152), body.UserProjection.ProjectedUsers)
require.Nil(t, body.UserProjection)
require.True(t, body.IdentityCache.RedisReady)
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
require.Equal(t, int64(153), body.IdentityCache.KeyCount)

View File

@@ -24,7 +24,6 @@ import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// OryProviderAPI defines the subset of Ory Provider used by UserHandler
@@ -44,6 +43,7 @@ type UserHandler struct {
UserProjectionRepo repository.UserProjectionRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
IdentityCache domain.RedisRepository
Worksmobile service.WorksmobileSyncer
}
@@ -589,6 +589,24 @@ func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]boo
return allowed
}
func identityMirrorKey(identityID string) string {
return "identity:mirror:" + strings.TrimSpace(identityID)
}
type identityMirrorLister interface {
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
}
type identityMirrorStatusReader interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
}
type identityMirrorFlusher interface {
FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error)
}
const identityMirrorVersion = "kratos-full-pagination-v1"
func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool {
allowed := profileTenantAccessKeys(profile)
if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] {
@@ -654,6 +672,26 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
return timestamp, identity.ID
}
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
if searchLower == "" {
return true
}
if strings.Contains(strings.ToLower(identity.ID), searchLower) {
return true
}
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "email")), searchLower) {
return true
}
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
return true
}
rawTraits, err := json.Marshal(identity.Traits)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
}
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Get requester profile from middleware
var requesterRole string
@@ -745,161 +783,96 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
}
}
if h.UserRepo != nil {
var tenantIDs []string
if tenantSlug != "" && targetTenantID == "" {
return c.JSON(userListResponse{
Items: []userSummary{},
Limit: limit,
Offset: offset,
Total: 0,
Cursor: cursorRaw,
})
}
if requesterRole != domain.RoleSuperAdmin && tenantSlug != "" && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
return c.JSON(userListResponse{
Items: []userSummary{},
Limit: limit,
Offset: offset,
Total: 0,
Cursor: cursorRaw,
})
}
identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
if err != nil {
slog.Warn("Identity mirror unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
}
filtered := make([]service.KratosIdentity, 0, len(identities))
searchLower := strings.ToLower(search)
for _, identity := range identities {
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID]
if !hasAccess {
continue
}
}
// Dedicated tenantSlug filter
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,
})
matches := tID == targetTenantID
if !matches {
continue
}
}
users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw)
if !identityMatchesSearch(identity, searchLower) {
continue
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
items := make([]userSummary, 0, len(users))
for _, user := range users {
items = append(items, h.mapLocalUserSummary(c.Context(), user))
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
if cursorRaw != "" {
offset = 0
end := min(offset+limit, len(filtered))
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
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")
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
identities, err := h.KratosAdmin.ListIdentities(c.Context())
if err == nil {
filtered := make([]service.KratosIdentity, 0, len(identities))
searchLower := strings.ToLower(search)
for _, identity := range identities {
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID]
if !hasAccess {
continue
}
}
// Dedicated tenantSlug filter
if tenantSlug != "" {
matches := tID == targetTenantID
if !matches {
continue
}
}
// Search filtering
if search != "" {
matchesSearch := strings.Contains(email, searchLower) ||
strings.Contains(name, searchLower)
if !matchesSearch {
continue
}
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
end := min(offset+limit, len(filtered))
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
// This ensures that member counts (which use local DB) eventually match reality
if h.UserRepo != nil {
go func(ids []service.KratosIdentity) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, identity := range ids {
localUser := h.mapToLocalUser(identity)
_ = h.UserRepo.Update(ctx, localUser)
}
}(filtered)
}
return c.JSON(userListResponse{
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
slog.Warn("Kratos unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
return c.JSON(userListResponse{
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
@@ -912,26 +885,30 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
}
if identity := h.getIdentityFromMirror(userID); identity != nil {
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role != domain.RoleSuperAdmin {
allowedKeys := profileTenantAccessKeys(requester)
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
}
}
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err != nil || identity == nil {
// [FIX] Support fixed UUID lookup fallback
id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
if searchErr == nil && id != "" {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
if err != nil || identity == nil {
// Second Fallback: By Email from local DB
if h.UserRepo != nil {
local, _ := h.UserRepo.FindByID(c.Context(), userID)
if local != nil && local.Email != "" {
id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
if id != "" {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
}
if cached := h.getIdentityFromMirror(id); cached != nil {
identity = cached
err = nil
}
}
if searchErr == nil && id != "" && (err != nil || identity == nil) {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
if err != nil || identity == nil {
if identity == nil {
@@ -940,6 +917,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
h.storeIdentityMirror(*identity)
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
@@ -953,6 +931,149 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
}
func (h *UserHandler) getIdentityFromMirror(identityID string) *service.KratosIdentity {
if h == nil || h.IdentityCache == nil {
return nil
}
raw, err := h.IdentityCache.Get(identityMirrorKey(identityID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
return nil
}
if strings.TrimSpace(identity.ID) == "" {
return nil
}
return &identity
}
func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]service.KratosIdentity, error) {
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorLister); ok {
identities, err := lister.ListIdentityMirrors(ctx)
if err != nil {
return nil, err
}
if h.identityMirrorReady(ctx, len(identities)) {
return identities, nil
}
}
}
if h == nil || h.KratosAdmin == nil {
return nil, errors.New("identity mirror is empty and kratos admin service is unavailable")
}
return h.rebuildIdentityMirror(ctx)
}
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
return 0, err
}
return len(identities), nil
}
func (h *UserHandler) rebuildIdentityMirror(ctx context.Context) ([]service.KratosIdentity, error) {
if h == nil || h.KratosAdmin == nil {
return nil, errors.New("kratos admin service is unavailable")
}
identities, err := h.KratosAdmin.ListIdentities(ctx)
if err != nil {
return nil, err
}
h.flushIdentityMirror(ctx)
for _, identity := range identities {
h.storeIdentityMirror(identity)
}
h.markIdentityMirrorReady(len(identities))
return identities, nil
}
func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int) bool {
if h == nil || h.IdentityCache == nil || identityCount == 0 {
return false
}
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion &&
status.ObservedCount == int64(identityCount)
}
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
if h == nil || h.IdentityCache == nil {
return
}
flusher, ok := h.IdentityCache.(identityMirrorFlusher)
if !ok {
return
}
_, _ = flusher.FlushIdentityCache(ctx)
}
func (h *UserHandler) markIdentityMirrorReady(identityCount int) {
if h == nil || h.IdentityCache == nil {
return
}
now := time.Now().UTC()
status := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: int64(identityCount),
LastRefreshedAt: &now,
UpdatedAt: &now,
}
raw, err := json.Marshal(status)
if err != nil {
return
}
_ = h.IdentityCache.Set("identity:mirror:state", string(raw), 0)
}
func (h *UserHandler) invalidateIdentityMirrorState() {
if h == nil || h.IdentityCache == nil {
return
}
_ = h.IdentityCache.Delete("identity:mirror:state")
}
func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
}
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
}
func (h *UserHandler) updateIdentityMirrorEntry(identity service.KratosIdentity) {
h.storeIdentityMirror(identity)
h.invalidateIdentityMirrorState()
}
func (h *UserHandler) deleteIdentityMirrorEntry(identityID string) {
if h == nil || h.IdentityCache == nil {
return
}
identityID = strings.TrimSpace(identityID)
if identityID != "" {
_ = h.IdentityCache.Delete(identityMirrorKey(identityID))
}
h.invalidateIdentityMirrorState()
}
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if h.OryProvider == nil || h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
@@ -1171,8 +1292,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if identity == nil {
h.invalidateIdentityMirrorState()
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
}
h.updateIdentityMirrorEntry(*identity)
// [New] Local DB Sync - Ensure user exists in read-model
if h.UserRepo != nil {
@@ -1672,6 +1795,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
} else {
resultStatus = "created"
h.invalidateIdentityMirrorState()
slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
}
}
@@ -2160,6 +2284,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
h.updateIdentityMirrorEntry(*updated)
// Sync to local DB
if h.UserRepo != nil {
@@ -2267,6 +2392,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
h.deleteIdentityMirrorEntry(id)
if h.Worksmobile != nil {
localUser := h.mapToLocalUser(*identity)
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
@@ -2635,6 +2761,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.updateIdentityMirrorEntry(*updated)
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
if h.UserRepo != nil {
@@ -2807,6 +2934,10 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
h.deleteIdentityMirrorEntry(userID)
if actualKratosID != userID {
h.deleteIdentityMirrorEntry(actualKratosID)
}
slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)
if h.Worksmobile != nil && identity != nil {
@@ -3003,16 +3134,6 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
traits := identity.Traits
role := roleFromTraits(traits)
// [FIX] Prioritize Local DB ID (the fixed UUID from user)
finalID := identity.ID
email := extractTraitString(traits, "email")
if h.UserRepo != nil && email != "" {
// 1. Try finding by email first as it's a strong identifier
if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
finalID = local.ID
}
}
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := ""
var tenantSummary *domain.Tenant
@@ -3038,7 +3159,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
}
summary := userSummary{
ID: finalID,
ID: identity.ID,
Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits),
CustomLoginIDs: customLoginIDs,

View File

@@ -0,0 +1,46 @@
package handler
import (
"baron-sso-backend/internal/service"
"context"
"os"
"strconv"
"testing"
"time"
)
func TestUserHandler_LiveWarmIdentityMirrorLatency(t *testing.T) {
if os.Getenv("BARON_LIVE_IDENTITY_MIRROR_TEST") != "1" {
t.Skip("set BARON_LIVE_IDENTITY_MIRROR_TEST=1 to run against local Kratos and Redis")
}
redisService, err := service.NewRedisService()
if err != nil {
t.Fatalf("connect redis: %v", err)
}
kratosAdmin := service.NewKratosAdminService()
handler := &UserHandler{
KratosAdmin: kratosAdmin,
IdentityCache: redisService,
}
startedAt := time.Now()
count, err := handler.WarmIdentityMirror(context.Background())
elapsed := time.Since(startedAt)
if err != nil {
t.Fatalf("warm identity mirror: %v", err)
}
maxMillis := int64(2000)
if raw := os.Getenv("BARON_LIVE_IDENTITY_MIRROR_MAX_MS"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
t.Fatalf("invalid BARON_LIVE_IDENTITY_MIRROR_MAX_MS=%q", raw)
}
maxMillis = parsed
}
t.Logf("identity mirror warmup identities=%d elapsed=%s max=%dms", count, elapsed, maxMillis)
if elapsed > time.Duration(maxMillis)*time.Millisecond {
t.Fatalf("identity mirror warmup took %s, over %dms", elapsed, maxMillis)
}
}

View File

@@ -981,15 +981,88 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
mockOry.AssertExpectations(t)
}
func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
type identityMirrorRedisStub struct {
mockRedisRepo
}
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
continue
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
identities = append(identities, identity)
}
return identities, nil
}
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
raw := s.data["identity:mirror:state"]
if strings.TrimSpace(raw) == "" {
return domain.IdentityCacheStatus{RedisReady: true, Status: "empty"}, nil
}
var status domain.IdentityCacheStatus
if err := json.Unmarshal([]byte(raw), &status); err != nil {
return domain.IdentityCacheStatus{}, err
}
return status, nil
}
func (s *identityMirrorRedisStub) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
var deleted int64
for key := range s.data {
if strings.HasPrefix(key, "identity:mirror:") || strings.HasPrefix(key, "identity:index:") {
delete(s.data, key)
deleted++
}
}
return domain.IdentityCacheFlushResult{
Status: "success",
FlushedKeys: deleted,
UpdatedAt: time.Now().UTC(),
}, nil
}
func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockRepo := new(MockUserRepoForHandler)
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
mirrorIdentity := service.KratosIdentity{
ID: "mirror-user-1",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "mirror1@example.com",
"name": "Mirror One",
},
}
rawMirrorIdentity, err := json.Marshal(mirrorIdentity)
require.NoError(t, err)
state := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: 1,
}
rawState, err := json.Marshal(state)
require.NoError(t, err)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
"identity:mirror:state": string(rawState),
}}},
}
app.Use(func(c *fiber.Ctx) error {
@@ -1000,19 +1073,6 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
})
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)
@@ -1023,19 +1083,21 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
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)
require.Equal(t, "mirror-user-1", res.Items[0].ID)
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
}
func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) {
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockRepo := new(MockUserRepoForHandler)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
@@ -1046,27 +1108,11 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
})
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"},
}
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
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)
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
resp, err := app.Test(req)
@@ -1076,11 +1122,162 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
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)
require.Equal(t, int64(2), res.Total)
require.Len(t, res.Items, 2)
require.Equal(t, "kratos-user-1", res.Items[0].ID)
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, "ready", status.Status)
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
require.Equal(t, int64(2), status.ObservedCount)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
}}}
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
identities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Second), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(identities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
count, err := h.WarmIdentityMirror(context.Background())
require.NoError(t, err)
require.Equal(t, 2, count)
require.Empty(t, redis.data[identityMirrorKey("stale-user")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, "ready", status.Status)
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
require.Equal(t, int64(2), status.ObservedCount)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 8, 6, 55, 0, 0, time.UTC)
legacyIdentity := service.KratosIdentity{
ID: "legacy-partial-user",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "legacy@example.com",
"name": "Legacy Partial",
},
}
rawLegacyIdentity, err := json.Marshal(legacyIdentity)
require.NoError(t, err)
legacyState := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
ObservedCount: 1,
}
rawLegacyState, err := json.Marshal(legacyState)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
"identity:mirror:state": string(rawLegacyState),
}}}
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
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(2), res.Total)
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 8, 6, 50, 0, 0, time.UTC)
partialIdentity := service.KratosIdentity{
ID: "partial-user",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "partial@example.com",
"name": "Partial",
},
}
rawPartialIdentity, err := json.Marshal(partialIdentity)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
}}}
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
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(2), res.Total)
require.Empty(t, redis.data[identityMirrorKey("partial-user")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
@@ -1117,6 +1314,86 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
require.Equal(t, int64(3), res.Total)
}
func TestUserHandler_GetUserUsesIdentityMirrorBeforeKratos(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 12, 8, 20, 0, 0, time.UTC)
userID := "2b7fd276-b25f-45ef-b691-ea9d72e701e1"
identity := service.KratosIdentity{
ID: userID,
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "mirror-user@example.com",
"name": "Mirror User",
},
}
rawIdentity, err := json.Marshal(identity)
require.NoError(t, err)
redis := &mockRedisRepo{data: map[string]string{
identityMirrorKey(userID): string(rawIdentity),
}}
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/:id", h.GetUser)
req := httptest.NewRequest(http.MethodGet, "/users/"+userID, nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got userSummary
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, userID, got.ID)
require.Equal(t, "mirror-user@example.com", got.Email)
require.Equal(t, "Mirror User", got.Name)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
redis := &mockRedisRepo{data: map[string]string{
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
}}
h := &UserHandler{IdentityCache: redis}
identity := service.KratosIdentity{
ID: "user-1",
Traits: map[string]any{
"email": "user1@example.com",
},
}
h.updateIdentityMirrorEntry(identity)
require.Empty(t, redis.data["identity:mirror:state"])
require.NotEmpty(t, redis.data[identityMirrorKey("user-1")])
}
func TestUserHandler_DeleteIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
redis := &mockRedisRepo{data: map[string]string{
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
identityMirrorKey("u-1"): `{"id":"u-1"}`,
}}
h := &UserHandler{IdentityCache: redis}
h.deleteIdentityMirrorEntry("u-1")
require.Empty(t, redis.data["identity:mirror:state"])
require.Empty(t, redis.data[identityMirrorKey("u-1")])
}
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

View File

@@ -213,5 +213,19 @@ func worksmobileGuardError(c *fiber.Ctx, err error, operation string, attrs ...a
if strings.Contains(err.Error(), "hanmac-family root") {
return errorJSON(c, fiber.StatusNotFound, err.Error())
}
if worksmobileBadRequestError(err) {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
func worksmobileBadRequestError(err error) bool {
message := err.Error()
return strings.Contains(message, "target user tenant is excluded from Worksmobile sync") ||
strings.Contains(message, "target user is outside hanmac-family subtree") ||
strings.Contains(message, "target user has no tenant") ||
strings.Contains(message, "target user status is excluded from Worksmobile sync") ||
strings.Contains(message, "target tenant is excluded from Worksmobile sync") ||
strings.Contains(message, "target tenant is not a worksmobile orgunit tenant") ||
strings.Contains(message, "target orgunit is outside hanmac-family subtree")
}

View File

@@ -195,6 +195,19 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
require.Contains(t, logs.String(), "works user sync failed")
}
func TestWorksmobileHandlerReturnsBadRequestForOutOfScopeUserSync(t *testing.T) {
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
syncUserErr: errors.New("target user tenant is excluded from Worksmobile sync"),
})
app := fiber.New()
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
resp, err := app.Test(httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", nil))
require.NoError(t, err)
require.Equal(t, fiber.StatusBadRequest, resp.StatusCode)
}
type fakeWorksmobileAdminService struct {
overview service.WorksmobileTenantOverview
credentials []service.WorksmobileInitialPasswordCredential