WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
versionKnowledge KnowledgeVersionAdapter
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
|
||||
func administrator(ctx context.Context) bool {
|
||||
return types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin)
|
||||
}
|
||||
func identity(ctx context.Context) (uint64, string) {
|
||||
t, _ := types.TenantIDFromContext(ctx)
|
||||
u, _ := types.UserIDFromContext(ctx)
|
||||
return t, u
|
||||
}
|
||||
func data(c *gin.Context, v any) { c.JSON(200, gin.H{"success": true, "data": v}) }
|
||||
func fail(c *gin.Context, status int, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"success": false, "message": message})
|
||||
}
|
||||
func dbResult(c *gin.Context, err error, v any) {
|
||||
if err == nil {
|
||||
data(c, v)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fail(c, 404, "记录不存在或不属于当前空间")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errInvalid) {
|
||||
fail(c, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errConflict) {
|
||||
fail(c, 409, err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, 500, "访问控制操作失败,请检查系统日志")
|
||||
}
|
||||
|
||||
var errInvalid = errors.New("请求参数无效")
|
||||
var errConflict = errors.New("记录已更改或仍被使用,请刷新后重试")
|
||||
|
||||
func (s *Service) Register(r *gin.RouterGroup) {
|
||||
r.GET("/access/me", s.Me)
|
||||
a := r.Group("/access", func(c *gin.Context) {
|
||||
t, u := identity(c.Request.Context())
|
||||
if t == 0 || u == "" || types.IsSyntheticUserID(u) || !administrator(c.Request.Context()) {
|
||||
fail(c, 403, "需要当前空间管理员权限")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
a.GET("/catalog", func(c *gin.Context) { data(c, Catalog) })
|
||||
a.GET("/roles", s.ListRoles)
|
||||
a.POST("/roles", s.SaveRole)
|
||||
a.PUT("/roles/:id", s.SaveRole)
|
||||
a.DELETE("/roles/:id", s.DeleteRole)
|
||||
a.GET("/users", s.ListUsers)
|
||||
a.POST("/users", s.CreateUser)
|
||||
a.PUT("/users/:id", s.UpdateUser)
|
||||
a.DELETE("/users/:id", s.DeleteUser)
|
||||
a.GET("/resources", s.ListResources)
|
||||
a.GET("/grants", s.ListGrants)
|
||||
a.POST("/grants", s.UpdateGrants)
|
||||
}
|
||||
|
||||
func (s *Service) Snapshot(ctx context.Context) (*accesspolicy.Snapshot, error) {
|
||||
t, u := identity(ctx)
|
||||
v := &accesspolicy.Snapshot{Permissions: map[string]bool{}, Publications: map[string]bool{}, Grants: map[string]bool{}}
|
||||
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
|
||||
return v, nil
|
||||
}
|
||||
if !administrator(ctx) {
|
||||
var assignment Assignment
|
||||
r := s.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", t, u).Limit(1).Find(&assignment)
|
||||
if r.Error != nil {
|
||||
return nil, r.Error
|
||||
}
|
||||
if r.RowsAffected > 0 {
|
||||
v.Restricted = true
|
||||
var role Role
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", assignment.RoleID, t).First(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range effectiveRolePermissions(role) {
|
||||
v.Permissions[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var publications []Publication
|
||||
if err := s.db.WithContext(ctx).Where("tenant_id = ?", t).Find(&publications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range publications {
|
||||
v.Publications[p.Kind+":"+p.ResourceID] = p.Global
|
||||
}
|
||||
var grants []Grant
|
||||
if err := s.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", t, u).Find(&grants).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, g := range grants {
|
||||
v.Grants[g.Kind+":"+g.ResourceID] = true
|
||||
}
|
||||
if err := s.populatePersonalAccess(ctx, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var humans []struct{ ID, PrimaryAgentID string }
|
||||
if err := s.db.WithContext(ctx).Table("digital_humans").Select("id,primary_agent_id").Where("tenant_id=? AND deleted_at IS NULL", t).Scan(&humans).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range humans {
|
||||
if h.PrimaryAgentID == "" {
|
||||
continue
|
||||
}
|
||||
global, managed := v.Publications["digital-humans:"+h.ID]
|
||||
if managed && global {
|
||||
v.Grants["agents:"+h.PrimaryAgentID] = true
|
||||
}
|
||||
if v.Grants["digital-humans:"+h.ID] {
|
||||
v.Grants["agents:"+h.PrimaryAgentID] = true
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Service) Me(c *gin.Context) {
|
||||
v, err := s.Snapshot(c.Request.Context())
|
||||
if err != nil {
|
||||
dbResult(c, err, nil)
|
||||
return
|
||||
}
|
||||
data(c, gin.H{"permissions": visiblePermissions(c.Request.Context(), v), "restricted": v.Restricted, "administrator": administrator(c.Request.Context())})
|
||||
}
|
||||
|
||||
func visiblePermissions(ctx context.Context, snapshot *accesspolicy.Snapshot) []string {
|
||||
permissions := []string{}
|
||||
admin := administrator(ctx)
|
||||
contributor := admin || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleContributor)
|
||||
for _, m := range Catalog {
|
||||
for _, p := range m.Permissions {
|
||||
if snapshot.Restricted && !snapshot.Permissions[p.ID] {
|
||||
continue
|
||||
}
|
||||
if m.AdminOnly && !admin {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(p.ID, ".read") && p.ID != "content.deliver" && m.ID != "chat" && !contributor {
|
||||
continue
|
||||
}
|
||||
permissions = append(permissions, p.ID)
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
func (s *Service) audit(tx *gorm.DB, ctx context.Context, action, target string, details map[string]any) error {
|
||||
t, u := identity(ctx)
|
||||
body, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&types.AuditLog{TenantID: t, ActorUserID: u, ActorRole: string(types.TenantRoleFromContext(ctx)), Action: types.AuditAction(action), TargetType: "access", TargetID: target, Outcome: types.AuditOutcomeSuccess, Details: types.JSON(body), CreatedAt: time.Now()}).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListRoles(c *gin.Context) {
|
||||
t, _ := identity(c.Request.Context())
|
||||
rows := []Role{}
|
||||
err := s.db.Where("tenant_id = ?", t).Order("name,id").Find(&rows).Error
|
||||
for i := range rows {
|
||||
rows[i].Permissions = effectiveRolePermissions(rows[i])
|
||||
}
|
||||
dbResult(c, err, rows)
|
||||
}
|
||||
func (s *Service) SaveRole(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Name) == "" || len(req.Name) > 100 || len(req.Description) > 2000 || !validPermissions(req.Permissions) {
|
||||
fail(c, 400, "请填写角色名称,操作权限必须同时包含查看权限")
|
||||
return
|
||||
}
|
||||
t, _ := identity(c.Request.Context())
|
||||
id := c.Param("id")
|
||||
now := time.Now()
|
||||
role := Role{ID: id, TenantID: t, Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), Permissions: req.Permissions, PermissionVersion: currentPermissionVersion, Version: req.Version + 1, UpdatedAt: now}
|
||||
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&Role{}).Where("tenant_id = ? AND name = ? AND id <> ?", t, role.Name, id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%w: 角色名称已存在", errConflict)
|
||||
}
|
||||
if id == "" {
|
||||
role.ID = uuid.NewString()
|
||||
role.Version = 1
|
||||
role.CreatedAt = now
|
||||
if err := tx.Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
q := tx.Model(&Role{}).Where("id = ? AND tenant_id = ? AND version = ?", id, t, req.Version).Updates(map[string]any{"name": role.Name, "description": role.Description, "permissions": role.Permissions, "permission_version": currentPermissionVersion, "version": role.Version, "updated_at": now})
|
||||
if q.Error != nil {
|
||||
return q.Error
|
||||
}
|
||||
if q.RowsAffected != 1 {
|
||||
return errConflict
|
||||
}
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.role_saved", role.ID, map[string]any{"name": role.Name, "permissions": req.Permissions})
|
||||
})
|
||||
dbResult(c, err, role)
|
||||
}
|
||||
func (s *Service) DeleteRole(c *gin.Context) {
|
||||
t, _ := identity(c.Request.Context())
|
||||
id := c.Param("id")
|
||||
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var role Role
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, t).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&Assignment{}).Where("role_id = ? AND tenant_id = ?", id, t).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%w: 请先更换使用该角色的用户", errConflict)
|
||||
}
|
||||
if err := tx.Delete(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.role_deleted", id, map[string]any{"name": role.Name})
|
||||
})
|
||||
dbResult(c, err, true)
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CustomRoleID string `json:"custom_role_id"`
|
||||
IsSystemAdmin bool `json:"is_system_admin"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(c *gin.Context) {
|
||||
t, _ := identity(c.Request.Context())
|
||||
rows := []UserView{}
|
||||
err := repository.TenantUsersQuery(s.db.WithContext(c.Request.Context()), t).
|
||||
Select("u.id,u.username,u.email,u.is_system_admin,u.is_active,m.role,m.status,COALESCE(a.role_id,'') AS custom_role_id").
|
||||
Joins("LEFT JOIN workbench_access_assignments a ON a.user_id=u.id AND a.tenant_id=m.tenant_id").
|
||||
Order("u.username,u.id").Scan(&rows).Error
|
||||
dbResult(c, err, rows)
|
||||
}
|
||||
|
||||
type userRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
CustomRoleID string `json:"custom_role_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func assign(tx *gorm.DB, t uint64, u, id string) error {
|
||||
if id == "" {
|
||||
return tx.Where("tenant_id = ? AND user_id = ?", t, u).Delete(&Assignment{}).Error
|
||||
}
|
||||
var role Role
|
||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id = ? AND id = ?", t, id).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role_id"})}).Create(&Assignment{TenantID: t, UserID: u, RoleID: id}).Error
|
||||
}
|
||||
func validUserRequest(req userRequest, create bool) bool {
|
||||
if req.Role != "contributor" && req.Role != "viewer" {
|
||||
return false
|
||||
}
|
||||
if req.Status != "active" && req.Status != "suspended" {
|
||||
return false
|
||||
}
|
||||
if req.Password != "" && secutils.ValidatePasswordPolicy(req.Password, false) != nil {
|
||||
return false
|
||||
}
|
||||
if req.Email != "" {
|
||||
address, err := mail.ParseAddress(req.Email)
|
||||
if err != nil || address.Address != req.Email || len(req.Email) > 255 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(req.Username) > 100 || (req.Username != "" && strings.TrimSpace(req.Username) == "") {
|
||||
return false
|
||||
}
|
||||
if create {
|
||||
address, err := mail.ParseAddress(req.Email)
|
||||
return err == nil && address.Address == req.Email && len(req.Email) <= 255 && strings.TrimSpace(req.Username) != "" && len(req.Username) <= 100 && req.Password != ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Service) CreateUser(c *gin.Context) {
|
||||
var req userRequest
|
||||
if c.ShouldBindJSON(&req) != nil || !validUserRequest(req, true) {
|
||||
fail(c, 400, "请填写用户名、有效邮箱、3 至 32 个字符的密码(最多 72 字节)及普通用户角色")
|
||||
return
|
||||
}
|
||||
t, _ := identity(c.Request.Context())
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
dbResult(c, err, nil)
|
||||
return
|
||||
}
|
||||
u := types.User{ID: uuid.NewString(), TenantID: t, Username: strings.TrimSpace(req.Username), Email: strings.ToLower(strings.TrimSpace(req.Email)), PasswordHash: string(hash), IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
err = s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if e := tx.Unscoped().Model(&types.User{}).Where("LOWER(email)=? OR username=?", u.Email, u.Username).Count(&count).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%w: 用户名或邮箱已被使用", errConflict)
|
||||
}
|
||||
if e := tx.Create(&u).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := tx.Create(&types.TenantMember{TenantID: t, UserID: u.ID, Role: types.TenantRole(req.Role), Status: types.TenantMemberStatus(req.Status), JoinedAt: time.Now()}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := assign(tx, t, u.ID, req.CustomRoleID); e != nil {
|
||||
return e
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.user_created", u.ID, map[string]any{"username": u.Username, "role": req.Role})
|
||||
})
|
||||
dbResult(c, err, gin.H{"id": u.ID})
|
||||
}
|
||||
func manageableUser(tx *gorm.DB, t uint64, actor, id string) (*types.User, *types.TenantMember, error) {
|
||||
if id == actor {
|
||||
return nil, nil, fmt.Errorf("%w: 不能修改或删除当前登录账号", errInvalid)
|
||||
}
|
||||
var u types.User
|
||||
var m types.TenantMember
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, "id = ?", id).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ? AND tenant_id = ?", id, t).First(&m).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if u.IsSystemAdmin || u.CanAccessAllTenants || m.Role.HasPermission(types.TenantRoleAdmin) {
|
||||
return nil, nil, fmt.Errorf("%w: 管理员账号不能在普通用户管理中修改", errInvalid)
|
||||
}
|
||||
return &u, &m, nil
|
||||
}
|
||||
func (s *Service) UpdateUser(c *gin.Context) {
|
||||
var req userRequest
|
||||
if c.ShouldBindJSON(&req) != nil || !validUserRequest(req, false) {
|
||||
fail(c, 400, "请选择有效角色和状态;新密码为 3 至 32 个字符(最多 72 字节)")
|
||||
return
|
||||
}
|
||||
t, actor := identity(c.Request.Context())
|
||||
id := c.Param("id")
|
||||
var hash []byte
|
||||
var err error
|
||||
if req.Password != "" {
|
||||
hash, err = bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
dbResult(c, err, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
u, m, e := manageableUser(tx, t, actor, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
changes := map[string]any{}
|
||||
if req.Username != "" && strings.TrimSpace(req.Username) != u.Username {
|
||||
changes["username"] = strings.TrimSpace(req.Username)
|
||||
}
|
||||
if req.Email != "" && strings.ToLower(strings.TrimSpace(req.Email)) != u.Email {
|
||||
changes["email"] = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
}
|
||||
if len(hash) > 0 {
|
||||
changes["password_hash"] = string(hash)
|
||||
}
|
||||
if req.Status == "active" && !u.IsActive {
|
||||
if len(hash) == 0 {
|
||||
return fmt.Errorf("%w: 启用迁移或停用账号时请同时设置新密码", errInvalid)
|
||||
}
|
||||
changes["is_active"] = true
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
if u.TenantID != t {
|
||||
return fmt.Errorf("%w: 只能修改本空间创建账号的登录信息", errInvalid)
|
||||
}
|
||||
username, email := u.Username, u.Email
|
||||
if v, ok := changes["username"].(string); ok {
|
||||
username = v
|
||||
}
|
||||
if v, ok := changes["email"].(string); ok {
|
||||
email = v
|
||||
}
|
||||
var count int64
|
||||
if e = tx.Unscoped().Model(&types.User{}).Where("id <> ? AND (LOWER(email) = ? OR username = ?)", id, strings.ToLower(email), username).Count(&count).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%w: 用户名或邮箱已被使用", errConflict)
|
||||
}
|
||||
if e = tx.Model(u).Updates(changes).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if e = tx.Model(m).Updates(map[string]any{"role": req.Role, "status": req.Status}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e = assign(tx, t, id, req.CustomRoleID); e != nil {
|
||||
return e
|
||||
}
|
||||
if len(changes) > 0 || req.Status == "suspended" {
|
||||
if e = tx.Model(&types.AuthToken{}).Where("user_id = ?", id).Update("is_revoked", true).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.user_updated", id, map[string]any{"role": req.Role, "status": req.Status, "custom_role_id": req.CustomRoleID, "password_reset": len(hash) > 0})
|
||||
})
|
||||
dbResult(c, err, true)
|
||||
}
|
||||
func (s *Service) DeleteUser(c *gin.Context) {
|
||||
t, actor := identity(c.Request.Context())
|
||||
id := c.Param("id")
|
||||
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
u, _, e := manageableUser(tx, t, actor, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if u.TenantID != t {
|
||||
return fmt.Errorf("%w: 不能删除属于其他空间的账号", errInvalid)
|
||||
}
|
||||
var count int64
|
||||
if e = tx.Model(&types.TenantMember{}).Where("user_id = ? AND tenant_id <> ?", id, t).Count(&count).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%w: 此用户仍属于其他空间,不能删除整个账号", errConflict)
|
||||
}
|
||||
if e = tx.Model(&types.AuthToken{}).Where("user_id = ?", id).Update("is_revoked", true).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&Assignment{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&Grant{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&types.TenantMember{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Delete(u).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.user_deleted", id, map[string]any{"username": u.Username})
|
||||
})
|
||||
dbResult(c, err, true)
|
||||
}
|
||||
|
||||
// Native owners keep their edit rights. Grants provide read/use, never ownership.
|
||||
type ResourceView struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Global bool `json:"global"`
|
||||
}
|
||||
type resourceTable struct{ table, owner, where string }
|
||||
|
||||
var resourceTables = map[string]resourceTable{
|
||||
"agents": {"custom_agents", "created_by", "deleted_at IS NULL"},
|
||||
"skills": {"tenant_skill_catalog", "created_by", "instruction_only = true AND deleted_at IS NULL"},
|
||||
"knowledge": {"knowledge_bases", "creator_id", "deleted_at IS NULL AND is_temporary = false"},
|
||||
"digital-humans": {"digital_humans", "owner_id", "deleted_at IS NULL"},
|
||||
"flows": {"workbench_resources", "owner_id", "kind = 'flows'"},
|
||||
}
|
||||
|
||||
func resources(tx *gorm.DB, t uint64) ([]ResourceView, error) {
|
||||
rows := []ResourceView{}
|
||||
for kind, spec := range resourceTables {
|
||||
visibility := "false AS global"
|
||||
if kind == "knowledge" {
|
||||
visibility = "true AS global"
|
||||
}
|
||||
if kind == "agents" || kind == "skills" {
|
||||
visibility = "(visibility = 'global' AND enabled = true) AS global"
|
||||
}
|
||||
part := []ResourceView{}
|
||||
q := tx.Table(spec.table).Select("id,name,"+spec.owner+" AS owner_id,"+visibility).Where("tenant_id = ?", t).Where(spec.where)
|
||||
if err := q.Order("name,id").Scan(&part).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range part {
|
||||
part[i].Kind = kind
|
||||
}
|
||||
rows = append(rows, part...)
|
||||
}
|
||||
var users []types.User
|
||||
if err := tx.Select("id,username").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, u := range users {
|
||||
names[u.ID] = u.Username
|
||||
}
|
||||
var pubs []Publication
|
||||
if err := tx.Where("tenant_id = ?", t).Find(&pubs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
published := map[string]bool{}
|
||||
for _, p := range pubs {
|
||||
published[p.Kind+":"+p.ResourceID] = p.Global
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].OwnerName = names[rows[i].OwnerID]
|
||||
if global, managed := published[rows[i].Kind+":"+rows[i].ID]; managed {
|
||||
rows[i].Global = global
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func (s *Service) ListResources(c *gin.Context) {
|
||||
t, _ := identity(c.Request.Context())
|
||||
rows, err := resources(s.db.WithContext(c.Request.Context()), t)
|
||||
dbResult(c, err, rows)
|
||||
}
|
||||
func (s *Service) ListGrants(c *gin.Context) {
|
||||
t, _ := identity(c.Request.Context())
|
||||
rows := []Grant{}
|
||||
err := s.db.Where("tenant_id = ?", t).Find(&rows).Error
|
||||
dbResult(c, err, rows)
|
||||
}
|
||||
func (s *Service) UpdateGrants(c *gin.Context) {
|
||||
var req struct {
|
||||
Resources []struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
} `json:"resources"`
|
||||
UserIDs []string `json:"user_ids"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || len(req.Resources) == 0 || len(req.Resources) > 500 || len(req.UserIDs) > 200 {
|
||||
fail(c, 400, "请选择 1 至 500 个资源及不超过 200 个用户")
|
||||
return
|
||||
}
|
||||
if req.Action != "grant" && req.Action != "revoke" && req.Action != "publish" && req.Action != "unpublish" {
|
||||
fail(c, 400, "未知授权操作")
|
||||
return
|
||||
}
|
||||
if (req.Action == "grant" || req.Action == "revoke") && len(req.UserIDs) == 0 {
|
||||
fail(c, 400, "请选择授权用户")
|
||||
return
|
||||
}
|
||||
t, _ := identity(c.Request.Context())
|
||||
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
for _, id := range req.UserIDs {
|
||||
var count int64
|
||||
if e := tx.Table("users u").Joins("JOIN tenant_members m ON m.user_id=u.id AND m.tenant_id=? AND m.deleted_at IS NULL", t).Where("u.id=? AND u.deleted_at IS NULL AND u.is_system_admin=false AND u.can_access_all_tenants=false AND m.role IN ?", id, []string{"viewer", "contributor"}).Count(&count).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%w: 授权用户不属于本空间或为管理员", errInvalid)
|
||||
}
|
||||
}
|
||||
for _, r := range req.Resources {
|
||||
spec, ok := resourceTables[r.Kind]
|
||||
if !ok {
|
||||
return errInvalid
|
||||
}
|
||||
var row struct{ ID string }
|
||||
if e := tx.Table(spec.table).Select("id").Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id=? AND id=?", t, r.ID).Where(spec.where).Take(&row).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if req.Action == "publish" || req.Action == "unpublish" {
|
||||
p := Publication{TenantID: t, Kind: r.Kind, ResourceID: r.ID, Global: req.Action == "publish", UpdatedAt: time.Now()}
|
||||
if e := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "kind"}, {Name: "resource_id"}}, DoUpdates: clause.AssignmentColumns([]string{"global", "updated_at"})}).Create(&p).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
} else {
|
||||
// First explicit grant opts an asset into per-user authorization.
|
||||
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Publication{TenantID: t, Kind: r.Kind, ResourceID: r.ID}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
for _, id := range req.UserIDs {
|
||||
if req.Action == "grant" {
|
||||
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Grant{TenantID: t, Kind: r.Kind, ResourceID: r.ID, UserID: id, UpdatedAt: time.Now()}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
} else {
|
||||
if e := tx.Where("tenant_id=? AND kind=? AND resource_id=? AND user_id=?", t, r.Kind, r.ID, id).Delete(&Grant{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.audit(tx, c.Request.Context(), "access.resources_"+req.Action, "", map[string]any{"resources": req.Resources, "user_ids": req.UserIDs})
|
||||
})
|
||||
dbResult(c, err, true)
|
||||
}
|
||||
Reference in New Issue
Block a user