Files
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

632 lines
22 KiB
Go

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