feat(authz): complete Phase 1 admin roles, sessions, and unified admin gate
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>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
8cdc839650
commit
6219caf690
@@ -389,3 +389,53 @@ func (s *PgStore) RevokeAllForUser(ctx context.Context, userID int64) error {
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) ListActiveSessions(ctx context.Context, userID int64, currentTokenHash string) ([]SessionRecord, error) {
|
||||
var currentID string
|
||||
if currentTokenHash != "" {
|
||||
_ = s.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM auth_sessions
|
||||
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
`, currentTokenHash, userID).Scan(¤tID)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, created_at, last_seen_at, user_agent
|
||||
FROM auth_sessions
|
||||
WHERE user_id = $1
|
||||
AND revoked_at IS NULL
|
||||
AND idle_expires_at > now()
|
||||
AND absolute_expires_at > now()
|
||||
ORDER BY last_seen_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]SessionRecord, 0)
|
||||
for rows.Next() {
|
||||
var rec SessionRecord
|
||||
var createdAt, lastSeenAt time.Time
|
||||
if err := rows.Scan(&rec.ID, &createdAt, &lastSeenAt, &rec.UserAgent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
|
||||
rec.LastSeenAt = lastSeenAt.Format("2006-01-02 15:04:05")
|
||||
rec.IsCurrent = currentID != "" && rec.ID == currentID
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) RevokeSessionByID(ctx context.Context, userID int64, sessionID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_sessions SET revoked_at = now()
|
||||
WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL
|
||||
`, sessionID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -200,6 +200,10 @@ func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccoun
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status == StatusDisabled || user.Status == StatusPendingApproval {
|
||||
_ = s.sessions.RevokeByTokenHash(ctx, HashToken(token))
|
||||
return nil, errors.New("会话已过期或未登录")
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
return user, nil
|
||||
}
|
||||
@@ -281,10 +285,27 @@ func (s *Service) UpdateUserStatus(ctx context.Context, userID int64, status Acc
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == StatusDisabled {
|
||||
_ = s.sessions.RevokeAllForUser(ctx, userID)
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUserSessions 列出当前用户活跃会话。
|
||||
func (s *Service) ListUserSessions(ctx context.Context, userID int64, currentToken string) ([]SessionRecord, error) {
|
||||
currentHash := ""
|
||||
if currentToken != "" {
|
||||
currentHash = HashToken(currentToken)
|
||||
}
|
||||
return s.sessions.ListActiveSessions(ctx, userID, currentHash)
|
||||
}
|
||||
|
||||
// RevokeUserSession 撤销指定会话(不可跨用户)。
|
||||
func (s *Service) RevokeUserSession(ctx context.Context, userID int64, sessionID string) error {
|
||||
return s.sessions.RevokeSessionByID(ctx, userID, sessionID)
|
||||
}
|
||||
|
||||
// GetConfig 获取准入策略
|
||||
func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) {
|
||||
return s.store.GetConfig(ctx)
|
||||
|
||||
@@ -21,6 +21,15 @@ type SessionMeta struct {
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// SessionRecord 用户可见的活跃会话摘要。
|
||||
type SessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
// SessionStore 持久化或内存会话存储。
|
||||
type SessionStore interface {
|
||||
CreateSession(ctx context.Context, userID int64, tokenHash string, meta SessionMeta) error
|
||||
@@ -28,6 +37,8 @@ type SessionStore interface {
|
||||
TouchSession(ctx context.Context, tokenHash string) error
|
||||
RevokeByTokenHash(ctx context.Context, tokenHash string) error
|
||||
RevokeAllForUser(ctx context.Context, userID int64) error
|
||||
ListActiveSessions(ctx context.Context, userID int64, currentTokenHash string) ([]SessionRecord, error)
|
||||
RevokeSessionByID(ctx context.Context, userID int64, sessionID string) error
|
||||
}
|
||||
|
||||
// HashToken 将会话 token 转为存储用 SHA-256 十六进制摘要。
|
||||
@@ -36,26 +47,41 @@ func HashToken(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type memorySessionEntry struct {
|
||||
id string
|
||||
userID int64
|
||||
createdAt time.Time
|
||||
lastSeenAt time.Time
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// MemorySessionStore 进程内 token 映射(仅 dev/stub fallback)。
|
||||
type MemorySessionStore struct {
|
||||
tokens map[string]int64
|
||||
tokens map[string]memorySessionEntry
|
||||
}
|
||||
|
||||
func NewMemorySessionStore() *MemorySessionStore {
|
||||
return &MemorySessionStore{tokens: make(map[string]int64)}
|
||||
return &MemorySessionStore{tokens: make(map[string]memorySessionEntry)}
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) CreateSession(_ context.Context, userID int64, tokenHash string, _ SessionMeta) error {
|
||||
s.tokens[tokenHash] = userID
|
||||
func (s *MemorySessionStore) CreateSession(_ context.Context, userID int64, tokenHash string, meta SessionMeta) error {
|
||||
now := time.Now()
|
||||
s.tokens[tokenHash] = memorySessionEntry{
|
||||
id: tokenHash[:12],
|
||||
userID: userID,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
userAgent: meta.UserAgent,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) ResolveUserID(_ context.Context, tokenHash string) (int64, error) {
|
||||
userID, ok := s.tokens[tokenHash]
|
||||
entry, ok := s.tokens[tokenHash]
|
||||
if !ok {
|
||||
return 0, errSessionNotFound
|
||||
}
|
||||
return userID, nil
|
||||
return entry.userID, nil
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) TouchSession(_ context.Context, _ string) error {
|
||||
@@ -68,10 +94,37 @@ func (s *MemorySessionStore) RevokeByTokenHash(_ context.Context, tokenHash stri
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) RevokeAllForUser(_ context.Context, userID int64) error {
|
||||
for tok, uid := range s.tokens {
|
||||
if uid == userID {
|
||||
for tok, entry := range s.tokens {
|
||||
if entry.userID == userID {
|
||||
delete(s.tokens, tok)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) ListActiveSessions(_ context.Context, userID int64, currentTokenHash string) ([]SessionRecord, error) {
|
||||
out := make([]SessionRecord, 0)
|
||||
for tok, entry := range s.tokens {
|
||||
if entry.userID != userID {
|
||||
continue
|
||||
}
|
||||
out = append(out, SessionRecord{
|
||||
ID: entry.id,
|
||||
CreatedAt: entry.createdAt.Format("2006-01-02 15:04:05"),
|
||||
LastSeenAt: entry.lastSeenAt.Format("2006-01-02 15:04:05"),
|
||||
UserAgent: entry.userAgent,
|
||||
IsCurrent: currentTokenHash != "" && tok == currentTokenHash,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MemorySessionStore) RevokeSessionByID(_ context.Context, userID int64, sessionID string) error {
|
||||
for tok, entry := range s.tokens {
|
||||
if entry.userID == userID && entry.id == sessionID {
|
||||
delete(s.tokens, tok)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errSessionNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package authz
|
||||
|
||||
// FallbackCapabilitiesByRoleID 在无 PG RBAC 时按 role_id 返回静态权限(与 IAM 种子对齐)。
|
||||
func FallbackCapabilitiesByRoleID(roleID string) []string {
|
||||
switch roleID {
|
||||
case "role-admin":
|
||||
return []string{
|
||||
"project:create",
|
||||
"project:assign_lead",
|
||||
PermManage,
|
||||
"workflow:config_visibility",
|
||||
"doc:override_visibility",
|
||||
"doc:export",
|
||||
"workflow:view_all",
|
||||
}
|
||||
case "role-director":
|
||||
return []string{
|
||||
"project:create",
|
||||
"project:assign_lead",
|
||||
"workflow:config_visibility",
|
||||
"doc:override_visibility",
|
||||
"doc:export",
|
||||
"workflow:view_all",
|
||||
}
|
||||
case "role-tech-lead":
|
||||
return []string{
|
||||
"project:create",
|
||||
"project:assign_lead",
|
||||
"doc:override_visibility",
|
||||
"doc:export",
|
||||
"workflow:view_all",
|
||||
}
|
||||
case "role-engineer":
|
||||
return []string{"doc:export", "workflow:view_all"}
|
||||
case "role-guest":
|
||||
return []string{"doc:export"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package authz_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
func TestService_ListRoles_FallbackWithoutPool(t *testing.T) {
|
||||
svc := authz.NewService(nil)
|
||||
roles, err := svc.ListRoles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(roles) < 5 {
|
||||
t.Fatalf("expected seeded roles, got %d", len(roles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackCapabilities_AdminHasManage(t *testing.T) {
|
||||
caps := authz.FallbackCapabilitiesByRoleID("role-admin")
|
||||
found := false
|
||||
for _, c := range caps {
|
||||
if c == authz.PermManage {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("admin fallback should include perm:manage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
roles, err := s.authzSvc.ListRoles(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles})
|
||||
case http.MethodPost:
|
||||
var req authz.CreateRoleInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := s.authzSvc.CreateRole(r.Context(), req)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, authz.ErrRolesNotAvailable) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusCreated, created)
|
||||
default:
|
||||
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
roleID := strings.TrimSpace(r.PathValue("id"))
|
||||
if roleID == "" {
|
||||
http.Error(w, "无效的角色 ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPatch, http.MethodPut:
|
||||
var req authz.UpdateRoleInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updated, err := s.authzSvc.UpdateRole(r.Context(), roleID, req)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
switch {
|
||||
case errors.Is(err, authz.ErrRoleNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, authz.ErrRoleIsSystem):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, authz.ErrRolesNotAvailable):
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, updated)
|
||||
case http.MethodDelete:
|
||||
if err := s.authzSvc.DeleteRole(r.Context(), roleID); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
switch {
|
||||
case errors.Is(err, authz.ErrRoleNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, authz.ErrRoleIsSystem):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, authz.ErrRoleInUse):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, authz.ErrRolesNotAvailable):
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
|
||||
default:
|
||||
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
// extractBearerToken 从 Authorization 请求头解析 Bearer Token
|
||||
@@ -131,12 +133,7 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
env = s.cfg.Env
|
||||
}
|
||||
|
||||
caps := []string{}
|
||||
if s.authzSvc != nil {
|
||||
if listed, capErr := s.authzSvc.ListCapabilities(r.Context(), user.ID); capErr == nil && listed != nil {
|
||||
caps = listed
|
||||
}
|
||||
}
|
||||
caps := s.resolveUserCapabilities(r.Context(), user)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -147,11 +144,69 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveUserCapabilities(ctx context.Context, user *auth.UserAccount) []string {
|
||||
caps := []string{}
|
||||
if s.authzSvc != nil {
|
||||
if listed, capErr := s.authzSvc.ListCapabilities(ctx, user.ID); capErr == nil && len(listed) > 0 {
|
||||
return listed
|
||||
}
|
||||
}
|
||||
if user != nil && user.RoleID != "" {
|
||||
if fallback := authz.FallbackCapabilitiesByRoleID(user.RoleID); len(fallback) > 0 {
|
||||
caps = fallback
|
||||
}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// handleAuthSessions 列出或撤销当前用户会话。
|
||||
func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
token := extractBearerToken(r)
|
||||
sessions, listErr := s.authSvc.ListUserSessions(r.Context(), user.ID, token)
|
||||
if listErr != nil {
|
||||
http.Error(w, listErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []auth.SessionRecord{}
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"sessions": sessions})
|
||||
return
|
||||
}
|
||||
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(r.PathValue("id"))
|
||||
if sessionID == "" {
|
||||
http.Error(w, "无效的会话 ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.authSvc.RevokeUserSession(r.Context(), user.ID, sessionID); err != nil {
|
||||
http.Error(w, "会话不存在或已失效", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// handleAdminUsers 管理员查询或新建用户
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user.RoleID != "role-admin" {
|
||||
http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden)
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,19 +242,12 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleAdminUpdateRole 管理员为用户指定职位与角色
|
||||
func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user.RoleID != "role-admin" {
|
||||
http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden)
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 5 {
|
||||
http.Error(w, "无效的用户路径参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseInt(parts[3], 10, 64)
|
||||
if err != nil {
|
||||
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || targetID <= 0 {
|
||||
http.Error(w, "无效的用户ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -225,19 +273,12 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleAdminUpdateStatus 管理员审批通过/停用账号
|
||||
func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user.RoleID != "role-admin" {
|
||||
http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden)
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 5 {
|
||||
http.Error(w, "无效的用户路径参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseInt(parts[3], 10, 64)
|
||||
if err != nil {
|
||||
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || targetID <= 0 {
|
||||
http.Error(w, "无效的用户ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -268,9 +309,7 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleAdminConfig 获取或更新系统准入配置
|
||||
func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user.RoleID != "role-admin" {
|
||||
http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden)
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if isAdminUser(user) {
|
||||
if s.isSystemAdminUser(r.Context(), user) {
|
||||
return user, true
|
||||
}
|
||||
if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil {
|
||||
|
||||
@@ -43,11 +43,24 @@ func (s *Server) requirePrincipal(w http.ResponseWriter, r *http.Request) (*auth
|
||||
|
||||
// requireAdmin 要求系统管理员角色。
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (*auth.UserAccount, bool) {
|
||||
return s.requireSystemAdmin(w, r)
|
||||
}
|
||||
|
||||
// requireSystemAdmin 通过 AuthorizationService 判定系统管理员。
|
||||
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (*auth.UserAccount, bool) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if user.RoleID != "role-admin" {
|
||||
admin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID)
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]string{
|
||||
"error_code": "ERR_AUTHZ",
|
||||
"message": "权限判定失败",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
if !admin {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "需要管理员权限",
|
||||
|
||||
@@ -208,6 +208,12 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
|
||||
mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword)
|
||||
mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe)
|
||||
mux.HandleFunc("GET /api/v1/auth/sessions", s.handleAuthSessions)
|
||||
mux.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.handleAuthSessionByID)
|
||||
mux.HandleFunc("GET /api/v1/admin/roles", s.handleAdminRoles)
|
||||
mux.HandleFunc("POST /api/v1/admin/roles", s.handleAdminRoles)
|
||||
mux.HandleFunc("PATCH /api/v1/admin/roles/{id}", s.handleAdminRoleByID)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/roles/{id}", s.handleAdminRoleByID)
|
||||
mux.HandleFunc("GET /api/v1/admin/users", s.handleAdminUsers)
|
||||
mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers)
|
||||
mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole)
|
||||
|
||||
@@ -20,7 +20,7 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con
|
||||
http.Error(w, "run not found", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
if isAdminUser(user) {
|
||||
if s.isSystemAdminUser(ctx, user) {
|
||||
return true
|
||||
}
|
||||
if rec.UserID > 0 && rec.UserID == user.ID {
|
||||
@@ -38,6 +38,10 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con
|
||||
return false
|
||||
}
|
||||
|
||||
func isAdminUser(user *auth.UserAccount) bool {
|
||||
return user != nil && user.RoleID == "role-admin"
|
||||
func (s *Server) isSystemAdminUser(ctx context.Context, user *auth.UserAccount) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
admin, err := s.authzSvc.IsSystemAdmin(ctx, user.ID, user.RoleID)
|
||||
return err == nil && admin
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user