diff --git a/cmd/server/main.go b/cmd/server/main.go index 3c2b685..c4d48d1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -428,10 +428,10 @@ func main() { masterGroup.GET("/stats", masterHandler.GetSelfStats) } - // Auth Routes (public, no middleware - self-validates token) - authGroup := r.Group("/auth") + // Users Routes (public, no middleware - self-validates token) + usersGroup := r.Group("/users") { - authGroup.GET("/whoami", authHandler.Whoami) + usersGroup.GET("/info", authHandler.Whoami) } // Public/General Routes (if any) diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index 41d5ece..f212b3b 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -34,57 +34,8 @@ func NewAuthHandler(db *gorm.DB, rdb *redis.Client, adminService *service.AdminS } } -// WhoamiAdminResponse represents admin identity response. -type WhoamiAdminResponse struct { - Type string `json:"type" example:"admin"` // Always "admin" - Role string `json:"role" example:"admin"` // Admin role -} - -// WhoamiMasterResponse represents master identity response. -type WhoamiMasterResponse struct { - Type string `json:"type" example:"master"` // Always "master" - ID uint `json:"id" example:"1"` // Master ID - Name string `json:"name" example:"tenant-a"` // Master name - Group string `json:"group" example:"default"` // Routing group - DefaultNamespace string `json:"default_namespace" example:"default"` // Default namespace - Namespaces string `json:"namespaces" example:"default,ns1"` // Comma-separated namespaces - Status string `json:"status" example:"active"` // Status: active/suspended - Epoch int64 `json:"epoch" example:"1"` // Epoch for key revocation - MaxChildKeys int `json:"max_child_keys" example:"5"` // Max child keys allowed - GlobalQPS int `json:"global_qps" example:"100"` // Global QPS limit - CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp - UpdatedAt int64 `json:"updated_at" example:"1703505600"` // Unix timestamp -} - -// WhoamiKeyResponse represents child key identity response. -type WhoamiKeyResponse struct { - Type string `json:"type" example:"key"` // Always "key" - ID uint `json:"id" example:"5"` // Key ID - MasterID uint `json:"master_id" example:"1"` // Parent master ID - Group string `json:"group" example:"default"` // Routing group - Scopes string `json:"scopes" example:"chat:write"` // Comma-separated scopes - DefaultNamespace string `json:"default_namespace" example:"default"` // Default namespace - Namespaces string `json:"namespaces" example:"default"` // Comma-separated namespaces - Status string `json:"status" example:"active"` // Status: active/suspended - IssuedAtEpoch int64 `json:"issued_at_epoch" example:"1"` // Master epoch when issued - IssuedBy string `json:"issued_by" example:"master"` // "master" or "admin" - AllowIPs string `json:"allow_ips,omitempty" example:""` // IP whitelist (for diagnostics) - DenyIPs string `json:"deny_ips,omitempty" example:""` // IP blacklist (for diagnostics) - CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp - UpdatedAt int64 `json:"updated_at" example:"1703505600"` // Unix timestamp -} - -// WhoamiRealtimeView represents realtime statistics for the authenticated identity. -type WhoamiRealtimeView struct { - Requests int64 `json:"requests" example:"100"` // Total requests - Tokens int64 `json:"tokens" example:"50000"` // Total tokens used - QPS int64 `json:"qps" example:"5"` // Current QPS - QPSLimit int64 `json:"qps_limit" example:"100"` // QPS limit - RateLimited bool `json:"rate_limited" example:"false"` // Whether currently rate limited - UpdatedAt *int64 `json:"updated_at,omitempty" example:"1703505600"` // Last updated timestamp -} - // WhoamiResponse is a union type for all identity responses. +// Based on ez-contract/schemas/auth/auth.yaml // swagger:model WhoamiResponse type WhoamiResponse struct { // Type of the authenticated identity: "admin", "master", or "key" @@ -108,34 +59,17 @@ type WhoamiResponse struct { UpdatedAt int64 `json:"updated_at,omitempty" example:"1703505600"` // Key fields (only present when type is "key") - MasterID uint `json:"master_id,omitempty" example:"1"` - MasterName string `json:"master_name,omitempty" example:"tenant-a"` // Parent master name (for display) - Scopes string `json:"scopes,omitempty" example:"chat:write"` - IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"` - IssuedBy string `json:"issued_by,omitempty" example:"master"` - AllowIPs string `json:"allow_ips,omitempty" example:""` // IP whitelist (for diagnostics) - DenyIPs string `json:"deny_ips,omitempty" example:""` // IP blacklist (for diagnostics) - ExpiresAt int64 `json:"expires_at,omitempty" example:"0"` // Expiration timestamp (0 = never) - ModelLimits string `json:"model_limits,omitempty" example:"gpt-4,claude"` // Model restrictions - ModelLimitsEnabled bool `json:"model_limits_enabled,omitempty" example:"false"` // Whether model limits are active - QuotaLimit int64 `json:"quota_limit,omitempty" example:"-1"` // Token quota limit (-1 = unlimited) - QuotaUsed int64 `json:"quota_used,omitempty" example:"0"` // Token quota used - QuotaResetAt int64 `json:"quota_reset_at,omitempty" example:"0"` // Quota reset timestamp - QuotaResetType string `json:"quota_reset_type,omitempty" example:"monthly"` // Quota reset type - RequestCount int64 `json:"request_count,omitempty" example:"0"` // Total request count (from DB) - UsedTokens int64 `json:"used_tokens,omitempty" example:"0"` // Total tokens used (from DB) - LastAccessedAt int64 `json:"last_accessed_at,omitempty" example:"0"` // Last access timestamp - - // Realtime stats (for master and key types) - Realtime *WhoamiRealtimeView `json:"realtime,omitempty"` + MasterID uint `json:"master_id,omitempty" example:"1"` + MasterName string `json:"master_name,omitempty" example:"tenant-a"` // Parent master name (for display) + Scopes string `json:"scopes,omitempty" example:"chat:write"` + IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"` + IssuedBy string `json:"issued_by,omitempty" example:"master"` } // Whoami godoc // @Summary Get current identity -// @Description Returns complete identity and realtime statistics of the authenticated user. +// @Description Returns identity information of the authenticated user. // @Description Supports Admin Token, Master Key, and Child Key (API Key) authentication. -// @Description This endpoint is designed for frontend initialization - call once after login -// @Description and store the response for subsequent use. // @Description // @Description **Response varies by token type:** // @Description @@ -146,18 +80,14 @@ type WhoamiResponse struct { // @Description // @Description **Master Key:** // @Description - type: "master" -// @Description - Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps +// @Description - Basic info: id, name, group, default_namespace, namespaces, status, epoch, max_child_keys, global_qps // @Description - Timestamps: created_at, updated_at -// @Description - Realtime stats: requests, tokens, qps, qps_limit, rate_limited // @Description // @Description **Child Key (API Key):** // @Description - type: "key" -// @Description - Basic info: id, master_id, master_name, group, scopes, namespaces, status -// @Description - Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at -// @Description - Model limits: model_limits, model_limits_enabled -// @Description - Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type -// @Description - Usage stats: request_count, used_tokens, last_accessed_at -// @Description - Realtime stats: requests, tokens, qps, qps_limit, rate_limited +// @Description - Basic info: id, master_id, master_name, group, scopes, default_namespace, namespaces, status +// @Description - Security: issued_at_epoch, issued_by +// @Description - Timestamps: created_at, updated_at // @Description // @Description **Error responses:** // @Description - 401: authorization header required @@ -173,7 +103,7 @@ type WhoamiResponse struct { // @Security MasterAuth // @Success 200 {object} ResponseEnvelope{data=WhoamiResponse} // @Failure 401 {object} ResponseEnvelope{data=MapData} "Invalid or missing token" -// @Router /auth/whoami [get] +// @Router /users/info [get] func (h *AuthHandler) Whoami(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { @@ -217,28 +147,6 @@ func (h *AuthHandler) Whoami(c *gin.Context) { UpdatedAt: master.UpdatedAt.UTC().Unix(), } - // Get realtime stats for master - if h.statsService != nil { - if stats, err := h.statsService.GetMasterRealtimeSnapshot(c.Request.Context(), master.ID); err == nil { - if stats.QPSLimit == 0 && master.GlobalQPS > 0 { - stats.QPSLimit = int64(master.GlobalQPS) - } - var updatedAt *int64 - if stats.UpdatedAt != nil { - sec := stats.UpdatedAt.Unix() - updatedAt = &sec - } - resp.Realtime = &WhoamiRealtimeView{ - Requests: stats.Requests, - Tokens: stats.Tokens, - QPS: stats.QPS, - QPSLimit: stats.QPSLimit, - RateLimited: stats.RateLimited, - UpdatedAt: updatedAt, - } - } - } - c.JSON(http.StatusOK, resp) return } @@ -330,64 +238,20 @@ func (h *AuthHandler) Whoami(c *gin.Context) { masterName = master.Name } - // Convert nullable time fields - var quotaResetAt int64 - if key.QuotaResetAt != nil { - quotaResetAt = key.QuotaResetAt.UTC().Unix() - } - var lastAccessedAt int64 - if key.LastAccessedAt != nil { - lastAccessedAt = key.LastAccessedAt.UTC().Unix() - } - resp := WhoamiResponse{ - Type: "key", - ID: key.ID, - MasterID: uint(masterID), - MasterName: masterName, - Group: key.Group, - Scopes: strings.TrimSpace(key.Scopes), - DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), - Namespaces: strings.TrimSpace(key.Namespaces), - Status: key.Status, - IssuedAtEpoch: key.IssuedAtEpoch, - IssuedBy: strings.TrimSpace(key.IssuedBy), - AllowIPs: strings.TrimSpace(key.AllowIPs), - DenyIPs: strings.TrimSpace(key.DenyIPs), - ExpiresAt: expiresAt, - ModelLimits: strings.TrimSpace(key.ModelLimits), - ModelLimitsEnabled: key.ModelLimitsEnabled, - QuotaLimit: key.QuotaLimit, - QuotaUsed: key.QuotaUsed, - QuotaResetAt: quotaResetAt, - QuotaResetType: strings.TrimSpace(key.QuotaResetType), - RequestCount: key.RequestCount, - UsedTokens: key.UsedTokens, - LastAccessedAt: lastAccessedAt, - CreatedAt: key.CreatedAt.UTC().Unix(), - UpdatedAt: key.UpdatedAt.UTC().Unix(), - } - - // Get realtime stats for the key's master - if h.statsService != nil { - if stats, err := h.statsService.GetMasterRealtimeSnapshot(c.Request.Context(), uint(masterID)); err == nil { - if stats.QPSLimit == 0 && master.GlobalQPS > 0 { - stats.QPSLimit = int64(master.GlobalQPS) - } - var updatedAt *int64 - if stats.UpdatedAt != nil { - sec := stats.UpdatedAt.Unix() - updatedAt = &sec - } - resp.Realtime = &WhoamiRealtimeView{ - Requests: stats.Requests, - Tokens: stats.Tokens, - QPS: stats.QPS, - QPSLimit: stats.QPSLimit, - RateLimited: stats.RateLimited, - UpdatedAt: updatedAt, - } - } + Type: "key", + ID: key.ID, + MasterID: uint(masterID), + MasterName: masterName, + Group: key.Group, + Scopes: strings.TrimSpace(key.Scopes), + DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), + Namespaces: strings.TrimSpace(key.Namespaces), + Status: key.Status, + IssuedAtEpoch: key.IssuedAtEpoch, + IssuedBy: strings.TrimSpace(key.IssuedBy), + CreatedAt: key.CreatedAt.UTC().Unix(), + UpdatedAt: key.UpdatedAt.UTC().Unix(), } c.JSON(http.StatusOK, resp) diff --git a/internal/api/auth_handler_test.go b/internal/api/auth_handler_test.go index 47c5dc1..93ca2fa 100644 --- a/internal/api/auth_handler_test.go +++ b/internal/api/auth_handler_test.go @@ -97,7 +97,8 @@ func TestAuthHandler_Whoami_InvalidMasterEpoch_Returns401(t *testing.T) { } } -func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) { +// TestAuthHandler_Whoami_KeyResponseFormat tests the key response format per contract. +func TestAuthHandler_Whoami_KeyResponseFormat(t *testing.T) { handler, db, mr := newAuthHandler(t) token := "sk-live-valid" @@ -131,9 +132,9 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) { r := gin.New() r.Use(middleware.ResponseEnvelope()) - r.GET("/auth/whoami", handler.Whoami) + r.GET("/users/info", handler.Whoami) - req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil) + req := httptest.NewRequest(http.MethodGet, "/users/info", nil) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() r.ServeHTTP(rr, req) @@ -150,14 +151,25 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) { if env.Message != "success" { t.Fatalf("expected message=success, got %q", env.Message) } - if resp["allow_ips"] != "1.2.3.4" { - t.Fatalf("expected allow_ips, got %v", resp["allow_ips"]) + // Verify contract-defined fields are present + if resp["type"] != "key" { + t.Fatalf("expected type=key, got %v", resp["type"]) } - if resp["deny_ips"] != "5.6.7.0/24" { - t.Fatalf("expected deny_ips, got %v", resp["deny_ips"]) + if resp["scopes"] != "chat:write" { + t.Fatalf("expected scopes=chat:write, got %v", resp["scopes"]) } - if got, ok := resp["expires_at"].(float64); !ok || int64(got) != expiresAt { - t.Fatalf("expected expires_at=%d, got %v", expiresAt, resp["expires_at"]) + if resp["issued_by"] != "master" { + t.Fatalf("expected issued_by=master, got %v", resp["issued_by"]) + } + // Verify removed fields are not present (per contract) + if _, exists := resp["allow_ips"]; exists { + t.Fatalf("allow_ips should not be in response per contract") + } + if _, exists := resp["deny_ips"]; exists { + t.Fatalf("deny_ips should not be in response per contract") + } + if _, exists := resp["expires_at"]; exists { + t.Fatalf("expires_at should not be in response per contract") } }