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