1
0
forked from baron/baron-sso

테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거

This commit is contained in:
2026-05-13 18:05:51 +09:00
parent a4d707d4d8
commit 5e7b7b878c
85 changed files with 4808 additions and 734 deletions

View File

@@ -3,6 +3,7 @@ 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"
@@ -81,10 +82,22 @@ type tenantSummary struct {
}
type tenantListResponse struct {
Items []tenantSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
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"`
}
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 tenantImportResult struct {
@@ -115,43 +128,45 @@ type tenantCSVRecord struct {
}
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"`
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 orgContextUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Status string `json:"status"`
TenantIDs []string `json:"tenantIds"`
TenantSlugs []string `json:"tenantSlugs"`
Department string `json:"department,omitempty"`
Grade string `json:"grade,omitempty"`
Position string `json:"position,omitempty"`
JobTitle string `json:"jobTitle,omitempty"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
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"`
IsLeader bool `json:"isLeader"`
IsPrimary bool `json:"isPrimary"`
}
type orgContextMemberAssignment struct {
TenantID string
Member orgContextMember
}
type orgContextTreeNode struct {
orgContextTenant
DirectUserIDs []string `json:"directUserIds"`
Children []orgContextTreeNode `json:"children"`
Children []orgContextTreeNode `json:"children"`
}
type orgContextScope struct {
@@ -165,7 +180,6 @@ type orgContextResponse struct {
Scope orgContextScope `json:"scope"`
Tree *orgContextTreeNode `json:"tree"`
Tenants []orgContextTenant `json:"tenants"`
Users []orgContextUser `json:"users"`
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
@@ -213,6 +227,7 @@ 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
@@ -224,6 +239,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
var tenants []domain.Tenant
var total int64
var err error
nextCursor := ""
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
role := ""
@@ -291,21 +307,48 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
tenants, err = h.filterPrivateTenantsForProfile(c.Context(), tenants, profile)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
total = int64(len(tenants))
if offset < 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 := offset + limit
if end > len(tenants) {
end = 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
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 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)
}
}
}
@@ -321,7 +364,52 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
items = append(items, summary)
}
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
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) ([]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)
}
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 {
@@ -330,6 +418,11 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
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)
var buf bytes.Buffer
@@ -923,6 +1016,152 @@ func filterPublicTenants(tenants []domain.Tenant) []domain.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
@@ -1948,25 +2187,28 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
includeUsers := !strings.EqualFold(strings.TrimSpace(c.Query("includeUsers")), "false")
contextUsers := []orgContextUser{}
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")
}
contextUsers, err = h.loadOrgContextUsers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug)
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
directUserIDsByTenantID := make(map[string][]string)
for _, user := range contextUsers {
for _, tenantID := range user.TenantIDs {
directUserIDsByTenantID[tenantID] = append(directUserIDsByTenantID[tenantID], user.ID)
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, directUserIDsByTenantID)
tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID)
return c.JSON(orgContextResponse{
SchemaVersion: "baron.org-context.v1",
IssuedAt: time.Now().UTC().Format(time.RFC3339),
@@ -1976,11 +2218,10 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
},
Tree: tree,
Tenants: contextTenants,
Users: contextUsers,
})
}
func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant) ([]orgContextUser, error) {
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
@@ -1989,21 +2230,29 @@ func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tena
if err != nil {
return nil, err
}
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "")
if err != nil {
return nil, err
}
seen := make(map[string]bool)
contextUsers := make([]orgContextUser, 0, len(usersByID)+len(usersBySlug))
for _, user := range append(usersByID, usersBySlug...) {
membersByTenantID := make(map[string][]orgContextMember)
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || user.Status != domain.UserStatusActive {
continue
}
mapped, ok := mapOrgContextUser(user, tenantByID, tenantBySlug)
if !ok {
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
contextUsers = append(contextUsers, mapped)
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
}
return contextUsers, nil
return membersByTenantID, nil
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
@@ -2089,62 +2338,119 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
Config: tenant.Config,
CreatedAt: tenant.CreatedAt.Format(time.RFC3339),
UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339),
Members: []orgContextMember{},
}
}
func mapOrgContextUser(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant) (orgContextUser, bool) {
matchedTenants := make([]orgContextTenant, 0, 2)
func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
addTenant := func(tenant orgContextTenant, ok 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
matchedTenants = append(matchedTenants, tenant)
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 != "")
addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "", nil)
}
if user.Tenant != nil {
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "")
addTenant(tenantBySlug[strings.ToLower(user.Tenant.Slug)], tenantBySlug[strings.ToLower(user.Tenant.Slug)].ID != "")
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "", nil)
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
addTenant(tenant, tenant.ID != "", nil)
}
if user.CompanyCode != "" {
addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))].ID != "")
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
for _, companyCode := range user.CompanyCodes {
addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))].ID != "")
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
if len(matchedTenants) == 0 {
return orgContextUser{}, false
}
tenantIDs := make([]string, 0, len(matchedTenants))
tenantSlugs := make([]string, 0, len(matchedTenants))
for _, tenant := range matchedTenants {
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
}
return orgContextUser{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Role: user.Role,
Status: user.Status,
TenantIDs: tenantIDs,
TenantSlugs: tenantSlugs,
Department: user.Department,
Grade: user.Grade,
Position: user.Position,
JobTitle: user.JobTitle,
Metadata: user.Metadata,
CreatedAt: user.CreatedAt.Format(time.RFC3339),
UpdatedAt: user.UpdatedAt.Format(time.RFC3339),
}, true
return assignments
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant, directUserIDsByTenantID map[string][]string) *orgContextTreeNode {
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", "isManager"); ok {
isOwner = value
}
isLeader := isOwner
if value, ok := metadataBoolFromMap(appointment, "lead", "isLead"); ok {
isLeader = 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,
IsLeader: isLeader,
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 {
@@ -2161,7 +2467,6 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
node := &orgContextTreeNode{
orgContextTenant: tenant,
DirectUserIDs: directUserIDsByTenantID[tenantID],
Children: []orgContextTreeNode{},
}
for _, child := range childrenByParentID[tenantID] {