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

@@ -2,6 +2,8 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"errors"
"strings"
"time"
@@ -29,8 +31,55 @@ type apiKeySummary struct {
}
type apiKeyListResponse struct {
Items []apiKeySummary `json:"items"`
Total int64 `json:"total"`
Items []apiKeySummary `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
func apiKeyToSummary(k domain.ApiKey) apiKeySummary {
lastUsed := ""
if k.LastUsedAt != nil {
lastUsed = k.LastUsedAt.Format(time.RFC3339)
}
return apiKeySummary{
ID: k.ID,
Name: k.Name,
ClientID: k.ClientID,
Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")),
Status: k.Status,
LastUsedAt: &lastUsed,
CreatedAt: k.CreatedAt,
}
}
func apiKeyWithUpdatedScopes(k domain.ApiKey, scopes []string) domain.ApiKey {
k.Scopes = strings.Join(normalizeApiKeyScopes(scopes), " ")
return k
}
func apiKeyWithRotatedSecretHash(k domain.ApiKey, hashedSecret string) domain.ApiKey {
k.ClientSecretHash = hashedSecret
return k
}
func normalizeApiKeyScopes(scopes []string) []string {
seen := make(map[string]struct{}, len(scopes))
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
if _, exists := seen[scope]; exists {
continue
}
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
return normalized
}
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
@@ -40,6 +89,13 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
@@ -47,28 +103,48 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
}
var keys []domain.ApiKey
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil {
query := h.DB.Order("created_at desc, id desc").Limit(limit + 1)
if cursorRaw != "" {
cursor, err := pagination.Decode(cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
query = pagination.ApplyCreatedAtIDCursor(query, cursor, "created_at", "id")
offset = 0
} else {
query = query.Offset(offset)
}
if err := query.Find(&keys).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
nextCursor := ""
hasMore := len(keys) > limit
if len(keys) > limit {
keys = keys[:limit]
}
if cursorRaw == "" && total > int64(offset+len(keys)) {
hasMore = true
}
if hasMore && len(keys) > 0 {
last := keys[len(keys)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
items := make([]apiKeySummary, 0, len(keys))
for _, k := range keys {
lastUsed := ""
if k.LastUsedAt != nil {
lastUsed = k.LastUsedAt.Format(time.RFC3339)
}
items = append(items, apiKeySummary{
ID: k.ID,
Name: k.Name,
ClientID: k.ClientID,
Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")),
Status: k.Status,
LastUsedAt: &lastUsed,
CreatedAt: k.CreatedAt,
})
items = append(items, apiKeyToSummary(k))
}
return c.JSON(apiKeyListResponse{Items: items, Total: total})
return c.JSON(apiKeyListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
@@ -87,6 +163,10 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
if strings.TrimSpace(req.Name) == "" {
return errorJSON(c, fiber.StatusBadRequest, "name is required")
}
req.Scopes = normalizeApiKeyScopes(req.Scopes)
if len(req.Scopes) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
}
// Generate Client ID (16 chars hex)
clientID := GenerateSecureToken(8)
@@ -112,21 +192,84 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
}
// Return summary + PLAIN SECRET (only this time)
lastUsed := ""
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"apiKey": apiKeySummary{
ID: apiKey.ID,
Name: apiKey.Name,
ClientID: apiKey.ClientID,
Scopes: req.Scopes,
Status: apiKey.Status,
LastUsedAt: &lastUsed,
CreatedAt: apiKey.CreatedAt,
},
"apiKey": apiKeyToSummary(apiKey),
"clientSecret": plainSecret, // VERY IMPORTANT: user must save this now
})
}
func (h *ApiKeyHandler) UpdateApiKey(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
id := c.Params("id")
if id == "" {
return errorJSON(c, fiber.StatusBadRequest, "id is required")
}
var req struct {
Scopes []string `json:"scopes"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Scopes = normalizeApiKeyScopes(req.Scopes)
if len(req.Scopes) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
}
var apiKey domain.ApiKey
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "api key not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
apiKey = apiKeyWithUpdatedScopes(apiKey, req.Scopes)
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("scopes", apiKey.Scopes).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(apiKeyToSummary(apiKey))
}
func (h *ApiKeyHandler) RotateApiKeySecret(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
id := c.Params("id")
if id == "" {
return errorJSON(c, fiber.StatusBadRequest, "id is required")
}
var apiKey domain.ApiKey
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "api key not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
plainSecret := GenerateSecureToken(8)
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
}
apiKey = apiKeyWithRotatedSecretHash(apiKey, string(hashedSecret))
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("client_secret_hash", apiKey.ClientSecretHash).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"apiKey": apiKeyToSummary(apiKey),
"clientSecret": plainSecret,
})
}
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")

View File

@@ -1,6 +1,7 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"encoding/json"
"net/http"
@@ -57,3 +58,76 @@ func TestApiKeyHandler_Validation(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestApiKeyHandler_UpdateApiKeyScopesRequiresDatabase(t *testing.T) {
app := fiber.New()
h := &ApiKeyHandler{DB: nil}
app.Patch("/api-keys/:id", h.UpdateApiKey)
body, _ := json.Marshal(map[string]interface{}{
"scopes": []string{"org-context:read"},
})
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestApiKeyHandler_RotateApiKeySecretRequiresDatabase(t *testing.T) {
app := fiber.New()
h := &ApiKeyHandler{DB: nil}
app.Post("/api-keys/:id/secret/rotate", h.RotateApiKeySecret)
req := httptest.NewRequest("POST", "/api-keys/api-key-id/secret/rotate", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestApiKeyWithUpdatedScopesPreservesClientID(t *testing.T) {
key := domain.ApiKey{
ID: "api-key-id",
Name: "M2M Test",
ClientID: "client-id-stable",
ClientSecretHash: "old-secret-hash",
Scopes: "audit:read",
Status: "active",
}
updated := apiKeyWithUpdatedScopes(key, []string{"audit:read", "org-context:read"})
assert.Equal(t, "client-id-stable", updated.ClientID)
assert.Equal(t, "old-secret-hash", updated.ClientSecretHash)
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
}
func TestApiKeyWithRotatedSecretHashPreservesClientIDAndScopes(t *testing.T) {
key := domain.ApiKey{
ID: "api-key-id",
Name: "M2M Test",
ClientID: "client-id-stable",
ClientSecretHash: "old-secret-hash",
Scopes: "audit:read org-context:read",
Status: "active",
}
updated := apiKeyWithRotatedSecretHash(key, "new-secret-hash")
assert.Equal(t, "client-id-stable", updated.ClientID)
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
assert.Equal(t, "new-secret-hash", updated.ClientSecretHash)
}
func TestNormalizeApiKeyScopesTrimsAndDeduplicates(t *testing.T) {
scopes := normalizeApiKeyScopes([]string{
" audit:read ",
"",
"org-context:read",
"audit:read",
})
assert.Equal(t, []string{"audit:read", "org-context:read"}, scopes)
}

View File

@@ -107,7 +107,7 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
return nil, nil
}
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"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"
@@ -121,9 +122,11 @@ type clientSummary struct {
}
type clientListResponse struct {
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type clientDetailResponse struct {
@@ -186,7 +189,12 @@ type consentSummary struct {
}
type consentListResponse struct {
Items []consentSummary `json:"items"`
Items []consentSummary `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type clientUpsertRequest struct {
@@ -1097,6 +1105,30 @@ func (h *DevHandler) listVisibleClientSummaries(
return items, nil
}
func (h *DevHandler) listAllVisibleClientSummaries(c *fiber.Ctx, profile *domain.UserProfileResponse) ([]clientSummary, error) {
const pageSize = 500
items := make([]clientSummary, 0)
for offset := 0; ; offset += pageSize {
page, err := h.listVisibleClientSummaries(c, profile, pageSize, offset)
if err != nil {
return nil, err
}
items = append(items, page...)
if len(page) < pageSize {
break
}
}
return items, nil
}
func clientSummaryCursorKey(client clientSummary) (time.Time, string) {
timestamp := time.Unix(0, 0).UTC()
if client.CreatedAt != nil && !client.CreatedAt.IsZero() {
timestamp = client.CreatedAt.UTC()
}
return timestamp, client.ID
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
@@ -1213,6 +1245,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
@@ -1221,7 +1254,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
}
profile := h.getCurrentProfile(c)
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
allItems, err := h.listAllVisibleClientSummaries(c, profile)
if err != nil {
status := fiber.StatusInternalServerError
errMsg := err.Error()
@@ -1239,10 +1272,37 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
return errorJSON(c, status, errMsg)
}
var items []clientSummary
nextCursor := ""
if cursorRaw != "" {
ordered := append([]clientSummary(nil), allItems...)
pagination.SortByKeyDesc(ordered, clientSummaryCursorKey)
items, nextCursor, err = pagination.PageByCursor(ordered, limit, cursorRaw, clientSummaryCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(allItems) {
offset = len(allItems)
}
end := offset + limit
if end > len(allItems) {
end = len(allItems)
}
items = allItems[offset:end]
if len(allItems) > end && len(items) > 0 {
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
return c.JSON(clientListResponse{
Items: items,
Limit: limit,
Offset: offset,
Items: items,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
@@ -2126,9 +2186,13 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
// [Isolation] Get admin tenant ID from locals or header
adminTenantID := ""
@@ -2156,10 +2220,16 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
}
}
queryLimit := limit
queryOffset := offset
if cursorRaw != "" || subject != "" || (statusFilter != "" && statusFilter != "all") {
queryLimit = 10000
queryOffset = 0
}
if adminTenantID != "" {
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset)
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, queryLimit, queryOffset)
} else {
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset)
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, queryLimit, queryOffset)
}
if err != nil {
@@ -2216,12 +2286,47 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
pagination.SortByKeyDesc(items, consentSummaryCursorKey)
nextCursor := ""
if cursorRaw != "" {
items, nextCursor, err = pagination.PageByCursor(items, limit, cursorRaw, consentSummaryCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if queryLimit != limit {
if offset > len(items) {
offset = len(items)
}
end := offset + limit
if end > len(items) {
end = len(items)
}
pageItems := items[offset:end]
if len(items) > end && len(pageItems) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
items = pageItems
} else if total > int64(offset+len(items)) && len(items) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(items[len(items)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
return c.JSON(consentListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func consentSummaryCursorKey(consent consentSummary) (time.Time, string) {
return consent.CreatedAt, consent.ClientID + ":" + consent.Subject
}
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
subject := strings.TrimSpace(c.Query("subject"))

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] {

View File

@@ -130,10 +130,10 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
return nil, nil
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, companyCode)
args := m.Called(ctx, offset, limit, search, tenantSlug)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
}
}
@@ -368,6 +368,205 @@ func TestTenantHandler_ListTenants(t *testing.T) {
}
}
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)},
}
mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Len(t, res.Items, 2)
require.NotEmpty(t, res.NextCursor)
}
func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000003", Name: "Tenant C", Slug: "slug-c", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
}
page, nextCursor, err := pageTenantsByCursor(tenants, 2, "")
require.NoError(t, err)
require.NotEmpty(t, nextCursor)
require.Equal(t, []string{
"00000000-0000-0000-0000-000000000003",
"00000000-0000-0000-0000-000000000002",
}, []string{page[0].ID, page[1].ID})
nextPage, _, err := pageTenantsByCursor(tenants, 2, nextCursor)
require.NoError(t, err)
require.Equal(t, []string{"00000000-0000-0000-0000-000000000001"}, []string{nextPage[0].ID})
}
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(3), res.Total)
require.NotContains(t, toJSONString(t, res), "private-team")
require.NotContains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
ManageableTenants: []domain.Tenant{
{ID: "private-team", Slug: "private-team"},
},
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(4), res.Total)
require.Contains(t, toJSONString(t, res), "private-team")
require.Contains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
mockKeto := new(devMockKetoService)
h := &TenantHandler{Keto: mockKeto}
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", "view_private").Return(true, nil).Once()
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
require.NoError(t, err)
require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child"))
mockKeto.AssertExpectations(t)
}
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
if len(got) != len(want) {
return false
}
counts := make(map[string]int, len(want))
for _, slug := range want {
counts[slug]++
}
for _, tenant := range got {
counts[tenant.Slug]--
if counts[tenant.Slug] < 0 {
return false
}
}
return true
}
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -391,15 +590,60 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now},
}
usersByTenantID := []domain.User{
{ID: "user-platform-lead", Email: "lead@example.com", Name: "플랫폼 리드", Status: domain.UserStatusActive, TenantID: parent("dept-platform"), CompanyCode: "platform", Grade: "책임", Position: "실장", CreatedAt: now, UpdatedAt: now},
{
ID: "user-platform-lead",
Email: "lead@example.com",
Name: "플랫폼 리드",
Phone: "010-1111-2222",
Status: domain.UserStatusActive,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
Grade: "책임",
Position: "실장",
JobTitle: "Backend Engineer",
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": "dept-platform",
"isPrimary": true,
"isOwner": true,
"grade": "수석",
"position": "실장",
"jobTitle": "기술기획",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
usersBySlug := []domain.User{
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
}
usersByList := []domain.User{
{
ID: "user-appointment-only",
Email: "appointment@example.com",
Name: "겸직 사용자",
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantSlug": "sso",
"lead": true,
"position": "파트장",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil)
mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil)
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req)
@@ -421,18 +665,96 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"])
require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"])
usersPayload := got["users"].([]any)
require.Len(t, usersPayload, 2)
require.Equal(t, "user-platform-lead", usersPayload[0].(map[string]any)["id"])
require.Equal(t, []any{"dept-platform"}, usersPayload[0].(map[string]any)["tenantIds"])
require.Equal(t, "user-sso-member", usersPayload[1].(map[string]any)["id"])
require.NotContains(t, got, "users")
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 1)
firstUser := platformMembers[0].(map[string]any)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
require.NotContains(t, firstUser, "tenantIds")
require.NotContains(t, firstUser, "tenantSlugs")
require.NotContains(t, firstUser, "memberships")
require.NotContains(t, firstUser, "role")
require.NotContains(t, firstUser, "status")
require.NotContains(t, firstUser, "metadata")
require.NotContains(t, firstUser, "createdAt")
require.NotContains(t, firstUser, "updatedAt")
require.Equal(t, "lead@example.com", firstUser["email"])
require.Equal(t, "플랫폼 리드", firstUser["name"])
require.Equal(t, true, firstUser["isOwner"])
require.Equal(t, true, firstUser["isLeader"])
require.Equal(t, true, firstUser["isPrimary"])
require.Equal(t, "수석", firstUser["grade"])
require.Equal(t, "실장", firstUser["position"])
require.Equal(t, "기술기획", firstUser["jobTitle"])
teamSSO := tenantsPayload[3].(map[string]any)
ssoMembers := teamSSO["members"].([]any)
require.Len(t, ssoMembers, 2)
appointmentOnly := ssoMembers[1].(map[string]any)
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
require.Equal(t, false, appointmentOnly["isOwner"])
require.Equal(t, true, appointmentOnly["isLeader"])
tree := got["tree"].(map[string]any)
require.Equal(t, "group-hanmac-family", tree["id"])
require.NotContains(t, tree, "directUserIds")
require.Contains(t, tree, "members")
require.NotContains(t, toJSONString(t, got), "directUserIds")
require.NotContains(t, toJSONString(t, got), "private-team")
require.NotContains(t, toJSONString(t, got), "root-other")
}
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "company-hanmac", Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUserIds=true", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NotContains(t, got, "users")
tenantsPayload := got["tenants"].([]any)
members := tenantsPayload[0].(map[string]any)["members"].([]any)
require.Len(t, members, 1)
member := members[0].(map[string]any)
require.Equal(t, "user-1", member["id"])
require.Equal(t, "010-1234-5678", member["phone"])
require.NotContains(t, member, "tenantIds")
require.NotContains(t, member, "tenantSlugs")
require.NotContains(t, member, "memberships")
tree := got["tree"].(map[string]any)
treeMembers := tree["members"].([]any)
require.Len(t, treeMembers, 1)
require.Equal(t, "user-1", treeMembers[0].(map[string]any)["id"])
require.Equal(t, "010-1234-5678", treeMembers[0].(map[string]any)["phone"])
require.NotContains(t, tree, "directUserIds")
}
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -697,6 +1019,44 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
return c.Next()
})
app.Get("/tenants/export", h.ExportTenantsCSV)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
text := string(body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, text, "public-team")
assert.NotContains(t, text, "private-team")
assert.NotContains(t, text, "private-child")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -2,6 +2,7 @@ package handler
import (
"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"
@@ -80,6 +81,20 @@ func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[st
return metadata
}
func sanitizeUserMetadata(metadata map[string]any) map[string]any {
if metadata == nil {
return nil
}
sanitized := make(map[string]any, len(metadata))
for key, value := range metadata {
if key == "hanmacFamily" || key == "userType" {
continue
}
sanitized[key] = value
}
return sanitized
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
if value := strings.TrimSpace(primaryTenantID); value != "" {
return value
@@ -206,6 +221,25 @@ func gradeFromTraits(traits map[string]interface{}) string {
return value
}
func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) string {
if value := strings.TrimSpace(tenantSlug); value != "" {
return value
}
return strings.TrimSpace(legacyCompanyCode)
}
func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) *string {
if tenantSlug != nil {
value := strings.TrimSpace(*tenantSlug)
return &value
}
if legacyCompanyCode != nil {
value := strings.TrimSpace(*legacyCompanyCode)
return &value
}
return nil
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -215,6 +249,7 @@ type userSummary struct {
Phone string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
TenantSlug string `json:"tenantSlug,omitempty"`
CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
@@ -229,10 +264,20 @@ type userSummary struct {
}
type userListResponse struct {
Items []userSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Items []userSummary `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 kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string) {
timestamp := identity.CreatedAt
if timestamp.IsZero() {
timestamp = time.Unix(0, 0).UTC()
}
return timestamp, identity.ID
}
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
@@ -246,6 +291,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
search := strings.TrimSpace(c.Query("search"))
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
@@ -399,17 +445,33 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
items := make([]userSummary, 0, end-offset)
for _, identity := range filtered[offset:end] {
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
@@ -427,7 +489,14 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
}(filtered)
}
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
return c.JSON(userListResponse{
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
slog.Warn("Kratos unavailable for user list", "error", err)
@@ -490,6 +559,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Grade string `json:"grade"`
@@ -504,7 +574,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
req.CompanyCode = tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
email := strings.TrimSpace(req.Email)
if email == "" {
@@ -724,6 +795,7 @@ type bulkUserItem struct {
Role string `json:"role"`
TenantID string `json:"tenantId"`
TenantSlug string `json:"tenantSlug"`
CompanyCode string `json:"companyCode"`
EmailDomain string `json:"emailDomain"`
Department string `json:"department"`
Grade string `json:"grade"`
@@ -881,7 +953,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := strings.TrimSpace(item.TenantSlug)
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
@@ -1054,6 +1126,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
item.Metadata = sanitizeUserMetadata(item.Metadata)
password, _ := utils.GeneratePasswordWithPolicy(policy)
role := item.Role
@@ -1218,10 +1291,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if companyCode == "" {
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
}
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
var requesterRole string
var manageableSlugs []string
@@ -1269,8 +1339,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
}
// 1. Fetch Users using Repo for efficiency
// repo.List expects (ctx, offset, limit, search, companyCode)
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, companyCode)
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
}
@@ -1386,6 +1455,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
UserIDs []string `json:"userIds"`
Status *string `json:"status"`
Role *string `json:"role"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Grade *string `json:"grade"`
@@ -1395,6 +1465,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if len(req.UserIDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
@@ -1404,6 +1475,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if requester == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
}
if req.Role != nil {
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
*req.Role = role
}
// [New] Pre-fetch tenant cache if companyCode is being changed
type tenantCacheItem struct {
@@ -1683,6 +1764,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
@@ -1699,7 +1781,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
*req.Role = role
}
// [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
@@ -1754,6 +1847,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if traits == nil {
traits = map[string]interface{}{}
}
delete(traits, "hanmacFamily")
delete(traits, "userType")
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
@@ -2191,6 +2286,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
TenantSlug: compCode,
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),

View File

@@ -17,6 +17,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// --- Mocks ---
@@ -118,6 +119,22 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx contex
return nil
}
func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
metadata := map[string]any{
"hanmacFamily": true,
"userType": "hanmac",
"employeeId": "E001",
}
sanitized := sanitizeUserMetadata(metadata)
assert.NotContains(t, sanitized, "hanmacFamily")
assert.NotContains(t, sanitized, "userType")
assert.Equal(t, "E001", sanitized["employeeId"])
assert.Contains(t, metadata, "hanmacFamily")
assert.Contains(t, metadata, "userType")
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService
@@ -693,6 +710,40 @@ func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testin
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
h := &UserHandler{KratosAdmin: mockKratos}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]interface{}{"email": "c@example.com", "name": "C"}},
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]interface{}{"email": "b@example.com", "name": "B"}},
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]interface{}{"email": "a@example.com", "name": "A"}},
}, nil).Once()
req := httptest.NewRequest("GET", "/users?limit=2", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res userListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Len(t, res.Items, 2)
require.NotEmpty(t, res.NextCursor)
require.Equal(t, int64(3), res.Total)
}
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -904,6 +955,27 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
})
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
app := fiber.New()
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleTenantAdmin})
return h.BulkUpdateUsers(c)
})
role := domain.RoleSuperAdmin
payload := map[string]interface{}{
"userIds": []string{"u-1"},
"role": &role,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusForbidden, resp.StatusCode)
})
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
@@ -1381,7 +1453,8 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["tenant_id"] == tenantID &&
user.Attributes["companyCode"] == "saman" &&
user.Attributes["additionalAppointments"] != nil
user.Attributes["additionalAppointments"] != nil &&
user.Attributes["userType"] == nil
}), mock.Anything).Return("u-appointment", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
ID: "u-appointment",
@@ -1498,6 +1571,171 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
mockKratos.AssertExpectations(t)
}
func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
}, nil).Twice()
mockTenant.On("GetTenant", mock.Anything, "tenant-id").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["companyCode"] == "test-tenant" &&
user.Attributes["tenant_id"] == "tenant-id"
}), "Password1!").Return("user-id", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "test-tenant",
"tenant_id": "tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"email":"user@test.com","password":"Password1!","name":"Test User","tenantSlug":"test-tenant"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "old-tenant",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Twice()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == "new-tenant" &&
traits["tenant_id"] == "new-tenant-id"
}), "").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "new-tenant",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/bulk", h.BulkUpdateUsers)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "old-tenant",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == "new-tenant" &&
traits["tenant_id"] == "new-tenant-id"
}), "active").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "new-tenant",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"userIds":["user-id"],"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{