Add roles CRUD backed by system_roles tables, session list/revoke APIs, capabilities fallback for memory mode, and route all admin checks through AuthorizationService instead of hardcoded role-admin checks. Co-authored-by: Cursor <cursoragent@cursor.com>
292 lines
8.8 KiB
Go
292 lines
8.8 KiB
Go
package authz
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var (
|
|
ErrRoleNotFound = errors.New("authz: role not found")
|
|
ErrRoleIsSystem = errors.New("authz: system role cannot be deleted")
|
|
ErrRoleInUse = errors.New("authz: role still assigned to users")
|
|
ErrRoleInvalid = errors.New("authz: invalid role payload")
|
|
ErrRolesNotAvailable = errors.New("authz: roles API requires database IAM")
|
|
)
|
|
|
|
// SystemRole 系统角色与权限矩阵视图。
|
|
type SystemRole struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
IsSystem bool `json:"is_system"`
|
|
Status string `json:"status"`
|
|
Permissions []string `json:"permissions"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// CreateRoleInput 新建自定义角色。
|
|
type CreateRoleInput struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
// UpdateRoleInput 更新角色元数据与权限矩阵。
|
|
type UpdateRoleInput struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
func defaultSystemRoles() []SystemRole {
|
|
now := "2026-09-08 00:00:00"
|
|
return []SystemRole{
|
|
{ID: "role-admin", Code: "ADMIN", Name: "系统管理员", Description: "拥有系统所有功能与数据的全量管理权限(防死锁兜底)。", IsSystem: true, Status: "active", Permissions: FallbackCapabilitiesByRoleID("role-admin"), CreatedAt: now, UpdatedAt: now},
|
|
{ID: "role-director", Code: "DIRECTOR", Name: "研发总监", Description: "负责研发中心整体项目决策、立项审查、权限监督与终审批准。", IsSystem: false, Status: "active", Permissions: FallbackCapabilitiesByRoleID("role-director"), CreatedAt: now, UpdatedAt: now},
|
|
{ID: "role-tech-lead", Code: "TECH_LEAD", Name: "研发技术主管", Description: "负责具体研发管线的进度把控、文档责任人分配与技术审核。", IsSystem: false, Status: "active", Permissions: FallbackCapabilitiesByRoleID("role-tech-lead"), CreatedAt: now, UpdatedAt: now},
|
|
{ID: "role-engineer", Code: "ENGINEER", Name: "方案工程师", Description: "负责医疗器械规格设计、受控正文编制、技术测算与推演。", IsSystem: false, Status: "active", Permissions: FallbackCapabilitiesByRoleID("role-engineer"), CreatedAt: now, UpdatedAt: now},
|
|
{ID: "role-guest", Code: "GUEST", Name: "外部审计/访客", Description: "默认最小权限,仅在被授权范围内查阅。", IsSystem: false, Status: "active", Permissions: FallbackCapabilitiesByRoleID("role-guest"), CreatedAt: now, UpdatedAt: now},
|
|
}
|
|
}
|
|
|
|
func normalizePermKeys(perms []string) []string {
|
|
seen := make(map[string]struct{}, len(perms))
|
|
out := make([]string, 0, len(perms))
|
|
for _, p := range perms {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[p]; ok {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) scanRole(ctx context.Context, roleID string) (*SystemRole, error) {
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT id, code, name, description, is_system, status, created_at, updated_at
|
|
FROM system_roles WHERE id = $1
|
|
`, roleID)
|
|
var role SystemRole
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&role.ID, &role.Code, &role.Name, &role.Description, &role.IsSystem, &role.Status, &createdAt, &updatedAt); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrRoleNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
role.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
|
|
role.UpdatedAt = updatedAt.Format("2006-01-02 15:04:05")
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT p.perm_key
|
|
FROM system_role_permissions rp
|
|
JOIN system_permissions p ON p.id = rp.permission_id
|
|
WHERE rp.role_id = $1 AND rp.effect = 'allow'
|
|
ORDER BY p.perm_key
|
|
`, roleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
role.Permissions = make([]string, 0)
|
|
for rows.Next() {
|
|
var key string
|
|
if err := rows.Scan(&key); err != nil {
|
|
return nil, err
|
|
}
|
|
role.Permissions = append(role.Permissions, key)
|
|
}
|
|
return &role, rows.Err()
|
|
}
|
|
|
|
// ListRoles 列出全部系统角色及权限矩阵。
|
|
func (s *Service) ListRoles(ctx context.Context) ([]SystemRole, error) {
|
|
if !s.enabled() {
|
|
return defaultSystemRoles(), nil
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id FROM system_roles WHERE status = 'active' ORDER BY is_system DESC, id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]SystemRole, 0)
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
role, err := s.scanRole(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *role)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CreateRole 创建自定义角色。
|
|
func (s *Service) CreateRole(ctx context.Context, in CreateRoleInput) (*SystemRole, error) {
|
|
if !s.enabled() {
|
|
return nil, ErrRolesNotAvailable
|
|
}
|
|
code := strings.TrimSpace(strings.ToUpper(in.Code))
|
|
name := strings.TrimSpace(in.Name)
|
|
if code == "" || name == "" {
|
|
return nil, ErrRoleInvalid
|
|
}
|
|
id := "role-" + strings.ToLower(strings.ReplaceAll(code, "_", "-"))
|
|
perms := normalizePermKeys(in.Permissions)
|
|
if len(perms) == 0 {
|
|
perms = []string{"doc:export", "workflow:view_all"}
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO system_roles (id, code, name, description, is_system, status)
|
|
VALUES ($1, $2, $3, $4, false, 'active')
|
|
`, id, code, name, strings.TrimSpace(in.Description))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create role: %w", err)
|
|
}
|
|
if err := s.replaceRolePermissions(ctx, tx, id, perms); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.scanRole(ctx, id)
|
|
}
|
|
|
|
// UpdateRole 更新非系统角色或系统角色的权限矩阵(系统角色不可改名/删)。
|
|
func (s *Service) UpdateRole(ctx context.Context, roleID string, in UpdateRoleInput) (*SystemRole, error) {
|
|
if !s.enabled() {
|
|
return nil, ErrRolesNotAvailable
|
|
}
|
|
roleID = strings.TrimSpace(roleID)
|
|
if roleID == "" {
|
|
return nil, ErrRoleInvalid
|
|
}
|
|
existing, err := s.scanRole(ctx, roleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
perms := normalizePermKeys(in.Permissions)
|
|
if len(perms) == 0 {
|
|
return nil, ErrRoleInvalid
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if existing.IsSystem {
|
|
if strings.TrimSpace(in.Name) != "" && in.Name != existing.Name {
|
|
return nil, fmt.Errorf("%w: cannot rename system role", ErrRoleIsSystem)
|
|
}
|
|
} else {
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
name = existing.Name
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE system_roles SET name = $2, description = $3, updated_at = now() WHERE id = $1
|
|
`, roleID, name, strings.TrimSpace(in.Description))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.replaceRolePermissions(ctx, tx, roleID, perms); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.scanRole(ctx, roleID)
|
|
}
|
|
|
|
// DeleteRole 删除自定义角色(系统内置角色不可删)。
|
|
func (s *Service) DeleteRole(ctx context.Context, roleID string) error {
|
|
if !s.enabled() {
|
|
return ErrRolesNotAvailable
|
|
}
|
|
roleID = strings.TrimSpace(roleID)
|
|
if roleID == "" {
|
|
return ErrRoleInvalid
|
|
}
|
|
existing, err := s.scanRole(ctx, roleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing.IsSystem {
|
|
return ErrRoleIsSystem
|
|
}
|
|
var inUse bool
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM user_role_bindings
|
|
WHERE role_id = $1 AND (valid_until IS NULL OR valid_until > now())
|
|
) OR EXISTS (
|
|
SELECT 1 FROM users WHERE role_id = $1
|
|
)
|
|
`, roleID).Scan(&inUse); err != nil {
|
|
return err
|
|
}
|
|
if inUse {
|
|
return ErrRoleInUse
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM system_roles WHERE id = $1 AND is_system = false`, roleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrRoleNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) replaceRolePermissions(ctx context.Context, tx pgx.Tx, roleID string, perms []string) error {
|
|
if _, err := tx.Exec(ctx, `DELETE FROM system_role_permissions WHERE role_id = $1`, roleID); err != nil {
|
|
return err
|
|
}
|
|
for _, permKey := range perms {
|
|
var permID int64
|
|
err := tx.QueryRow(ctx, `SELECT id FROM system_permissions WHERE perm_key = $1`, permKey).Scan(&permID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("unknown permission %q", permKey)
|
|
}
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO system_role_permissions (role_id, permission_id, effect)
|
|
VALUES ($1, $2, 'allow')
|
|
ON CONFLICT DO NOTHING
|
|
`, roleID, permID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|