From 6219caf69067e3c820146c97a42d38a77d4a7734 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 02:47:57 +0000 Subject: [PATCH] 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 --- internal/auth/pg_store.go | 50 ++++ internal/auth/service.go | 21 ++ internal/auth/session.go | 69 +++++- internal/authz/fallback.go | 40 ++++ internal/authz/roles.go | 291 ++++++++++++++++++++++++ internal/authz/roles_test.go | 33 +++ internal/server/admin_roles_handlers.go | 99 ++++++++ internal/server/auth_handlers.go | 103 ++++++--- internal/server/auth_helpers.go | 2 +- internal/server/principal_context.go | 15 +- internal/server/server.go | 6 + internal/server/task_run_auth.go | 10 +- 12 files changed, 694 insertions(+), 45 deletions(-) create mode 100644 internal/authz/fallback.go create mode 100644 internal/authz/roles.go create mode 100644 internal/authz/roles_test.go create mode 100644 internal/server/admin_roles_handlers.go diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 0b182489..86895a18 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -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 +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 656282d6..bbc5c9eb 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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) diff --git a/internal/auth/session.go b/internal/auth/session.go index 0dfd69f8..25018887 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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 +} diff --git a/internal/authz/fallback.go b/internal/authz/fallback.go new file mode 100644 index 00000000..79087edc --- /dev/null +++ b/internal/authz/fallback.go @@ -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 + } +} diff --git a/internal/authz/roles.go b/internal/authz/roles.go new file mode 100644 index 00000000..edf1af7f --- /dev/null +++ b/internal/authz/roles.go @@ -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 +} diff --git a/internal/authz/roles_test.go b/internal/authz/roles_test.go new file mode 100644 index 00000000..52ee0980 --- /dev/null +++ b/internal/authz/roles_test.go @@ -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") + } +} diff --git a/internal/server/admin_roles_handlers.go b/internal/server/admin_roles_handlers.go new file mode 100644 index 00000000..d7e078fb --- /dev/null +++ b/internal/server/admin_roles_handlers.go @@ -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) + } +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 3ebb1647..08146671 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -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 } diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index 28effa08..3e334f37 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -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 { diff --git a/internal/server/principal_context.go b/internal/server/principal_context.go index 1e7876ba..79930850 100644 --- a/internal/server/principal_context.go +++ b/internal/server/principal_context.go @@ -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": "需要管理员权限", diff --git a/internal/server/server.go b/internal/server/server.go index 806a80e6..4fc79724 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index f480cc83..a849ee13 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -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 }