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")