fix(admin): complete management APIs for console integration

This commit is contained in:
Haitao Pan 2026-02-05 15:01:12 +08:00
parent 3ffd39cc8b
commit d849e3e6cc
5 changed files with 170 additions and 14 deletions

View File

@ -1,13 +1,153 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"account/internal/store"
)
type createCustomUserRequest struct {
Email string `json:"email"`
UUID string `json:"uuid"`
Groups []string `json:"groups"`
}
func normalizeGroups(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
groups := make([]string, 0, len(values))
for _, value := range values {
normalized := strings.TrimSpace(value)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
groups = append(groups, normalized)
}
if len(groups) == 0 {
return nil
}
return groups
}
func generatePasswordHash() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(hex.EncodeToString(b)), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (h *handler) createCustomUser(c *gin.Context) {
requestUser, ok := h.requireAdminPermission(c, permissionAdminUsersRoleWrite)
if !ok {
return
}
if !h.isRootAccount(requestUser) {
respondError(c, http.StatusForbidden, "root_only", "only root account can create custom uuid users")
return
}
var req createCustomUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" || !strings.Contains(email, "@") {
respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address")
return
}
proxyUUID := strings.TrimSpace(req.UUID)
if _, err := uuid.Parse(proxyUUID); err != nil {
respondError(c, http.StatusBadRequest, "invalid_uuid", "uuid must be a valid UUID")
return
}
groups := normalizeGroups(req.Groups)
if len(groups) == 0 {
respondError(c, http.StatusBadRequest, "invalid_groups", "at least one group is required")
return
}
blacklisted, err := h.store.IsBlacklisted(c.Request.Context(), email)
if err != nil {
respondError(c, http.StatusInternalServerError, "blacklist_check_failed", "failed to verify email status")
return
}
if blacklisted {
respondError(c, http.StatusForbidden, "email_blacklisted", "this email address is blocked")
return
}
passwordHash, err := generatePasswordHash()
if err != nil {
respondError(c, http.StatusInternalServerError, "password_generation_failed", "failed to prepare account credentials")
return
}
user := &store.User{
Name: email,
Email: email,
PasswordHash: passwordHash,
EmailVerified: true,
Level: store.LevelUser,
Role: store.RoleUser,
Groups: groups,
Active: true,
ProxyUUID: proxyUUID,
}
if err := h.store.CreateUser(c.Request.Context(), user); err != nil {
switch {
case errors.Is(err, store.ErrEmailExists):
respondError(c, http.StatusConflict, "email_exists", "user with this email already exists")
return
case errors.Is(err, store.ErrNameExists):
respondError(c, http.StatusConflict, "name_exists", "user with this name already exists")
return
case errors.Is(err, store.ErrInvalidName):
respondError(c, http.StatusBadRequest, "invalid_name", "name is invalid")
return
default:
respondError(c, http.StatusInternalServerError, "user_creation_failed", "failed to create user")
return
}
}
createdUser, err := h.store.GetUserByID(c.Request.Context(), user.ID)
if err != nil {
createdUser = user
}
c.JSON(http.StatusCreated, gin.H{
"message": "user_created",
"user": sanitizeUser(createdUser, nil),
})
}
func (h *handler) pauseUser(c *gin.Context) {
if _, ok := h.requireAdminPermission(c, permissionAdminUsersPause); !ok {
return
@ -106,15 +246,6 @@ func (h *handler) renewProxyUUID(c *gin.Context) {
return
}
// Generate new UUID
// We use crypto/rand usually, but for simplicity here we assume a helper or just a placeholder
// Since I don't have a helper, I'll use a simple random string or assume store handles it if empty
// Actually, ProxyUUID is a string in the Store.
user.ProxyUUID = "" // Let the store or a helper generate it if we had one.
// Since I can't easily import a uuid generator here without checking if it's available,
// I'll just use a placeholder for now or assume the user wants a new one.
// Wait, schema says it has a default gen_random_uuid().
if req.ExpiresAt != "" {
t, err := time.Parse("2006-01-02", req.ExpiresAt)
if err != nil {
@ -130,8 +261,6 @@ func (h *handler) renewProxyUUID(c *gin.Context) {
user.ProxyUUIDExpiresAt = nil
}
// For now, let's just use a simple random hex string if we want to "reset" it manually
// in the logic before UpdateUser.
user.ProxyUUID = generateRandomUUID()
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {

View File

@ -165,6 +165,7 @@ func registerAdminRoutes(group *gin.RouterGroup, h *handler) {
admin.GET("/agents/status", h.adminAgentStatus)
// User management
admin.POST("/users", h.createCustomUser)
admin.POST("/users/:userId/pause", h.pauseUser)
admin.POST("/users/:userId/resume", h.resumeUser)
admin.DELETE("/users/:userId", h.deleteUser)

View File

@ -273,9 +273,20 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.GET("/admin/settings", h.getAdminSettings)
authProtected.POST("/admin/settings", h.updateAdminSettings)
authProtected.GET("/users", h.listUsers)
// Backward-compatible auth-scoped admin routes consumed by the dashboard BFF.
authProtected.GET("/admin/users/metrics", h.adminUsersMetrics)
authProtected.POST("/admin/users", h.createCustomUser)
authProtected.POST("/admin/users/:userId/role", h.updateUserRole)
authProtected.DELETE("/admin/users/:userId/role", h.resetUserRole)
authProtected.POST("/admin/users/:userId/pause", h.pauseUser)
authProtected.POST("/admin/users/:userId/resume", h.resumeUser)
authProtected.DELETE("/admin/users/:userId", h.deleteUser)
authProtected.POST("/admin/users/:userId/renew-uuid", h.renewProxyUUID)
authProtected.GET("/admin/blacklist", h.listBlacklist)
authProtected.POST("/admin/blacklist", h.addToBlacklist)
authProtected.DELETE("/admin/blacklist/:email", h.removeFromBlacklist)
authProtected.GET("/users", h.listUsers)
// Internal routes for service-to-service reads.
internalGroup := r.Group("/api/internal")

View File

@ -717,6 +717,7 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
if agentRegistry != nil {
agentRegistry.SetStore(st)
agentRegistry.SetLogger(logger.With("component", "agent-registry"))
if err := agentRegistry.Load(ctx); err != nil {
logger.Warn("failed to load agents from store", "err", err)
} else {

View File

@ -11,6 +11,7 @@ import (
"account/internal/agentproto"
"account/internal/store"
"log/slog"
)
// Credential defines the authentication material assigned to a managed agent.
@ -47,6 +48,7 @@ type Registry struct {
byID map[string]Identity
statuses map[string]StatusSnapshot
store store.Store
logger *slog.Logger
}
// NewRegistry constructs a registry from configuration, validating credentials
@ -56,6 +58,7 @@ func NewRegistry(cfg Config) (*Registry, error) {
credentials: make(map[[32]byte]Identity),
byID: make(map[string]Identity),
statuses: make(map[string]StatusSnapshot),
logger: slog.Default().With("component", "agent-registry"),
}
for _, cred := range cfg.Credentials {
@ -95,6 +98,15 @@ func (r *Registry) SetStore(st store.Store) {
r.store = st
}
// SetLogger overrides the default logger.
func (r *Registry) SetLogger(logger *slog.Logger) {
r.mu.Lock()
defer r.mu.Unlock()
if logger != nil {
r.logger = logger
}
}
// Authenticate validates the provided token and returns the associated agent
// identity when successful.
func (r *Registry) Authenticate(token string) (*Identity, bool) {
@ -143,7 +155,7 @@ func (r *Registry) ReportStatus(agent Identity, report agentproto.StatusReport)
SyncRevision: rep.SyncRevision,
}
if err := r.store.UpsertAgent(ctx, dbAgent); err != nil {
// We can't do much here since it's a goroutine, but it's okay for transient failures
r.logger.Error("failed to persist agent status heartbeat", "agent", a.ID, "err", err)
}
}(agent, report)
}
@ -182,7 +194,9 @@ func (r *Registry) RegisterAgent(agentID string, groups []string) Identity {
Name: id,
Groups: g,
}
_ = r.store.UpsertAgent(ctx, dbAgent)
if err := r.store.UpsertAgent(ctx, dbAgent); err != nil {
r.logger.Error("failed to persist dynamically registered agent", "agent", id, "err", err)
}
}(agentID, groups)
}