forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -16,10 +16,8 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,25 +27,28 @@ import (
|
||||
)
|
||||
|
||||
type TenantHandler struct {
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
SharedLink service.SharedLinkService
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
Hydra *service.HydraAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
OrgChartCache orgChartCacheStore
|
||||
IdentityCache domain.RedisRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
SharedLink service.SharedLinkService
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
Hydra *service.HydraAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
}
|
||||
|
||||
type orgChartCacheStore interface {
|
||||
Get(key string) (string, error)
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
DeleteByPrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
const orgChartSnapshotCacheKeyPrefix = "orgchart:snapshot:v1:"
|
||||
|
||||
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
|
||||
}
|
||||
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
DB: db,
|
||||
Service: svc,
|
||||
UserRepo: userRepo,
|
||||
UserProjectionRepo: userProjectionRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
SharedLink: sharedLink,
|
||||
Hydra: hydra,
|
||||
ConsentRepo: consentRepo,
|
||||
DB: db,
|
||||
Service: svc,
|
||||
UserRepo: userRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
SharedLink: sharedLink,
|
||||
Hydra: hydra,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
||||
}
|
||||
@@ -311,12 +312,25 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
parentMap := make(map[string]string)
|
||||
tenantIDs := make(map[string]bool, len(allTenants))
|
||||
for _, t := range allTenants {
|
||||
tenantIDs[t.ID] = true
|
||||
if t.ParentID != nil {
|
||||
parentMap[t.ID] = *t.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
hasValidBaseTenant := false
|
||||
for _, id := range baseTenantIDs {
|
||||
if tenantIDs[strings.TrimSpace(id)] {
|
||||
hasValidBaseTenant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(baseTenantIDs) > 0 && !hasValidBaseTenant {
|
||||
return errorJSON(c, fiber.StatusConflict, "tenant scope is not available")
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
roots[findTenantRootID(parentMap, id)] = true
|
||||
@@ -372,7 +386,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -688,6 +702,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
result.Details = append(result.Details, detail)
|
||||
}
|
||||
if result.Created > 0 || result.Updated > 0 {
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -1247,6 +1264,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
|
||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
|
||||
if err != nil {
|
||||
if isMissingKetoRelationError(err) {
|
||||
continue
|
||||
}
|
||||
return false, fmt.Errorf("private tenant permission check failed: %w", err)
|
||||
}
|
||||
if allowed {
|
||||
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
|
||||
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
|
||||
if err != nil {
|
||||
if isMissingKetoRelationError(err) {
|
||||
continue
|
||||
}
|
||||
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
|
||||
}
|
||||
if allowed {
|
||||
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isMissingKetoRelationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "relation ") && strings.Contains(message, "does not exist")
|
||||
}
|
||||
|
||||
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
|
||||
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
|
||||
for _, tenant := range profile.ManageableTenants {
|
||||
@@ -1669,7 +1700,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), []domain.Tenant{tenant})
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -1795,6 +1826,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_created")
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(summary)
|
||||
}
|
||||
@@ -1955,6 +1987,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
|
||||
|
||||
return c.JSON(mapTenantSummary(tenant))
|
||||
}
|
||||
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
return seedTenantDeleteError(c)
|
||||
}
|
||||
|
||||
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), []string{tenantID}); err != nil {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
|
||||
logTenantCleanupFailure(err, []string{tenantID})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -1999,6 +2036,7 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
|
||||
}
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -2287,6 +2325,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
req.IDs = uniqueNonEmptyStrings(req.IDs)
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
@@ -2313,6 +2355,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
|
||||
logTenantCleanupFailure(err, req.IDs)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -2321,6 +2367,7 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Tenants deleted successfully",
|
||||
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
|
||||
if h == nil || h.DB == nil {
|
||||
return nil
|
||||
}
|
||||
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
|
||||
if len(deletedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(affectedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tenants []domain.Tenant
|
||||
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var affectedUsers []domain.User
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Where("tenant_id IN ?", affectedIDs).
|
||||
Find(&affectedUsers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for deletedID, targetID := range targets {
|
||||
if err := tx.Model(&domain.User{}).
|
||||
Where("tenant_id = ?", deletedID).
|
||||
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&domain.UserLoginID{}).
|
||||
Where("tenant_id = ?", deletedID).
|
||||
Update("tenant_id", targetID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
|
||||
continue
|
||||
}
|
||||
deletedID := strings.TrimSpace(*user.TenantID)
|
||||
targetID := strings.TrimSpace(targets[deletedID])
|
||||
if deletedID == "" || targetID == "" {
|
||||
continue
|
||||
}
|
||||
deletedTenant, ok := tenantByID[deletedID]
|
||||
if !ok {
|
||||
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
|
||||
}
|
||||
targetTenant, ok := tenantByID[targetID]
|
||||
if !ok {
|
||||
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||
}
|
||||
if identity == nil {
|
||||
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
|
||||
}
|
||||
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
|
||||
}
|
||||
if updated == nil {
|
||||
identity.Traits = traits
|
||||
h.storePromotedIdentityMirror(*identity)
|
||||
continue
|
||||
}
|
||||
h.storePromotedIdentityMirror(*updated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
|
||||
next := cloneIdentityTraits(traits)
|
||||
changed := false
|
||||
primaryChanged := forcePrimary
|
||||
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["tenant_id"] = targetTenant.ID
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["primaryTenantId"] = targetTenant.ID
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
|
||||
next["primaryTenantSlug"] = targetTenant.Slug
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
|
||||
next["primaryTenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
primaryChanged = true
|
||||
}
|
||||
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
|
||||
next["companyCode"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
|
||||
next["company_code"] = targetTenant.Slug
|
||||
changed = true
|
||||
}
|
||||
|
||||
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
|
||||
if appointmentsChanged {
|
||||
next["additionalAppointments"] = appointments
|
||||
changed = true
|
||||
}
|
||||
if primaryChanged {
|
||||
next["primaryTenantId"] = targetTenant.ID
|
||||
next["primaryTenantSlug"] = targetTenant.Slug
|
||||
next["primaryTenantName"] = targetTenant.Name
|
||||
}
|
||||
return next, changed
|
||||
}
|
||||
|
||||
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
|
||||
switch appointments := raw.(type) {
|
||||
case []any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, rawAppointment := range appointments {
|
||||
appointment, ok := rawAppointment.(map[string]any)
|
||||
if !ok {
|
||||
next = append(next, rawAppointment)
|
||||
continue
|
||||
}
|
||||
copied := maps.Clone(appointment)
|
||||
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
case []map[string]any:
|
||||
next := make([]any, 0, len(appointments))
|
||||
changed := false
|
||||
for _, appointment := range appointments {
|
||||
copied := maps.Clone(appointment)
|
||||
if identityAppointmentMatchesTenant(copied, deletedTenant) {
|
||||
copied["tenantId"] = targetTenant.ID
|
||||
copied["tenantSlug"] = targetTenant.Slug
|
||||
copied["tenantName"] = targetTenant.Name
|
||||
changed = true
|
||||
}
|
||||
next = append(next, copied)
|
||||
}
|
||||
return next, changed
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
|
||||
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
|
||||
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
|
||||
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
|
||||
}
|
||||
|
||||
func traitStringEqualTenant(value any, id string, slug string) bool {
|
||||
raw := strings.TrimSpace(fmt.Sprint(value))
|
||||
if raw == "" || raw == "<nil>" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(raw, strings.TrimSpace(id)) {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
|
||||
}
|
||||
|
||||
func cloneIdentityTraits(traits map[string]any) map[string]any {
|
||||
if traits == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
next := make(map[string]any, len(traits))
|
||||
for key, value := range traits {
|
||||
next[key] = cloneIdentityTraitValue(value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func cloneIdentityTraitValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(typed))
|
||||
for key, nested := range typed {
|
||||
next[key] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
for i, nested := range typed {
|
||||
next[i] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
case []map[string]any:
|
||||
next := make([]any, len(typed))
|
||||
for i, nested := range typed {
|
||||
next[i] = cloneIdentityTraitValue(nested)
|
||||
}
|
||||
return next
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TenantHandler) storePromotedIdentityMirror(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)
|
||||
_ = h.IdentityCache.Delete("identity:mirror:state")
|
||||
}
|
||||
|
||||
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
|
||||
referenced := make(map[string]bool)
|
||||
|
||||
var userTenantIDs []string
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Model(&domain.User{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Distinct("tenant_id").
|
||||
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tenantID := range userTenantIDs {
|
||||
if tenantID != "" {
|
||||
referenced[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var loginTenantIDs []string
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Model(&domain.UserLoginID{}).
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Distinct("tenant_id").
|
||||
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tenantID := range loginTenantIDs {
|
||||
if tenantID != "" {
|
||||
referenced[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(referenced))
|
||||
for _, tenantID := range tenantIDs {
|
||||
if referenced[tenantID] {
|
||||
result = append(result, tenantID)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
|
||||
deleted := make(map[string]bool, len(deletedTenantIDs))
|
||||
for _, tenantID := range deletedTenantIDs {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
deleted[tenantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
}
|
||||
|
||||
targets := make(map[string]string, len(affectedTenantIDs))
|
||||
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
|
||||
tenant, ok := tenantByID[affectedID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
|
||||
}
|
||||
|
||||
visited := map[string]bool{affectedID: true}
|
||||
for {
|
||||
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
|
||||
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
|
||||
}
|
||||
parentID := strings.TrimSpace(*tenant.ParentID)
|
||||
if visited[parentID] {
|
||||
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
|
||||
}
|
||||
visited[parentID] = true
|
||||
|
||||
parent, ok := tenantByID[parentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
|
||||
}
|
||||
if !deleted[parentID] && !parent.DeletedAt.Valid {
|
||||
targets[affectedID] = parent.ID
|
||||
break
|
||||
}
|
||||
tenant = parent
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
domains := make([]string, 0, len(t.Domains))
|
||||
for _, d := range t.Domains {
|
||||
@@ -2673,7 +3076,7 @@ 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, map[string]int64, error) {
|
||||
func (h *TenantHandler) countTenantMembers(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
|
||||
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
|
||||
if len(tenants) == 0 {
|
||||
return counts, counts, nil
|
||||
}
|
||||
if h.UserProjectionRepo == nil {
|
||||
return nil, nil, errors.New("user projection is not configured")
|
||||
if h.UserRepo == nil {
|
||||
return counts, counts, nil
|
||||
}
|
||||
ready, err := h.UserProjectionRepo.IsReady(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
|
||||
|
||||
tenantIDs := make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
return nil, nil, errors.New("user projection is not ready")
|
||||
}
|
||||
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
|
||||
|
||||
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
totalCounts := make(map[string]int64, len(tenants))
|
||||
allTenants := tenants
|
||||
if h.Service != nil {
|
||||
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
|
||||
allTenants = listed
|
||||
}
|
||||
}
|
||||
childrenByParentID := make(map[string][]domain.Tenant)
|
||||
for _, tenant := range allTenants {
|
||||
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
|
||||
continue
|
||||
}
|
||||
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
|
||||
if len(descendantIDs) == 0 {
|
||||
totalCounts[tenant.ID] = directCounts[tenant.ID]
|
||||
continue
|
||||
}
|
||||
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
|
||||
if countErr != nil {
|
||||
return nil, nil, countErr
|
||||
}
|
||||
totalCounts[tenant.ID] = total
|
||||
}
|
||||
return directCounts, totalCounts, nil
|
||||
}
|
||||
|
||||
func collectTenantSubtreeIDs(rootID string, childrenByParentID map[string][]domain.Tenant) []string {
|
||||
rootID = strings.TrimSpace(rootID)
|
||||
if rootID == "" {
|
||||
return nil
|
||||
}
|
||||
ids := []string{rootID}
|
||||
queue := []string{rootID}
|
||||
seen := map[string]struct{}{rootID: {}}
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, child := range childrenByParentID[current] {
|
||||
childID := strings.TrimSpace(child.ID)
|
||||
if childID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[childID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[childID] = struct{}{}
|
||||
ids = append(ids, childID)
|
||||
queue = append(queue, childID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func normalizeTenantStatus(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
@@ -2763,7 +3217,6 @@ 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()
|
||||
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
||||
slog.Info("orgchart snapshot request started",
|
||||
"user_id", userID,
|
||||
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
var cached orgChartSnapshotResponse
|
||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||
cached.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "HIT")
|
||||
slog.Info("orgchart snapshot cache hit",
|
||||
@@ -2823,14 +3275,13 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
}
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := json.Marshal(snapshot); err == nil {
|
||||
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
|
||||
if err := h.OrgChartCache.Set(cacheKey, string(raw), orgChartSnapshotCacheExpiration()); err != nil {
|
||||
slog.Warn("orgchart snapshot cache write failed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
return c.JSON(snapshot)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) WarmOrgChartSnapshotCache(ctx context.Context) error {
|
||||
if h == nil || h.OrgChartCache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "orgfront-cache-warmup",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}
|
||||
snapshot, err := h.buildOrgChartSnapshot(ctx, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "database",
|
||||
Hit: false,
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.OrgChartCache.Set(
|
||||
orgChartSnapshotCacheKey(profile, ""),
|
||||
string(raw),
|
||||
orgChartSnapshotCacheExpiration(),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) refreshOrgChartSnapshotCacheAfterTenantChange(ctx context.Context, reason string) {
|
||||
if h == nil || h.OrgChartCache == nil {
|
||||
return
|
||||
}
|
||||
deleted, err := h.OrgChartCache.DeleteByPrefix(ctx, orgChartSnapshotCacheKeyPrefix)
|
||||
if err != nil {
|
||||
slog.Warn("Orgfront orgchart snapshot cache invalidation failed after tenant change",
|
||||
"reason", reason,
|
||||
"prefix", orgChartSnapshotCacheKeyPrefix,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := h.WarmOrgChartSnapshotCache(ctx); err != nil {
|
||||
slog.Warn("Orgfront orgchart snapshot cache refresh failed after tenant change",
|
||||
"reason", reason,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
slog.Info("Orgfront orgchart snapshot cache refreshed after tenant change",
|
||||
"reason", reason,
|
||||
"invalidated_keys", deleted,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
|
||||
if err != nil {
|
||||
return orgChartSnapshotResponse{}, err
|
||||
}
|
||||
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
userID = strings.TrimSpace(profile.ID)
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
if role == domain.RoleSuperAdmin {
|
||||
userID = "all"
|
||||
tenantID = "none"
|
||||
} else if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
}
|
||||
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
||||
if tenantID == "" {
|
||||
tenantID = "none"
|
||||
}
|
||||
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
|
||||
return fmt.Sprintf("%s%s:%s:%s", orgChartSnapshotCacheKeyPrefix, role, userID, tenantID)
|
||||
}
|
||||
|
||||
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
|
||||
@@ -3045,17 +3554,8 @@ func findTenantRootID(parentMap map[string]string, tenantID string) string {
|
||||
}
|
||||
}
|
||||
|
||||
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 orgChartSnapshotCacheExpiration() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
|
||||
Reference in New Issue
Block a user