refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
@ -2,14 +2,22 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
jwtpkg "github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
// AuthService 认证领域服务
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
workspaceRepo repository.WorkspaceRepository
|
||||
passwordHasher PasswordHasher
|
||||
tokenGenerator TokenGenerator
|
||||
}
|
||||
@ -22,27 +30,48 @@ type PasswordHasher interface {
|
||||
|
||||
// TokenGenerator Token 生成器接口
|
||||
type TokenGenerator interface {
|
||||
Generate(userID, username string) (accessToken, refreshToken string, err error)
|
||||
Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
|
||||
Verify(token string) (userID, username string, err error)
|
||||
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||||
VerifyAccess(token string) (*jwtpkg.Claims, error)
|
||||
VerifyRefresh(token string) (*jwtpkg.Claims, error)
|
||||
Refresh(refreshToken string) (newAccessToken string, err error)
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
workspaceRepo repository.WorkspaceRepository,
|
||||
passwordHasher PasswordHasher,
|
||||
tokenGenerator TokenGenerator,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
workspaceRepo: workspaceRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
tokenGenerator: tokenGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册新用户(仅需用户名和密码,邮箱将自动补全)
|
||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*entity.User, error) {
|
||||
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||||
type UserWorkspaceOptions struct {
|
||||
Namespace string
|
||||
DefaultClusterID string
|
||||
QuotaCPU string
|
||||
QuotaMemory string
|
||||
QuotaGPU string
|
||||
QuotaGPUMem string
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||||
if existingUser != nil {
|
||||
@ -54,6 +83,10 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认生成占位邮箱,避免数据库约束失败
|
||||
email := username + "@local.ocdp"
|
||||
@ -61,6 +94,27 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
// 创建用户
|
||||
user := entity.NewUser(username, passwordHash, email)
|
||||
user.ID = uuid.New().String()
|
||||
user.Role = normalizeUserRole(role)
|
||||
user.WorkspaceID = workspaceID
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
workspace, err := s.createUserWorkspace(ctx, username, principal.UserID, normalizedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.WorkspaceID = workspace.ID
|
||||
}
|
||||
if user.WorkspaceID == "" {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if user.Role == authz.RoleAdmin {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if isActive != nil {
|
||||
user.IsActive = *isActive
|
||||
}
|
||||
if mustChangePassword != nil {
|
||||
user.MustChangePassword = *mustChangePassword
|
||||
}
|
||||
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
@ -73,31 +127,241 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, err error) {
|
||||
// 查找用户
|
||||
user, err := s.userRepo.GetByUsername(ctx, username)
|
||||
func (s *AuthService) createUserWorkspace(ctx context.Context, username, createdBy string, opts UserWorkspaceOptions) (*entity.Workspace, error) {
|
||||
if s.workspaceRepo == nil {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
name := strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||||
workspace := entity.NewWorkspace(name, createdBy)
|
||||
workspace.ID = uuid.New().String()
|
||||
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
namespace := strings.TrimSpace(opts.Namespace)
|
||||
if namespace == "" {
|
||||
namespace = entity.NamespaceForUser(username)
|
||||
}
|
||||
if namespace != "" {
|
||||
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||||
return nil, entity.ErrInvalidNamespace
|
||||
}
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
workspace.QuotaCPU = strings.TrimSpace(opts.QuotaCPU)
|
||||
workspace.QuotaMemory = strings.TrimSpace(opts.QuotaMemory)
|
||||
workspace.QuotaGPU = strings.TrimSpace(opts.QuotaGPU)
|
||||
workspace.QuotaGPUMem = strings.TrimSpace(opts.QuotaGPUMem)
|
||||
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, error) {
|
||||
opts.Namespace = strings.TrimSpace(opts.Namespace)
|
||||
opts.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
opts.QuotaCPU = normalizeStandardQuotaQuantity(opts.QuotaCPU)
|
||||
opts.QuotaMemory = normalizeStandardQuotaQuantity(opts.QuotaMemory)
|
||||
opts.QuotaGPU = normalizeStandardQuotaQuantity(opts.QuotaGPU)
|
||||
gpuMem, err := normalizeGPUMemoryQuota(opts.QuotaGPUMem)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
return opts, err
|
||||
}
|
||||
opts.QuotaGPUMem = gpuMem
|
||||
for _, value := range []string{opts.QuotaCPU, opts.QuotaMemory, opts.QuotaGPU} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := resource.ParseQuantity(value); err != nil {
|
||||
return opts, entity.ErrInvalidTenantResourceQuota
|
||||
}
|
||||
}
|
||||
if opts.Namespace != "" && len(validation.IsDNS1123Label(opts.Namespace)) > 0 {
|
||||
return opts, entity.ErrInvalidNamespace
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ListUsers(ctx context.Context) ([]*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
return s.userRepo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if role != "" {
|
||||
user.Role = normalizeUserRole(role)
|
||||
}
|
||||
if workspaceID != "" {
|
||||
user.WorkspaceID = workspaceID
|
||||
}
|
||||
if user.Role == authz.RoleAdmin {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace, err := s.createUserWorkspace(ctx, user.Username, principal.UserID, normalizedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.WorkspaceID = workspace.ID
|
||||
}
|
||||
if isActive != nil {
|
||||
if user.ID == principal.UserID && !*isActive {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
user.IsActive = *isActive
|
||||
}
|
||||
if mustChangePassword != nil {
|
||||
user.MustChangePassword = *mustChangePassword
|
||||
}
|
||||
if user.Role != authz.RoleAdmin && hasWorkspaceUpdates(opts) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyWorkspaceOptions(workspace, normalizedOpts)
|
||||
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user.RevokedAfter = time.Now()
|
||||
user.UpdatedAt = time.Now()
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func hasWorkspaceUpdates(opts UserWorkspaceOptions) bool {
|
||||
return strings.TrimSpace(opts.Namespace) != "" ||
|
||||
strings.TrimSpace(opts.DefaultClusterID) != "" ||
|
||||
strings.TrimSpace(opts.QuotaCPU) != "" ||
|
||||
strings.TrimSpace(opts.QuotaMemory) != "" ||
|
||||
strings.TrimSpace(opts.QuotaGPU) != "" ||
|
||||
strings.TrimSpace(opts.QuotaGPUMem) != ""
|
||||
}
|
||||
|
||||
func applyWorkspaceOptions(workspace *entity.Workspace, opts UserWorkspaceOptions) {
|
||||
if namespace := strings.TrimSpace(opts.Namespace); namespace != "" {
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
if value := strings.TrimSpace(opts.DefaultClusterID); value != "" {
|
||||
workspace.DefaultClusterID = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaCPU); value != "" {
|
||||
workspace.QuotaCPU = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaMemory); value != "" {
|
||||
workspace.QuotaMemory = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaGPU); value != "" {
|
||||
workspace.QuotaGPU = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaGPUMem); value != "" {
|
||||
workspace.QuotaGPUMem = value
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
if userID == principal.UserID {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
return s.userRepo.Delete(ctx, userID)
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
if role == authz.RoleAdmin {
|
||||
return authz.RoleAdmin
|
||||
}
|
||||
return authz.RoleUser
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, user *entity.User, err error) {
|
||||
// 查找用户
|
||||
user, err = s.userRepo.GetByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", "", nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", "", nil, entity.ErrUserInactive
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||||
return "", "", entity.ErrInvalidPassword
|
||||
return "", "", nil, entity.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
|
||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
return accessToken, refreshToken, user, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
|
||||
return s.tokenGenerator.Refresh(refreshToken)
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, *entity.User, error) {
|
||||
claims, err := s.tokenGenerator.VerifyRefresh(refreshToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return "", nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", nil, entity.ErrUserInactive
|
||||
}
|
||||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||
return "", nil, entity.ErrTokenRevoked
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
accessToken, _, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return accessToken, user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
@ -106,25 +370,84 @@ func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User,
|
||||
}
|
||||
|
||||
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
|
||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (*authz.Principal, error) {
|
||||
// 1. JWT 自验证
|
||||
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
|
||||
claims, err := s.tokenGenerator.VerifyAccess(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 检查用户级别的撤销时间
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return nil, entity.ErrUserInactive
|
||||
}
|
||||
|
||||
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||||
if issuedAt < user.RevokedAfter.Unix() {
|
||||
return "", "", entity.ErrTokenRevoked
|
||||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||
return nil, entity.ErrTokenRevoked
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceName := ""
|
||||
namespace := ""
|
||||
defaultClusterID := ""
|
||||
quotaCPU := ""
|
||||
quotaMemory := ""
|
||||
quotaGPU := ""
|
||||
quotaGPUMem := ""
|
||||
if s.workspaceRepo != nil && user.WorkspaceID != "" {
|
||||
if workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID); err == nil && workspace != nil {
|
||||
workspaceName = workspace.Name
|
||||
namespace = workspace.K8sNamespace
|
||||
defaultClusterID = workspace.DefaultClusterID
|
||||
quotaCPU = workspace.QuotaCPU
|
||||
quotaMemory = workspace.QuotaMemory
|
||||
quotaGPU = workspace.QuotaGPU
|
||||
quotaGPUMem = workspace.QuotaGPUMem
|
||||
}
|
||||
}
|
||||
|
||||
return userID, username, nil
|
||||
return &authz.Principal{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName,
|
||||
Namespace: namespace,
|
||||
DefaultClusterID: defaultClusterID,
|
||||
QuotaCPU: quotaCPU,
|
||||
QuotaMemory: quotaMemory,
|
||||
QuotaGPU: quotaGPU,
|
||||
QuotaGPUMem: quotaGPUMem,
|
||||
Permissions: authz.PermissionsForRole(user.Role),
|
||||
PermissionVersion: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetWorkspaceByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||
if s.workspaceRepo == nil || id == "" {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
return s.workspaceRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureWorkspaceActive(ctx context.Context, user *entity.User) error {
|
||||
if user.Role == authz.RoleAdmin || user.WorkspaceID == "" || s.workspaceRepo == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if workspace.Status == entity.WorkspaceSuspended {
|
||||
return entity.ErrWorkspaceSuspended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(会触发全局登出)
|
||||
|
||||
Reference in New Issue
Block a user