forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user