package handler import ( "baron-sso-backend/internal/bootstrap" "baron-sso-backend/internal/domain" "baron-sso-backend/internal/pagination" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" "context" "encoding/csv" "encoding/json" "errors" "fmt" "io" "log/slog" "maps" "os" "reflect" "sort" "strconv" "strings" "time" "github.com/go-redis/redis/v8" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) 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 } 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") } func seedTenantSlugsForDeleteGuard() []string { slugs, err := bootstrap.SeedTenantSlugSet() if err != nil { return nil } result := make([]string, 0, len(slugs)) for slug := range slugs { result = append(result, slug) } 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 { return &TenantHandler{ DB: db, Service: svc, UserRepo: userRepo, UserProjectionRepo: userProjectionRepo, Keto: keto, KetoOutbox: outbox, KratosAdmin: kratos, SharedLink: sharedLink, Hydra: hydra, ConsentRepo: consentRepo, } } func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) { h.Worksmobile = syncer } type tenantPermissions struct { View bool `json:"view"` Manage bool `json:"manage"` ManageAdmins bool `json:"manage_admins"` ViewProfile bool `json:"view_profile"` ManageProfile bool `json:"manage_profile"` ViewPermissions bool `json:"view_permissions"` ManagePermissions bool `json:"manage_permissions"` ViewOrganization bool `json:"view_organization"` ManageOrganization bool `json:"manage_organization"` ViewSchema bool `json:"view_schema"` ManageSchema bool `json:"manage_schema"` ViewWorksmobile bool `json:"view_worksmobile"` ManageWorksmobile bool `json:"manage_worksmobile"` } 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"` TotalMemberCount int64 `json:"totalMemberCount"` UserPermissions *tenantPermissions `json:"userPermissions,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type tenantListResponse struct { Items []tenantSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` Total int64 `json:"total"` Cursor string `json:"cursor,omitempty"` 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) { return tenant.CreatedAt, tenant.ID }) return pagination.PageByCursor(ordered, limit, cursorRaw, func(tenant domain.Tenant) (time.Time, string) { return tenant.CreatedAt, tenant.ID }) } type tenantImportDetail struct { Row int `json:"row"` Slug string `json:"slug"` Name string `json:"name"` Success bool `json:"success"` Action string `json:"action"` // "created", "updated", "failed", "skipped" Message string `json:"message"` // Detailed error or success message ModifiedFields []string `json:"modifiedFields"` // List of fields changed during update } type tenantImportResult struct { Created int `json:"created"` Updated int `json:"updated"` Failed int `json:"failed"` Errors []string `json:"errors"` Details []tenantImportDetail `json:"details"` } type tenantDomainConflict struct { Domain string `json:"domain"` TenantID string `json:"tenantId"` TenantName string `json:"tenantName"` TenantSlug string `json:"tenantSlug"` } type tenantCSVRecord struct { TenantID string Name string Type string ParentTenantID *string ParentTenantSlug string Slug string Memo string Domains []string Visibility string OrgUnitType string WorksmobileSync string WorksmobileSyncSet bool } type orgContextTenant struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Slug string `json:"slug"` ParentID *string `json:"parentId"` Status string `json:"status"` Description string `json:"description"` Domains []string `json:"domains,omitempty"` MemberCount int64 `json:"memberCount"` Visibility string `json:"visibility"` OrgUnitType string `json:"orgUnitType,omitempty"` Config domain.JSONMap `json:"config,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Members []orgContextMember `json:"members"` } type orgContextMember struct { ID string `json:"id,omitempty"` Email string `json:"email"` Name string `json:"name"` Phone string `json:"phone,omitempty"` Department string `json:"department,omitempty"` Grade string `json:"grade,omitempty"` Position string `json:"position,omitempty"` JobTitle string `json:"jobTitle,omitempty"` IsOwner bool `json:"isOwner"` IsManager bool `json:"isManager"` IsPrimary bool `json:"isPrimary"` } type orgContextMemberAssignment struct { TenantID string Member orgContextMember } type orgContextTreeNode struct { orgContextTenant Children []orgContextTreeNode `json:"children"` } type orgContextScope struct { TenantID string `json:"tenantId"` TenantSlug string `json:"tenantSlug"` } type orgContextResponse struct { SchemaVersion string `json:"schemaVersion"` IssuedAt string `json:"issuedAt"` Scope orgContextScope `json:"scope"` Tree *orgContextTreeNode `json:"tree"` Tenants []orgContextTenant `json:"tenants"` } func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { var req struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Domain string `json:"domain"` AdminEmail string `json:"adminEmail"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // Basic validation if req.Name == "" || req.Domain == "" || req.AdminEmail == "" { return errorJSON(c, fiber.StatusBadRequest, "name, domain, and adminEmail are required") } tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ "message": "Registration request received and is pending approval.", "tenant": mapTenantSummary(*tenant), }) } func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(fiber.Map{"message": "Tenant approved successfully"}) } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) parentId := c.Query("parentId") cursorRaw := strings.TrimSpace(c.Query("cursor")) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } var tenants []domain.Tenant var total int64 var err error nextCursor := "" profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) role := "" if profile != nil { role = domain.NormalizeRole(profile.Role) } if role != domain.RoleSuperAdmin { // Not a super admin: Only return the entire tree(s) of the tenants they belong to allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } if profile != nil { baseTenantIDs := []string{} for _, t := range profile.ManageableTenants { baseTenantIDs = append(baseTenantIDs, t.ID) } for _, t := range profile.JoinedTenants { baseTenantIDs = append(baseTenantIDs, t.ID) } if profile.TenantID != nil { baseTenantIDs = append(baseTenantIDs, *profile.TenantID) } parentMap := make(map[string]string) for _, t := range allTenants { if t.ParentID != nil { parentMap[t.ID] = *t.ParentID } } roots := make(map[string]bool) for _, id := range baseTenantIDs { roots[findTenantRootID(parentMap, id)] = true } // Filter tenants that belong to the same tree family for _, t := range allTenants { if roots[findTenantRootID(parentMap, t.ID)] { tenants = append(tenants, t) } } } tenants, err = h.filterPrivateTenantsForProfile(c.Context(), tenants, profile) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } total = int64(len(tenants)) if cursorRaw != "" { tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } offset = 0 } else if offset < len(tenants) { end := min(offset+limit, len(tenants)) tenants = tenants[offset:end] if total > int64(end) && len(tenants) > 0 { last := tenants[len(tenants)-1] nextCursor = pagination.Encode(last.CreatedAt, last.ID) } } else { tenants = []domain.Tenant{} } } else { // Super Admin case if cursorRaw != "" && h.DB != nil { tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "") if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } offset = 0 } else { tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } if total > int64(offset+len(tenants)) && len(tenants) > 0 { last := tenants[len(tenants)-1] nextCursor = pagination.Encode(last.CreatedAt, last.ID) } } } memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { summary := mapTenantSummary(t) summary.MemberCount = memberCounts[t.ID] summary.TotalMemberCount = totalMemberCounts[t.ID] items = append(items, summary) } return c.JSON(tenantListResponse{ Items: items, Limit: limit, Offset: offset, Total: total, Cursor: cursorRaw, NextCursor: nextCursor, }) } func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) { cursor, err := pagination.Decode(cursorRaw) if err != nil { return nil, 0, "", err } countQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{}) pageQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{}) if parentID != "" { countQuery = countQuery.Where("parent_id = ?", parentID) pageQuery = pageQuery.Where("parent_id = ?", parentID) } if search != "" { searchTerm := "%" + strings.ToLower(search) + "%" countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm) pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm) } var total int64 if err := countQuery.Count(&total).Error; err != nil { return nil, 0, "", err } pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id") var tenants []domain.Tenant if err := pageQuery. Order("created_at desc, id desc"). Limit(limit + 1). Preload("Domains"). Find(&tenants).Error; err != nil { return nil, 0, "", err } nextCursor := "" if len(tenants) > limit { tenants = tenants[:limit] last := tenants[len(tenants)-1] nextCursor = pagination.Encode(last.CreatedAt, last.ID) } return tenants, total, nextCursor, nil } func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { parentID := strings.TrimSpace(c.Query("parentId")) allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) allTenants, err = h.filterPrivateTenantsForProfile(c.Context(), allTenants, profile) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } tenants := filterTenantCSVDescendants(allTenants, parentID) sortTenantsByInputOrder(tenants) var buf bytes.Buffer writer := csv.NewWriter(&buf) includeIDs := includeCSVIds(c) if includeIDs { if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } slugByID := make(map[string]string, len(allTenants)) for _, tenant := range allTenants { slugByID[tenant.ID] = tenant.Slug } for _, tenant := range tenants { parentID := "" parentSlug := "" if tenant.ParentID != nil { parentID = *tenant.ParentID parentSlug = slugByID[parentID] } domains := make([]string, 0, len(tenant.Domains)) for _, domainName := range tenant.Domains { domainName := strings.TrimSpace(domainName.Domain) if domainName != "" { domains = append(domains, domainName) } } visibility, orgUnitType, worksmobileSync := tenantCSVOrgConfigValues(tenant.Config) row := []string{ tenant.Name, tenant.Type, parentSlug, tenant.Slug, tenant.Description, strings.Join(domains, ";"), visibility, orgUnitType, worksmobileSync, } if includeIDs { row = []string{ tenant.ID, tenant.Name, tenant.Type, parentID, parentSlug, tenant.Slug, tenant.Description, strings.Join(domains, ";"), visibility, orgUnitType, worksmobileSync, } } if err := writer.Write(row); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } writer.Flush() if err := writer.Error(); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } c.Set(fiber.HeaderContentType, "text/csv") c.Set(fiber.HeaderContentDisposition, `attachment; filename="tenants.csv"`) return c.Send(buf.Bytes()) } func sortTenantsByInputOrder(tenants []domain.Tenant) { sort.SliceStable(tenants, func(i, j int) bool { if tenants[i].CreatedAt.Equal(tenants[j].CreatedAt) { return tenants[i].ID < tenants[j].ID } return tenants[i].CreatedAt.Before(tenants[j].CreatedAt) }) } func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant { parentID = strings.TrimSpace(parentID) if parentID == "" { return tenants } descendantIDs := map[string]bool{} frontier := map[string]bool{parentID: true} for len(frontier) > 0 { next := map[string]bool{} for _, tenant := range tenants { if tenant.ParentID == nil { continue } if !frontier[strings.TrimSpace(*tenant.ParentID)] { continue } if descendantIDs[tenant.ID] { continue } descendantIDs[tenant.ID] = true next[tenant.ID] = true } frontier = next } filtered := make([]domain.Tenant, 0, len(descendantIDs)) for _, tenant := range tenants { if descendantIDs[tenant.ID] { filtered = append(filtered, tenant) } } return filtered } func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { reader, err := tenantCSVReaderFromRequest(c) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } records, err := parseTenantCSVRecords(reader) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } records = orderTenantCSVRecordsByParentSlug(records) creatorID := "" if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { creatorID = profile.ID } tenantIDBySlug := make(map[string]string) if h.Service != nil { if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", ""); err == nil { for _, tenant := range tenants { tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID } } } result := tenantImportResult{ Errors: make([]string, 0), Details: make([]tenantImportDetail, 0, len(records)), } for i, record := range records { rowNumber := i + 2 detail := tenantImportDetail{ Row: rowNumber, Slug: record.Slug, Name: record.Name, } if record.ParentTenantID == nil && record.ParentTenantSlug != "" { parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)] if parentID == "" { result.Failed++ msg := fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug) result.Errors = append(result.Errors, msg) detail.Success = false detail.Action = "failed" detail.Message = msg result.Details = append(result.Details, detail) continue } record.ParentTenantID = &parentID } if record.TenantID != "" || (h.DB != nil && record.Slug != "") { tenant, modifiedFields, err := h.upsertTenantCSVRecord(c, record) if err != nil { result.Failed++ msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error()) result.Errors = append(result.Errors, msg) detail.Success = false detail.Action = "failed" detail.Message = msg result.Details = append(result.Details, detail) continue } if tenant != nil { if len(modifiedFields) > 0 { tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Updated++ detail.Success = true detail.Action = "updated" detail.ModifiedFields = modifiedFields if h.Worksmobile != nil { _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) } result.Details = append(result.Details, detail) continue } else { // No changes, skip detail.Success = true detail.Action = "skipped" detail.Message = "no changes detected" result.Details = append(result.Details, detail) continue } } } recordCreatorID := creatorID if record.Type == domain.TenantTypeOrganization { recordCreatorID = "" } tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID) if err != nil { result.Failed++ msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error()) result.Errors = append(result.Errors, msg) detail.Success = false detail.Action = "failed" detail.Message = msg result.Details = append(result.Details, detail) continue } if tenant == nil { result.Failed++ msg := fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber) result.Errors = append(result.Errors, msg) detail.Success = false detail.Action = "failed" detail.Message = msg result.Details = append(result.Details, detail) continue } tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Created++ detail.Success = true detail.Action = "created" if h.Worksmobile != nil { _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) } result.Details = append(result.Details, detail) } return c.JSON(result) } func tenantCSVReaderFromRequest(c *fiber.Ctx) (io.Reader, error) { file, err := c.FormFile("file") if err == nil && file != nil { opened, err := file.Open() if err != nil { return nil, errors.New("failed to open uploaded file") } defer opened.Close() data, err := io.ReadAll(opened) if err != nil { return nil, errors.New("failed to read uploaded file") } return bytes.NewReader(data), nil } body := c.Body() if len(bytes.TrimSpace(body)) == 0 { return nil, errors.New("csv file is required") } return bytes.NewReader(body), nil } func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) { data, err := io.ReadAll(r) if err != nil { return nil, errors.New("failed to read csv") } data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) reader := csv.NewReader(bytes.NewReader(data)) reader.FieldsPerRecord = -1 rows, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("invalid csv: %w", err) } if len(rows) == 0 { return nil, errors.New("csv is empty") } header := tenantCSVHeaderIndex(rows[0]) required := []string{"name", "type", "slug"} for _, key := range required { if _, ok := header[key]; !ok { return nil, fmt.Errorf("missing required column: %s", key) } } records := make([]tenantCSVRecord, 0, len(rows)-1) for i, row := range rows[1:] { if tenantCSVRowIsEmpty(row) { continue } name := tenantCSVValue(row, header, "name") if name == "" { return nil, fmt.Errorf("row %d: name is required", i+2) } tenantType := normalizeTenantType(tenantCSVValue(row, header, "type")) if tenantType == "" { return nil, fmt.Errorf("row %d: invalid tenant type", i+2) } slug := utils.GenerateSlug(tenantCSVValue(row, header, "slug")) if slug == "" { return nil, fmt.Errorf("row %d: slug is required", i+2) } parentValue := tenantCSVValue(row, header, "parent_tenant_id") var parentID *string if parentValue != "" { parentID = &parentValue } worksmobileSync, worksmobileSyncSet := tenantCSVWorksmobileSyncValue(row, header) records = append(records, tenantCSVRecord{ TenantID: tenantCSVValue(row, header, "tenant_id"), Name: name, Type: tenantType, ParentTenantID: parentID, ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"), Slug: slug, Memo: tenantCSVValue(row, header, "memo"), Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")), Visibility: tenantCSVValue(row, header, "visibility"), OrgUnitType: tenantCSVValue(row, header, "org_unit_type"), WorksmobileSync: worksmobileSync, WorksmobileSyncSet: worksmobileSyncSet, }) } return records, nil } func tenantCSVHeaderIndex(header []string) map[string]int { index := make(map[string]int, len(header)) aliases := map[string]string{ "id": "tenant_id", "tenantid": "tenant_id", "tenant_id": "tenant_id", "name": "name", "type": "type", "parentid": "parent_tenant_id", "parent_id": "parent_tenant_id", "parenttenantid": "parent_tenant_id", "parent_tenant_id": "parent_tenant_id", "parenttenantslug": "parent_tenant_slug", "parent_tenant_slug": "parent_tenant_slug", "slug": "slug", "memo": "memo", "description": "memo", "email-domain": "email_domain", "emaildomain": "email_domain", "email_domain": "email_domain", "domain": "email_domain", "domains": "email_domain", "visibility": "visibility", "public_setting": "visibility", "publicsetting": "visibility", "orgunittype": "org_unit_type", "org_unit_type": "org_unit_type", "org-unit-type": "org_unit_type", "organizationtype": "org_unit_type", "organization_type": "org_unit_type", "orgtype": "org_unit_type", "org_type": "org_unit_type", "worksmobile": "worksmobile_sync", "worksmobilesync": "worksmobile_sync", "worksmobile_sync": "worksmobile_sync", "works_sync": "worksmobile_sync", "works": "worksmobile_sync", "worksmobileexcluded": "worksmobile_excluded", "worksmobile_excluded": "worksmobile_excluded", } for i, column := range header { key := strings.ToLower(strings.TrimSpace(column)) key = strings.ReplaceAll(key, " ", "_") if canonical, ok := aliases[key]; ok { index[canonical] = i } } return index } func tenantCSVValue(row []string, header map[string]int, key string) string { idx, ok := header[key] if !ok || idx >= len(row) { return "" } return strings.TrimSpace(row[idx]) } func tenantCSVWorksmobileSyncValue(row []string, header map[string]int) (string, bool) { if _, ok := header["worksmobile_sync"]; ok { value := tenantCSVValue(row, header, "worksmobile_sync") if value == "" { return "yes", true } return value, true } if _, ok := header["worksmobile_excluded"]; ok { value := tenantCSVValue(row, header, "worksmobile_excluded") excluded, err := normalizeTenantWorksmobileExcluded(value) if err == nil && excluded { return "no", true } if err == nil { return "yes", true } return value, true } return "", false } func tenantCSVRowIsEmpty(row []string) bool { for _, value := range row { if strings.TrimSpace(value) != "" { return false } } return true } func includeCSVIds(c *fiber.Ctx) bool { value := strings.ToLower(strings.TrimSpace(c.Query("includeIds"))) return value == "true" || value == "1" || value == "yes" } func orderTenantCSVRecordsByParentSlug(records []tenantCSVRecord) []tenantCSVRecord { bySlug := make(map[string]tenantCSVRecord, len(records)) for _, record := range records { bySlug[strings.ToLower(record.Slug)] = record } ordered := make([]tenantCSVRecord, 0, len(records)) visited := make(map[string]bool, len(records)) var visit func(record tenantCSVRecord) visit = func(record tenantCSVRecord) { key := strings.ToLower(record.Slug) if visited[key] { return } if record.ParentTenantSlug != "" { if parent, ok := bySlug[strings.ToLower(record.ParentTenantSlug)]; ok { visit(parent) } } visited[key] = true ordered = append(ordered, record) } for _, record := range records { visit(record) } return ordered } func splitTenantCSVDomains(value string) []string { value = strings.ReplaceAll(value, "\n", ";") value = strings.ReplaceAll(value, ",", ";") parts := strings.Split(value, ";") domains := make([]string, 0, len(parts)) seen := make(map[string]bool, len(parts)) for _, part := range parts { domainName := strings.ToLower(strings.TrimSpace(part)) if domainName == "" || seen[domainName] { continue } seen[domainName] = true domains = append(domains, domainName) } return domains } func normalizeTenantDomainInputs(values []string) []string { seen := make(map[string]bool, len(values)) domains := make([]string, 0, len(values)) for _, value := range values { for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' ' }) { domainName := strings.ToLower(strings.TrimSpace(part)) if domainName == "" || seen[domainName] { continue } seen[domainName] = true domains = append(domains, domainName) } } return domains } func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) { normalized := make(domain.JSONMap, len(config)) orgUnitTypeError := "orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속" for key, value := range config { if key == "userSchema" { fields, err := normalizeTenantUserSchema(value) if err != nil { return nil, err } normalized[key] = fields continue } if key == "visibility" { visibility, ok := value.(string) if !ok { return nil, fmt.Errorf("visibility must be public, internal, or private") } visibility = strings.TrimSpace(strings.ToLower(visibility)) if visibility == "" || visibility == "public" { normalized[key] = "public" continue } if visibility != "internal" && visibility != "private" { return nil, fmt.Errorf("visibility must be public, internal, or private") } normalized[key] = visibility continue } if key == "orgUnitType" { orgUnitType, ok := value.(string) if !ok { return nil, errors.New(orgUnitTypeError) } orgUnitType = strings.TrimSpace(orgUnitType) if orgUnitType == "" { continue } if !isAllowedOrgUnitType(orgUnitType) { return nil, errors.New(orgUnitTypeError) } normalized[key] = orgUnitType continue } if key == "worksmobileExcluded" { excluded, err := normalizeTenantWorksmobileExcluded(value) if err != nil { return nil, err } normalized[key] = excluded continue } normalized[key] = value } return normalized, nil } func normalizeTenantWorksmobileExcluded(value any) (bool, error) { switch typed := value.(type) { case bool: return typed, nil case string: normalized := strings.ToLower(strings.TrimSpace(typed)) switch normalized { case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동": return false, nil case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외": return true, nil default: return false, fmt.Errorf("worksmobile_sync must be yes or no") } default: return false, fmt.Errorf("worksmobile_sync must be yes or no") } } func isAllowedOrgUnitType(value string) bool { switch value { case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속": return true default: return false } } func hasTenantOrgConfig(config domain.JSONMap) bool { if config == nil { return false } _, hasVisibility := config["visibility"] _, hasOrgUnitType := config["orgUnitType"] return hasVisibility || hasOrgUnitType } func isHanmacFamilyDescendantTenant(tenant domain.Tenant, tenants []domain.Tenant) bool { if strings.EqualFold(tenant.Slug, "hanmac-family") { return false } byID := make(map[string]domain.Tenant, len(tenants)+1) for _, item := range tenants { byID[item.ID] = item } byID[tenant.ID] = tenant parentID := tenant.ParentID visited := make(map[string]bool) for parentID != nil && *parentID != "" { if visited[*parentID] { return false } visited[*parentID] = true parent, ok := byID[*parentID] if !ok { return false } if strings.EqualFold(parent.Slug, "hanmac-family") { return true } parentID = parent.ParentID } return false } func validateTenantOrgConfigScope(tenant domain.Tenant, tenants []domain.Tenant, config domain.JSONMap) error { if !hasTenantOrgConfig(config) { return nil } if isHanmacFamilyDescendantTenant(tenant, tenants) { return nil } return fmt.Errorf("tenant org config is allowed only hanmac-family descendants") } func tenantVisibility(config domain.JSONMap) string { visibility, _ := config["visibility"].(string) switch strings.ToLower(strings.TrimSpace(visibility)) { case "internal": return "internal" case "private": return "private" default: return "public" } } func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string, string) { visibility := tenantVisibility(config) orgUnitType, _ := config["orgUnitType"].(string) worksmobileSync := "yes" if excluded, err := normalizeTenantWorksmobileExcluded(config["worksmobileExcluded"]); err == nil && excluded { worksmobileSync = "no" } return visibility, strings.TrimSpace(orgUnitType), worksmobileSync } func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) { config := map[string]any{} if strings.TrimSpace(record.Visibility) != "" { config["visibility"] = record.Visibility } if strings.TrimSpace(record.OrgUnitType) != "" { config["orgUnitType"] = record.OrgUnitType } if record.WorksmobileSyncSet { config["worksmobileExcluded"] = record.WorksmobileSync } if len(config) == 0 { return nil, nil } return normalizeTenantConfig(config) } func mergeTenantCSVRecordConfig(current domain.JSONMap, record tenantCSVRecord) (domain.JSONMap, bool, error) { recordConfig, err := tenantCSVRecordConfig(record) if err != nil { return nil, false, err } if len(recordConfig) == 0 { return current, false, nil } merged := make(domain.JSONMap, len(current)+len(recordConfig)) maps.Copy(merged, current) maps.Copy(merged, recordConfig) return merged, true, nil } func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant { excludedIDs := make(map[string]bool) for _, tenant := range tenants { visibility := tenantVisibility(tenant.Config) if visibility == "internal" || visibility == "private" { excludedIDs[tenant.ID] = true } } changed := true for changed { changed = false for _, tenant := range tenants { if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] { excludedIDs[tenant.ID] = true changed = true } } } filtered := make([]domain.Tenant, 0, len(tenants)) for _, tenant := range tenants { if !excludedIDs[tenant.ID] { filtered = append(filtered, tenant) } } return filtered } func (h *TenantHandler) filterPrivateTenantsForProfile(ctx context.Context, tenants []domain.Tenant, profile *domain.UserProfileResponse) ([]domain.Tenant, error) { if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin { return tenants, nil } privateRoots := privateTenantRootIDs(tenants) if len(privateRoots) == 0 { return tenants, nil } allowedPrivateRoots := make(map[string]bool, len(privateRoots)) for _, rootID := range privateRoots { allowed, err := h.canViewPrivateTenant(ctx, profile, rootID, tenants) if err != nil { return nil, err } if allowed { allowedPrivateRoots[rootID] = true } } excludedIDs := make(map[string]bool) for _, rootID := range privateRoots { if !allowedPrivateRoots[rootID] { excludedIDs[rootID] = true } } changed := true for changed { changed = false for _, tenant := range tenants { if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] { excludedIDs[tenant.ID] = true changed = true } } } filtered := make([]domain.Tenant, 0, len(tenants)) for _, tenant := range tenants { if !excludedIDs[tenant.ID] { filtered = append(filtered, tenant) } } return filtered, nil } func privateTenantRootIDs(tenants []domain.Tenant) []string { tenantByID := make(map[string]domain.Tenant, len(tenants)) for _, tenant := range tenants { tenantByID[tenant.ID] = tenant } roots := make([]string, 0) for _, tenant := range tenants { if tenantVisibility(tenant.Config) != "private" { continue } if tenant.ParentID != nil { parent, ok := tenantByID[*tenant.ParentID] if ok && tenantVisibility(parent.Config) == "private" { continue } } roots = append(roots, tenant.ID) } return roots } func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domain.UserProfileResponse, privateRootID string, tenants []domain.Tenant) (bool, error) { if profile == nil { return false, nil } if profileCanManageTenantOrAncestor(profile, privateRootID, tenants) { return true, nil } if h.Keto == nil || strings.TrimSpace(profile.ID) == "" { return false, nil } subject := "User:" + profile.ID for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} { allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation) if err != nil { return false, fmt.Errorf("private tenant permission check failed: %w", err) } if allowed { return true, nil } } for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) { allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants") if err != nil { return false, fmt.Errorf("private tenant descendant permission check failed: %w", err) } if allowed { return true, nil } } return false, nil } func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool { manageableIDs := make(map[string]bool, len(profile.ManageableTenants)) for _, tenant := range profile.ManageableTenants { if tenant.ID != "" { manageableIDs[tenant.ID] = true } } if len(manageableIDs) == 0 { return false } if manageableIDs[tenantID] { return true } for _, ancestorID := range tenantAncestorIDs(tenantID, tenants) { if manageableIDs[ancestorID] { return true } } return false } func tenantAncestorIDs(tenantID string, tenants []domain.Tenant) []string { tenantByID := make(map[string]domain.Tenant, len(tenants)) for _, tenant := range tenants { tenantByID[tenant.ID] = tenant } ancestors := make([]string, 0) visited := map[string]bool{} current, ok := tenantByID[tenantID] for ok && current.ParentID != nil && *current.ParentID != "" { parentID := *current.ParentID if visited[parentID] { break } visited[parentID] = true ancestors = append(ancestors, parentID) current, ok = tenantByID[parentID] } return ancestors } func normalizeTenantUserSchema(value any) ([]any, error) { if value == nil { return nil, nil } rawFields, ok := value.([]any) if !ok { return nil, fmt.Errorf("userSchema must be an array") } fields := make([]any, 0, len(rawFields)) for _, raw := range rawFields { field, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("userSchema fields must be objects") } normalized := make(map[string]any, len(field)) for key, value := range field { if key == "maxLength" { continue } normalized[key] = value } isLoginID, _ := normalized["isLoginId"].(bool) if isLoginID { fieldType, _ := normalized["type"].(string) if fieldType != "" && fieldType != "text" { return nil, fmt.Errorf("login ID fields must be text") } normalized["type"] = "text" normalized["indexed"] = true } else if indexed, ok := normalized["indexed"].(bool); !ok || !indexed { normalized["indexed"] = false } fields = append(fields, normalized) } return fields, nil } func normalizeTenantDomainForceSet(values []string) map[string]bool { domains := normalizeTenantDomainInputs(values) force := make(map[string]bool, len(domains)) for _, domainName := range domains { force[domainName] = true } return force } func tenantDomainConflictJSON(c *fiber.Ctx, conflicts []tenantDomainConflict) error { return c.Status(fiber.StatusConflict).JSON(fiber.Map{ "code": "tenant_domain_conflict", "error": "domain is already assigned to another tenant", "conflicts": conflicts, }) } func (h *TenantHandler) findTenantDomainConflicts(ctx context.Context, tenantID string, domains []string, forceDomains []string) ([]tenantDomainConflict, error) { if h.DB == nil || h.DB.Config == nil || len(domains) == 0 { return nil, nil } force := normalizeTenantDomainForceSet(forceDomains) var rows []domain.TenantDomain query := h.DB.WithContext(ctx).Where("domain IN ?", domains) if tenantID != "" { query = query.Where("tenant_id <> ?", tenantID) } if err := query.Find(&rows).Error; err != nil { return nil, err } conflicts := make([]tenantDomainConflict, 0, len(rows)) tenantIDs := make([]string, 0, len(rows)) seenTenantIDs := make(map[string]bool, len(rows)) for _, row := range rows { if force[row.Domain] { continue } if !seenTenantIDs[row.TenantID] { seenTenantIDs[row.TenantID] = true tenantIDs = append(tenantIDs, row.TenantID) } } tenantsByID := make(map[string]domain.Tenant, len(tenantIDs)) if len(tenantIDs) > 0 { var tenants []domain.Tenant if err := h.DB.WithContext(ctx).Where("id IN ?", tenantIDs).Find(&tenants).Error; err != nil { return nil, err } for _, tenant := range tenants { tenantsByID[tenant.ID] = tenant } } for _, row := range rows { if force[row.Domain] { continue } conflict := tenantDomainConflict{ Domain: row.Domain, TenantID: row.TenantID, } if tenant, ok := tenantsByID[row.TenantID]; ok { conflict.TenantName = tenant.Name conflict.TenantSlug = tenant.Slug } conflicts = append(conflicts, conflict) } return conflicts, nil } func (h *TenantHandler) replaceTenantDomains(ctx context.Context, tenantID string, domains []string, forceDomains []string) error { if h.DB == nil { return errors.New("database not available") } if h.DB.Config == nil { return nil } deleteQuery := h.DB.WithContext(ctx).Where("tenant_id = ?", tenantID) if len(domains) > 0 { deleteQuery = deleteQuery.Where("domain NOT IN ?", domains) } if err := deleteQuery.Delete(&domain.TenantDomain{}).Error; err != nil { return fmt.Errorf("failed to clear old domains: %w", err) } for _, domainName := range domains { var existing domain.TenantDomain err := h.DB.WithContext(ctx).Unscoped(). Where("tenant_id = ? AND domain = ?", tenantID, domainName). First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { if err := repository.NewTenantRepository(h.DB).AddDomain(ctx, tenantID, domainName, true); err != nil { return fmt.Errorf("failed to add domain: %s", domainName) } continue } if err != nil { return err } if err := h.DB.WithContext(ctx).Unscoped().Model(&existing).Updates(map[string]any{ "verified": true, "deleted_at": nil, }).Error; err != nil { return fmt.Errorf("failed to add domain: %s", domainName) } } return nil } func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, []string, error) { if h.DB == nil { if record.TenantID != "" { return nil, nil, errors.New("database not available for tenant update") } return nil, nil, nil } var tenant domain.Tenant query := h.DB.Preload("Domains") var err error if record.TenantID != "" { err = query.First(&tenant, "id = ?", record.TenantID).Error } else { err = query.First(&tenant, "slug = ?", record.Slug).Error } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil } if err != nil { return nil, nil, err } modifiedFields := []string{} if tenant.Name != record.Name { tenant.Name = record.Name modifiedFields = append(modifiedFields, "Name") } if tenant.Type != record.Type { tenant.Type = record.Type modifiedFields = append(modifiedFields, "Type") } if record.ParentTenantID != nil { oldParentID := "" if tenant.ParentID != nil { oldParentID = *tenant.ParentID } if oldParentID != *record.ParentTenantID { tenant.ParentID = record.ParentTenantID modifiedFields = append(modifiedFields, "ParentID") } } else if tenant.ParentID != nil { tenant.ParentID = nil modifiedFields = append(modifiedFields, "ParentID") } if tenant.Slug != record.Slug { tenant.Slug = record.Slug modifiedFields = append(modifiedFields, "Slug") } if tenant.Description != record.Memo { tenant.Description = record.Memo modifiedFields = append(modifiedFields, "Description") } if tenant.Status == "" { tenant.Status = domain.TenantStatusActive } mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record) if err != nil { return nil, nil, err } if changedConfig { tenant.Config = mergedConfig modifiedFields = append(modifiedFields, "Config") } existingDomains := make([]string, len(tenant.Domains)) for i, d := range tenant.Domains { existingDomains[i] = d.Domain } sort.Strings(existingDomains) newDomains := append([]string(nil), record.Domains...) sort.Strings(newDomains) if !reflect.DeepEqual(existingDomains, newDomains) { modifiedFields = append(modifiedFields, "Domains") } if len(modifiedFields) == 0 { return &tenant, nil, nil } if err := h.DB.Save(&tenant).Error; err != nil { return nil, nil, err } if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { return nil, nil, err } repo := repository.NewTenantRepository(h.DB) for _, domainName := range record.Domains { if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil { return nil, nil, err } } return &tenant, modifiedFields, nil } func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) { if h.DB != nil && record.TenantID != "" { var exists int64 if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil { return nil, err } if exists > 0 { return nil, errors.New("tenant slug already exists") } tenant := domain.Tenant{ ID: record.TenantID, Type: record.Type, ParentID: record.ParentTenantID, Name: record.Name, Slug: record.Slug, Description: record.Memo, Status: domain.TenantStatusActive, } config, _, err := mergeTenantCSVRecordConfig(nil, record) if err != nil { return nil, err } if len(config) > 0 { tenant.Config = config } if err := h.DB.Create(&tenant).Error; err != nil { return nil, err } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "admins", Subject: "System:global#super_admins", Action: domain.KetoOutboxActionCreate, }) if tenant.ParentID != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + *tenant.ParentID, Action: domain.KetoOutboxActionCreate, }) } if creatorID != "" { for _, relation := range []string{"owners", "admins", "members"} { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: relation, Subject: "User:" + creatorID, Action: domain.KetoOutboxActionCreate, }) } } } repo := repository.NewTenantRepository(h.DB) for _, domainName := range record.Domains { if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil { return nil, err } } return &tenant, nil } tenant, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID) if err != nil || tenant == nil { return tenant, err } config, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record) if err != nil { return nil, err } if changedConfig { if h.DB == nil { return nil, errors.New("database not available for tenant config import") } tenant.Config = config if err := h.DB.Save(tenant).Error; err != nil { return nil, err } } return tenant, err } func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } 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] // Populate Keto-based permissions for the current user profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if ok && profile != nil { role := domain.NormalizeRole(profile.Role) if role == domain.RoleSuperAdmin { summary.UserPermissions = &tenantPermissions{ View: true, Manage: true, ManageAdmins: true, ViewProfile: true, ManageProfile: true, ViewPermissions: true, ManagePermissions: true, ViewOrganization: true, ManageOrganization: true, ViewSchema: true, ManageSchema: true, ViewWorksmobile: true, ManageWorksmobile: true, } } else { // Query Keto in parallel for maximum performance subject := "User:" + profile.ID type checkResult struct { relation string allowed bool err error } ch := make(chan checkResult, 13) relations := []string{ "view", "manage", "manage_admins", "view_profile", "manage_profile", "view_permissions", "manage_permissions", "view_organization", "manage_organization", "view_schema", "manage_schema", "view_worksmobile", "manage_worksmobile", } for _, rel := range relations { go func(r string) { allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r) ch <- checkResult{relation: r, allowed: allowed, err: err} }(rel) } perms := &tenantPermissions{} for range relations { res := <-ch if res.err != nil { slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID) continue } switch res.relation { case "view": perms.View = res.allowed case "manage": perms.Manage = res.allowed case "manage_admins": perms.ManageAdmins = res.allowed case "view_profile": perms.ViewProfile = res.allowed case "manage_profile": perms.ManageProfile = res.allowed case "view_permissions": perms.ViewPermissions = res.allowed case "manage_permissions": perms.ManagePermissions = res.allowed case "view_organization": perms.ViewOrganization = res.allowed case "manage_organization": perms.ManageOrganization = res.allowed case "view_schema": perms.ViewSchema = res.allowed case "manage_schema": perms.ManageSchema = res.allowed case "view_worksmobile": perms.ViewWorksmobile = res.allowed case "manage_worksmobile": perms.ManageWorksmobile = res.allowed } } summary.UserPermissions = perms } } return c.JSON(summary) } func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } var req struct { Name string `json:"name"` Slug string `json:"slug"` Type string `json:"type"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains"` ForceDomains []string `json:"forceDomainConflicts"` ParentID *string `json:"parentId"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } name := strings.TrimSpace(req.Name) if name == "" { return errorJSON(c, fiber.StatusBadRequest, "name is required") } tenantType := normalizeTenantType(req.Type) if tenantType == "" { tenantType = domain.TenantTypeCompany // Default to COMPANY } slug := req.Slug if slug == "" { slug = utils.GenerateUniqueSlug(name, func(s string) bool { var count int64 h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count) return count > 0 }) } else { slug = utils.GenerateSlug(slug) } if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug is required") } status := normalizeTenantStatus(req.Status) if status == "" { status = "active" } // Use Service var parentID *string if req.ParentID != nil && strings.TrimSpace(*req.ParentID) != "" { pid := strings.TrimSpace(*req.ParentID) parentID = &pid } // Extract creator ID if present creatorID := "" if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { creatorID = profile.ID } normalizedDomains := normalizeTenantDomainInputs(req.Domains) conflicts, err := h.findTenantDomainConflicts(c.Context(), "", normalizedDomains, req.ForceDomains) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if len(conflicts) > 0 { return tenantDomainConflictJSON(c, conflicts) } tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, nil, parentID, creatorID) if err != nil { if strings.Contains(err.Error(), "already exists") { return errorJSON(c, fiber.StatusConflict, err.Error()) } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := mapTenantSummary(*tenant) summary.MemberCount = 0 summary.TotalMemberCount = 0 if req.Config != nil { config, err := normalizeTenantConfig(req.Config) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } var tenants []domain.Tenant if hasTenantOrgConfig(config) { if err := h.DB.Find(&tenants).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if err := validateTenantOrgConfigScope(*tenant, tenants, config); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } } tenant.Config = config h.DB.Save(tenant) summary.Config = tenant.Config } if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if len(normalizedDomains) > 0 { summary.Domains = normalizedDomains } if h.Worksmobile != nil { if refreshed := h.DB.Preload("Domains").First(tenant, "id = ?", tenant.ID); refreshed.Error == nil { if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant); err != nil { fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant sync: %v\n", err) } } } return c.Status(fiber.StatusCreated).JSON(summary) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var req struct { Name *string `json:"name"` Type *string `json:"type"` Slug *string `json:"slug"` Description *string `json:"description"` Status *string `json:"status"` ParentID *string `json:"parentId"` Domains []string `json:"domains"` ForceDomains []string `json:"forceDomainConflicts"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { return errorJSON(c, fiber.StatusBadRequest, "name cannot be empty") } tenant.Name = name } if req.Type != nil { tenantType := normalizeTenantType(*req.Type) if tenantType == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"}) } tenant.Type = tenantType } if req.Slug != nil { slug := utils.GenerateSlug(*req.Slug) if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty") } if slug != tenant.Slug { var exists domain.Tenant if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil { return errorJSON(c, fiber.StatusConflict, "slug already exists") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } tenant.Slug = slug } } if req.Description != nil { tenant.Description = strings.TrimSpace(*req.Description) } if req.Status != nil { status := normalizeTenantStatus(*req.Status) if status == "" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } tenant.Status = status } if req.ParentID != nil { pid := strings.TrimSpace(*req.ParentID) if pid == "" { tenant.ParentID = nil } else { // 순환 참조(Circular Dependency) 방지 로직: // 새로운 부모(pid)부터 상위로 탐색하면서 현재 테넌트(tenant.ID)가 나오면 순환 참조로 간주함 checkID := pid for checkID != "" { if checkID == tenant.ID { return errorJSON(c, fiber.StatusConflict, "순환 참조 오류: 하위 테넌트를 상위 테넌트로 지정할 수 없습니다.") } var pTenant domain.Tenant if err := h.DB.Select("id, parent_id").First(&pTenant, "id = ?", checkID).Error; err != nil { break // 데이터를 찾을 수 없거나 에러 발생 시 반복문 종료 (추후 외래키 제약조건 등에서 에러 발생) } if pTenant.ParentID != nil { checkID = *pTenant.ParentID } else { break } } tenant.ParentID = &pid } // [Keto] Sync hierarchy via Outbox if h.KetoOutbox != nil { if tenant.ParentID != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + *tenant.ParentID, Action: domain.KetoOutboxActionCreate, }) } else { // We don't have enough info here to delete specific parent if we don't know the old one, // but for now we focus on adding. } } } if req.Config != nil { config, err := normalizeTenantConfig(req.Config) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } var tenants []domain.Tenant if hasTenantOrgConfig(config) { if err := h.DB.Find(&tenants).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if err := validateTenantOrgConfigScope(tenant, tenants, config); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } } tenant.Config = config } if err := h.DB.Save(&tenant).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Update domains if provided if req.Domains != nil { normalizedDomains := normalizeTenantDomainInputs(req.Domains) conflicts, err := h.findTenantDomainConflicts(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if len(conflicts) > 0 { return tenantDomainConflictJSON(c, conflicts) } if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } // Refetch to get updated relations h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID) if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), tenant); err != nil { fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err) } } return c.JSON(mapTenantSummary(tenant)) } func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if bootstrap.IsSeedTenantSlug(tenant.Slug) { return seedTenantDeleteError(c) } 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()) } // Rename slug to release it for reuse before soft delete deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405") if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to release slug") } if err := h.DB.Delete(&tenant).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueTenantDeleteIfInScope(c.Context(), tenant); err != nil { fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err) } } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } // Fetch admins from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } type adminInfo struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } admins := []adminInfo{} for _, rel := range relations { if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") // Fetch user details - Try Kratos first, then local DB name := "Unknown" email := "Unknown" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } else if h.UserRepo != nil { // Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos) user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } admins = append(admins, adminInfo{ ID: userID, Name: name, Email: email, }) } return c.JSON(admins) } func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "User:"+userID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 관리자로 등록된 사용자입니다.") } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) // Also add as member for UI visibility/ReBAC logic _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { if profile.ID == userID { return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role") } } if h.Keto != nil { if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil { adminCount := 0 isTargetAdmin := false for _, rel := range relations { if strings.HasPrefix(rel.SubjectID, "User:") { adminCount++ if rel.SubjectID == "User:"+userID { isTargetAdmin = true } } } if isTargetAdmin && adminCount <= 1 { return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin") } } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) ListOwners(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } // Fetch owners from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } type ownerInfo struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } owners := []ownerInfo{} for _, rel := range relations { if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") // Fetch user details - Try Kratos first, then local DB name := "Unknown" email := "Unknown" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } else if h.UserRepo != nil { // Fallback to local DB user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } owners = append(owners, ownerInfo{ ID: userID, Name: name, Email: email, }) } return c.JSON(owners) } func (h *TenantHandler) AddOwner(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "User:"+userID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 소유자로 등록된 사용자입니다.") } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "owners", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) // Also add as member for UI visibility/ReBAC logic _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { if profile.ID == userID { return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role") } } if h.Keto != nil { if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil { ownerCount := 0 isTargetOwner := false for _, rel := range relations { if strings.HasPrefix(rel.SubjectID, "User:") { ownerCount++ if rel.SubjectID == "User:"+userID { isTargetOwner = true } } } if isTargetOwner && ownerCount <= 1 { return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner") } } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "owners", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error { var req struct { IDs []string `json:"ids"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if len(req.IDs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "no IDs provided") } // Permission check: Super Admin can delete anything. // Tenant Admin should theoretically only delete manageable sub-tenants, // but currently bulk delete is intended for Super Admin. profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion") } protectedSlugs := seedTenantSlugsForDeleteGuard() if len(protectedSlugs) > 0 { var protectedCount int64 if err := h.DB.Model(&domain.Tenant{}). Where("id IN ?", req.IDs). Where("slug IN ?", protectedSlugs). Count(&protectedCount).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if protectedCount > 0 { return seedTenantDeleteError(c) } } 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()) } if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusOK).JSON(fiber.Map{ "message": "Tenants deleted successfully", "count": len(req.IDs), }) } func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { domains = append(domains, d.Domain) } return tenantSummary{ ID: t.ID, Type: t.Type, ParentID: t.ParentID, Name: t.Name, Slug: t.Slug, Description: t.Description, Status: t.Status, Domains: domains, Config: t.Config, CreatedAt: t.CreatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339), } } func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error { if c.Locals("apiKeyName") == nil { return errorJSON(c, fiber.StatusUnauthorized, "api key authentication is required") } if h.Service == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured") } allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } rootSlug := strings.TrimSpace(c.Query("tenantSlug")) if rootSlug == "" { rootSlug = "hanmac-family" } root, ok := findOrgContextTenantBySlug(allTenants, rootSlug) if !ok { return errorJSON(c, fiber.StatusNotFound, "tenant slug not found") } scopedTenants := filterOrgContextSubtree(allTenants, root.ID) contextTenants := make([]orgContextTenant, 0, len(scopedTenants)) tenantIDs := make([]string, 0, len(scopedTenants)) tenantSlugs := make([]string, 0, len(scopedTenants)) tenantByID := make(map[string]orgContextTenant, len(scopedTenants)) tenantBySlug := make(map[string]orgContextTenant, len(scopedTenants)) for _, tenant := range scopedTenants { summary := mapOrgContextTenant(tenant) contextTenants = append(contextTenants, summary) tenantIDs = append(tenantIDs, tenant.ID) tenantSlugs = append(tenantSlugs, tenant.Slug) tenantByID[tenant.ID] = summary tenantBySlug[strings.ToLower(tenant.Slug)] = summary } includeUsers := !strings.EqualFold(strings.TrimSpace(c.Query("includeUsers")), "false") includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true") membersByTenantID := make(map[string][]orgContextMember) if includeUsers { if h.UserRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured") } membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } for i := range contextTenants { members := membersByTenantID[contextTenants[i].ID] if members == nil { members = []orgContextMember{} } contextTenants[i].Members = members tenantByID[contextTenants[i].ID] = contextTenants[i] tenantBySlug[strings.ToLower(contextTenants[i].Slug)] = contextTenants[i] } tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID) return c.JSON(orgContextResponse{ SchemaVersion: "baron.org-context.v1", IssuedAt: time.Now().UTC().Format(time.RFC3339), Scope: orgContextScope{ TenantID: root.ID, TenantSlug: root.Slug, }, Tree: tree, Tenants: contextTenants, }) } func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) { usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs) if err != nil { return nil, err } usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs) if err != nil { return nil, err } usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "") if err != nil { return nil, err } seen := make(map[string]bool) membersByTenantID := make(map[string][]orgContextMember) users := append(usersByID, usersBySlug...) users = append(users, usersByAppointment...) for _, user := range users { if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) { continue } assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs) if len(assignments) == 0 { continue } seen[user.ID] = true for _, assignment := range assignments { membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member) } } return membersByTenantID, nil } func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) { normalized := strings.ToLower(strings.TrimSpace(slug)) for _, tenant := range tenants { if strings.ToLower(tenant.Slug) == normalized && isOrgContextTenantType(tenant) { return tenant, true } } return domain.Tenant{}, false } func isOrgContextTenantType(tenant domain.Tenant) bool { switch strings.ToUpper(tenant.Type) { case domain.TenantTypeCompanyGroup, domain.TenantTypeCompany, domain.TenantTypeOrganization, domain.TenantTypeUserGroup: return true default: return false } } func filterOrgContextSubtree(tenants []domain.Tenant, rootID string) []domain.Tenant { descendantIDs := map[string]bool{rootID: true} frontier := map[string]bool{rootID: true} for len(frontier) > 0 { next := map[string]bool{} for _, tenant := range tenants { if tenant.ParentID == nil || !frontier[*tenant.ParentID] || descendantIDs[tenant.ID] { continue } descendantIDs[tenant.ID] = true next[tenant.ID] = true } frontier = next } excludedIDs := map[string]bool{} for _, tenant := range tenants { if descendantIDs[tenant.ID] && tenantVisibility(tenant.Config) == "private" { excludedIDs[tenant.ID] = true } } changed := true for changed { changed = false for _, tenant := range tenants { if tenant.ParentID == nil || !descendantIDs[tenant.ID] || excludedIDs[tenant.ID] { continue } if excludedIDs[*tenant.ParentID] { excludedIDs[tenant.ID] = true changed = true } } } filtered := make([]domain.Tenant, 0, len(descendantIDs)) for _, tenant := range tenants { if descendantIDs[tenant.ID] && !excludedIDs[tenant.ID] && isOrgContextTenantType(tenant) { filtered = append(filtered, tenant) } } return filtered } func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant { domains := make([]string, 0, len(tenant.Domains)) for _, domain := range tenant.Domains { domains = append(domains, domain.Domain) } visibility, orgUnitType, _ := tenantCSVOrgConfigValues(tenant.Config) return orgContextTenant{ ID: tenant.ID, Type: tenant.Type, Name: tenant.Name, Slug: tenant.Slug, ParentID: tenant.ParentID, Status: tenant.Status, Description: tenant.Description, Domains: domains, Visibility: visibility, OrgUnitType: orgUnitType, Config: tenant.Config, CreatedAt: tenant.CreatedAt.Format(time.RFC3339), UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339), Members: []orgContextMember{}, } } func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment { assignments := make([]orgContextMemberAssignment, 0, 2) seenTenants := map[string]bool{} appointments := tenantClaimAppointmentsFromTraits(map[string]any(user.Metadata)) addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) { if !ok || seenTenants[tenant.ID] { return } seenTenants[tenant.ID] = true if appointment == nil { appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{ ID: tenant.ID, Slug: tenant.Slug, }) } assignments = append(assignments, orgContextMemberAssignment{ TenantID: tenant.ID, Member: mapOrgContextMember(user, appointment, includeUserIDs), }) } for _, appointment := range appointments { for _, key := range []string{"tenantId", "tenant_id"} { if tenantID := tenantClaimString(appointment, key); tenantID != "" { addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment) } } for _, key := range []string{"tenantSlug", "tenant_slug"} { if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" { tenant := tenantBySlug[strings.ToLower(tenantSlug)] addTenant(tenant, tenant.ID != "", appointment) } } } if user.TenantID != nil { addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "", nil) } if user.Tenant != nil { addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "", nil) tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)] addTenant(tenant, tenant.ID != "", nil) } return assignments } func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember { grade := user.Grade position := user.Position jobTitle := user.JobTitle department := user.Department if value := tenantClaimString(appointment, "grade"); value != "" { grade = value } if value := tenantClaimString(appointment, "position"); value != "" { position = value } if value := tenantClaimString(appointment, "jobTitle"); value != "" { jobTitle = value } if value := tenantClaimString(appointment, "job_title"); value != "" { jobTitle = value } if value := tenantClaimString(appointment, "department"); value != "" { department = value } isOwner := false if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok { isOwner = value } isManager := false if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok { isManager = value } isPrimary := false if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok { isPrimary = value } id := "" phone := "" if includeUserIDs { id = user.ID phone = user.Phone } return orgContextMember{ ID: id, Email: user.Email, Name: user.Name, Phone: phone, Department: department, Grade: grade, Position: position, JobTitle: jobTitle, IsOwner: isOwner, IsManager: isManager, IsPrimary: isPrimary, } } func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode { childrenByParentID := make(map[string][]domain.Tenant) for _, tenant := range tenants { if tenant.ParentID == nil { continue } childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant) } var build func(tenantID string) *orgContextTreeNode build = func(tenantID string) *orgContextTreeNode { tenant, ok := tenantByID[tenantID] if !ok { return nil } node := &orgContextTreeNode{ orgContextTenant: tenant, Children: []orgContextTreeNode{}, } for _, child := range childrenByParentID[tenantID] { childNode := build(child.ID) if childNode != nil { node.Children = append(node.Children, *childNode) } } return node } return build(rootID) } 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, counts, nil } if h.UserProjectionRepo == nil { return nil, nil, errors.New("user projection is not configured") } ready, err := h.UserProjectionRepo.IsReady(ctx) if err != nil { return nil, nil, fmt.Errorf("user projection status unavailable: %w", err) } if !ready { return nil, nil, errors.New("user projection is not ready") } 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 { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "" } if value != "active" && value != "inactive" { return "" } return value } func normalizeTenantType(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) switch value { case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeOrganization, domain.TenantTypeUserGroup: return value default: return "" } } func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error { tenantID := c.Params("id") var req struct { Name string `json:"name"` Description string `json:"description"` ExpiresAt *time.Time `json:"expiresAt"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } link, err := h.SharedLink.CreateLink(c.Context(), tenantID, req.Name, req.Description, req.ExpiresAt) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(link) } func (h *TenantHandler) ListShareLinks(c *fiber.Ctx) error { tenantID := c.Params("id") links, err := h.SharedLink.GetLinksByTenant(c.Context(), tenantID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(links) } func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error { id := c.Params("id") if err := h.SharedLink.DeactivateLink(c.Context(), id); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.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() role, userID, profileTenantID := orgChartProfileLogValues(profile) slog.Info("orgchart snapshot request started", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "cache_mode", cacheMode, ) 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") slog.Info("orgchart snapshot cache hit", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "tenant_count", len(cached.Tenants), "user_count", len(cached.Users), ) return c.JSON(cached) } slog.Warn("orgchart snapshot cache payload ignored", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "error", err, ) } else if err != nil && err != redis.Nil { slog.Warn("orgchart snapshot cache read failed", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "error", err, ) } } snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile) if err != nil { slog.Error("orgchart snapshot build failed", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "error", err, ) 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 { if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil { slog.Warn("orgchart snapshot cache write failed", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "error", err, ) } } c.Set("X-Orgfront-Cache", "MISS") } else { c.Set("X-Orgfront-Cache", "BYPASS") } slog.Info("orgchart snapshot request completed", "user_id", userID, "role", role, "profile_tenant_id", profileTenantID, "tenant_header", c.Get("X-Tenant-ID"), "cache_mode", cacheMode, "cache_result", c.GetRespHeader("X-Orgfront-Cache"), "tenant_count", len(snapshot.Tenants), "user_count", len(snapshot.Users), ) 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 } } roots := make(map[string]bool) for _, id := range baseTenantIDs { if strings.TrimSpace(id) != "" { roots[findTenantRootID(parentMap, id)] = true } } tenants := make([]domain.Tenant, 0, len(allTenants)) for _, tenant := range allTenants { if roots[findTenantRootID(parentMap, 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 orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) { if profile == nil { return "anonymous", "anonymous", "" } tenantID := "" if profile.TenantID != nil { tenantID = strings.TrimSpace(*profile.TenantID) } return domain.NormalizeRole(profile.Role), strings.TrimSpace(profile.ID), tenantID } func findTenantRootID(parentMap map[string]string, tenantID string) string { curr := strings.TrimSpace(tenantID) if curr == "" { return "" } visited := map[string]struct{}{} for { parentID := strings.TrimSpace(parentMap[curr]) if parentID == "" || parentID == curr { return curr } if _, exists := visited[parentID]; exists { return parentID } visited[curr] = struct{}{} curr = parentID } } 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 == "" { slog.Warn("public orgchart rejected missing token") return errorJSON(c, fiber.StatusUnauthorized, "share token is required") } link, err := h.SharedLink.ValidateToken(c.Context(), token) if err != nil { slog.Warn("public orgchart token validation failed", "token_length", len(token), "error", err, ) return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { slog.Error("public orgchart tenant list failed", "link_id", link.ID, "tenant_id", link.TenantID, "error", err, ) return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } parentMap := make(map[string]string) for _, t := range allTenants { if t.ParentID != nil { parentMap[t.ID] = *t.ParentID } } sharedRootID := findTenantRootID(parentMap, link.TenantID) var filteredTenants []domain.Tenant var tenantIDs []string for _, t := range allTenants { if findTenantRootID(parentMap, t.ID) == sharedRootID { filteredTenants = append(filteredTenants, t) } } filteredTenants = filterPublicTenants(filteredTenants) for _, t := range filteredTenants { tenantIDs = append(tenantIDs, t.ID) } type publicUserSummary struct { ID string `json:"id"` Name string `json:"name"` Position string `json:"position"` JobTitle string `json:"jobTitle"` TenantSlug string `json:"tenantSlug"` Status string `json:"status"` } var publicUsers []publicUserSummary seen := make(map[string]bool) // Fetch users by IDs var usersByID []domain.User h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID) for _, u := range usersByID { if u.Status != "active" || seen[u.ID] { continue } seen[u.ID] = true tenantSlug := "" if u.Tenant != nil { tenantSlug = u.Tenant.Slug } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, TenantSlug: tenantSlug, Status: u.Status, }) } tenantSummaries := make([]tenantSummary, 0, len(filteredTenants)) for _, t := range filteredTenants { tenantSummaries = append(tenantSummaries, mapTenantSummary(t)) } slog.Info("public orgchart request completed", "link_id", link.ID, "tenant_id", link.TenantID, "shared_root_id", sharedRootID, "tenant_count", len(tenantSummaries), "user_count", len(publicUsers), ) return c.JSON(fiber.Map{ "tenants": tenantSummaries, "users": publicUsers, "sharedWith": link.Name, }) } type tenantRelationRequest struct { UserID string `json:"userId"` Relation string `json:"relation"` } func (h *TenantHandler) ListRelations(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } allowedRelations := map[string]bool{ "profile_viewers": true, "profile_managers": true, "permissions_viewers": true, "permissions_managers": true, "organization_viewers": true, "organization_managers": true, "schema_viewers": true, "schema_managers": true, } type userRelationInfo struct { UserID string `json:"userId"` Name string `json:"name"` Email string `json:"email"` Relations []string `json:"relations"` } userMap := make(map[string][]string) for _, rel := range relations { if !allowedRelations[rel.Relation] { continue } if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") userMap[userID] = append(userMap[userID], rel.Relation) } items := []userRelationInfo{} for userID, rels := range userMap { name := "Unknown" email := "Unknown" if h.KratosAdmin != nil { identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } } if name == "Unknown" && email == "Unknown" && h.UserRepo != nil { user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } items = append(items, userRelationInfo{ UserID: userID, Name: name, Email: email, Relations: rels, }) } return c.JSON(fiber.Map{ "items": items, }) } func (h *TenantHandler) AddRelation(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var req tenantRelationRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.UserID == "" || req.Relation == "" { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } allowedRelations := map[string]bool{ "profile_viewers": true, "profile_managers": true, "permissions_viewers": true, "permissions_managers": true, "organization_viewers": true, "organization_managers": true, "schema_viewers": true, "schema_managers": true, } if !allowedRelations[req.Relation] { return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.") } } var directWriteErr error if h.Keto != nil { directWriteErr = h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID) } if h.KetoOutbox != nil { status := domain.KetoOutboxStatusPending var processedAt *time.Time if directWriteErr == nil && h.Keto != nil { status = domain.KetoOutboxStatusProcessed now := time.Now() processedAt = &now } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: req.Relation, Subject: "User:" + req.UserID, Action: domain.KetoOutboxActionCreate, Status: status, ProcessedAt: processedAt, }) } if directWriteErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error()) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var req tenantRelationRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.UserID == "" || req.Relation == "" { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } var directWriteErr error if h.Keto != nil { directWriteErr = h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID) } if h.KetoOutbox != nil { status := domain.KetoOutboxStatusPending var processedAt *time.Time if directWriteErr == nil && h.Keto != nil { status = domain.KetoOutboxStatusProcessed now := time.Now() processedAt = &now } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: req.Relation, Subject: "User:" + req.UserID, Action: domain.KetoOutboxActionDelete, Status: status, ProcessedAt: processedAt, }) } if directWriteErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error()) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error { relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } allowedRelations := map[string]bool{ "overview_viewers": true, "tenants_viewers": true, "org_chart_viewers": true, "worksmobile_viewers": true, "ory_ssot_viewers": true, "data_integrity_viewers": true, "users_viewers": true, "permissions_direct_viewers": true, "auth_guard_viewers": true, "api_keys_viewers": true, "audit_logs_viewers": true, "overview_managers": true, "tenants_managers": true, "org_chart_managers": true, "worksmobile_managers": true, "ory_ssot_managers": true, "data_integrity_managers": true, "users_managers": true, "permissions_direct_managers": true, "auth_guard_managers": true, "api_keys_managers": true, "audit_logs_managers": true, } type userRelationInfo struct { UserID string `json:"userId"` Name string `json:"name"` Email string `json:"email"` Relations []string `json:"relations"` } userMap := make(map[string][]string) for _, rel := range relations { if !allowedRelations[rel.Relation] { continue } if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") userMap[userID] = append(userMap[userID], rel.Relation) } items := []userRelationInfo{} for userID, rels := range userMap { name := "Unknown" email := "Unknown" if h.KratosAdmin != nil { identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } } if name == "Unknown" && email == "Unknown" && h.UserRepo != nil { user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } items = append(items, userRelationInfo{ UserID: userID, Name: name, Email: email, Relations: rels, }) } return c.JSON(fiber.Map{ "items": items, }) } func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error { var req tenantRelationRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.UserID == "" || req.Relation == "" { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } allowedRelations := map[string]bool{ "overview_viewers": true, "tenants_viewers": true, "org_chart_viewers": true, "worksmobile_viewers": true, "ory_ssot_viewers": true, "data_integrity_viewers": true, "users_viewers": true, "permissions_direct_viewers": true, "auth_guard_viewers": true, "api_keys_viewers": true, "audit_logs_viewers": true, "overview_managers": true, "tenants_managers": true, "org_chart_managers": true, "worksmobile_managers": true, "ory_ssot_managers": true, "data_integrity_managers": true, "users_managers": true, "permissions_direct_managers": true, "auth_guard_managers": true, "api_keys_managers": true, "audit_logs_managers": true, } if !allowedRelations[req.Relation] { return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.") } } var directWriteErr error if h.Keto != nil { directWriteErr = h.Keto.CreateRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) } if h.KetoOutbox != nil { status := domain.KetoOutboxStatusPending var processedAt *time.Time if directWriteErr == nil && h.Keto != nil { status = domain.KetoOutboxStatusProcessed now := time.Now() processedAt = &now } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "System", Object: "system", Relation: req.Relation, Subject: "User:" + req.UserID, Action: domain.KetoOutboxActionCreate, Status: status, ProcessedAt: processedAt, }) } if directWriteErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error()) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error { var req tenantRelationRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.UserID == "" || req.Relation == "" { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } var directWriteErr error if h.Keto != nil { directWriteErr = h.Keto.DeleteRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) } if h.KetoOutbox != nil { status := domain.KetoOutboxStatusPending var processedAt *time.Time if directWriteErr == nil && h.Keto != nil { status = domain.KetoOutboxStatusProcessed now := time.Now() processedAt = &now } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "System", Object: "system", Relation: req.Relation, Subject: "User:" + req.UserID, Action: domain.KetoOutboxActionDelete, Status: status, ProcessedAt: processedAt, }) } if directWriteErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error()) } return c.SendStatus(fiber.StatusOK) }