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:
weichen
2026-09-13 02:50:22 +00:00
committed by weichen
co-authored by Cursor
parent 8cdc839650
commit 6219caf690
12 changed files with 694 additions and 45 deletions
+50
View File
@@ -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(&currentID)
}
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
}
+21
View File
@@ -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)
+61 -8
View File
@@ -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
}
+40
View File
@@ -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
}
}
+291
View File
@@ -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
}
+33
View File
@@ -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")
}
}
+99
View File
@@ -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)
}
}
+71 -32
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+14 -1
View File
@@ -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": "需要管理员权限",
+6
View File
@@ -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)
+7 -3
View File
@@ -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
}