forked from baron/baron-sso
3664 lines
107 KiB
Go
3664 lines
107 KiB
Go
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"`
|
|
}
|
|
|
|
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,
|
|
}
|
|
} 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, 11)
|
|
relations := []string{
|
|
"view", "manage", "manage_admins",
|
|
"view_profile", "manage_profile",
|
|
"view_permissions", "manage_permissions",
|
|
"view_organization", "manage_organization",
|
|
"view_schema", "manage_schema",
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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)
|
|
}
|