merge: fix/audit-b2-authz-hardening (audit batch 2: principal identity, docgov access, rate limit, compose secrets)

This commit is contained in:
weichen
2026-09-19 06:01:00 +00:00
11 changed files with 287 additions and 12 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ services:
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production}
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true}
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-false}
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3}
+4 -4
View File
@@ -14,7 +14,7 @@ services:
command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"]
environment:
POSTGRES_USER: ${SYSTEM_DB_USER:-app}
POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:-test_secret_db_pass}
POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:?required}
POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test}
ports:
- "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432"
@@ -82,7 +82,7 @@ services:
MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8}
DATA_DIR: /app/data
# 独占测试业务与对话库
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
# 共享知识库
POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable
PI_AGENT_RUNNER_URL: http://pi-agent:18866
@@ -97,7 +97,7 @@ services:
RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free}
RERANKER_API_KEY: ${RERANKER_API_KEY:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin-secret-password-123}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required}
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true}
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
@@ -152,7 +152,7 @@ services:
HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
DATA_DIR: /app/data
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
ports:
- "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}"
healthcheck:
@@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) {
}
func TestPublisherEnableDefaultOff(t *testing.T) {
if os.Getenv("FAV_PUBLISHER_ENABLE") == "1" {
if os.Getenv("FAV_PUBLISHER_ENABLED") == "1" {
t.Fatal("test env must not force publisher on")
}
}
+25 -2
View File
@@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) {
return nil, errors.New("missing auth token")
}
// handleAuthLogin 用户名密码登录
// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429)
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
@@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
return
}
if s.loginLimiter != nil {
key := s.loginLimiter.key(r, req.Username)
if !s.loginLimiter.allow(key) {
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
"error_code": "ERR_LOGIN_RATE_LIMITED",
"message": "登录尝试过于频繁,请稍后再试",
})
return
}
}
session, err := s.authSvc.Login(r.Context(), req.Username, req.Password)
if err != nil {
errMsg := err.Error()
@@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
return
}
if s.loginLimiter != nil {
s.loginLimiter.reset(s.loginLimiter.key(r, req.Username))
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(session)
}
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客)
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册)
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
var req auth.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -74,6 +89,14 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
return
}
if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) {
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
"error_code": "ERR_REGISTER_RATE_LIMITED",
"message": "注册尝试过于频繁,请稍后再试",
})
return
}
resp, err := s.authSvc.Register(r.Context(), req)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
+63
View File
@@ -8,6 +8,7 @@ import (
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
)
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
@@ -135,3 +136,65 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr
}
return user, true
}
// requireDocGovDocAccess 校验当前用户可访问指定受控文档(经 doc_id 解析 project_id 后做项目成员校验)。
func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, docID string) (*auth.UserAccount, bool) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return nil, false
}
if s.gov == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
"error_code": "ERR_DOCGOV_UNAVAILABLE",
"message": "docgov engine not initialized",
})
return nil, false
}
if s.isSystemAdminUser(r.Context(), user) {
return user, true
}
doc, err := s.gov.GetDocument(r.Context(), docID)
if err != nil || doc == nil {
writeJSONResponse(w, http.StatusNotFound, map[string]string{
"error_code": "ERR_DOCUMENT_NOT_FOUND",
"message": "文档不存在或无权限访问",
})
return nil, false
}
if doc.ProjectID <= 0 {
// 无项目归属的文档仅管理员可见
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该文档",
})
return nil, false
}
if err := s.authorizer.RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该文档所属项目",
})
return nil, false
}
return user, true
}
// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole {
if user == nil || s.gov == nil {
return docgov.UserRole("")
}
doc, err := s.gov.GetDocument(ctx, docID)
if err != nil || doc == nil {
return docgov.UserRole("")
}
switch user.ID {
case doc.AuthorID:
return docgov.RoleAuthor
case doc.ReviewerID:
return docgov.RoleReviewer
case doc.ApproverID:
return docgov.RoleApprover
}
return docgov.UserRole("")
}
+4
View File
@@ -1,6 +1,7 @@
package server
import (
"log/slog"
"net/http"
"strings"
)
@@ -48,6 +49,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
user, err := s.authenticateUser(r)
if (err != nil || user == nil) && s.allowLegacyTestPrincipal() {
if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil {
slog.Warn("legacy_test_principal_fallback",
"path", r.URL.Path, "method", r.Method,
"remote_addr", r.RemoteAddr, "fallback_user_id", fallback.ID)
user = fallback
}
}
+9
View File
@@ -10,6 +10,10 @@ import (
// handleGetChatTurn 查询回合状态(断线续接)
func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
clientTurnID := r.PathValue("client_turn_id")
sessionID := r.URL.Query().Get("session_id")
if clientTurnID == "" || sessionID == "" {
@@ -23,6 +27,11 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
return
}
// 归属校验:只允许查询本人会话的回合状态
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
return
}
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
writeSafeError(w, r, http.StatusNotFound, "turn not found")
+51 -4
View File
@@ -427,14 +427,26 @@ func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy s
// -----------------------------------------------------------------------------
// handleGetUserContext 获取单用户私有上下文与个性化偏好
// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。
func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
userIDStr := r.URL.Query().Get("user_id")
projectIDStr := r.URL.Query().Get("project_id")
sessionID := r.URL.Query().Get("session_id")
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
if userID <= 0 {
userID = 1
userID = user.ID
}
if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权查询其他用户的上下文",
})
return
}
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
@@ -544,6 +556,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
doc, err := s.gov.GetDocument(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
@@ -583,7 +599,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
return
}
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name)
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
return
@@ -868,6 +884,10 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
comments, err := s.gov.ListComments(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
@@ -889,6 +909,7 @@ type AddCommentRequestDTO struct {
}
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
var req AddCommentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -901,14 +922,19 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
return
}
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
if !ok {
return
}
cType := docgov.CommentType(req.CommentType)
if cType == "" {
cType = docgov.CommentGeneral
}
comment, err := s.gov.AddComment(
r.Context(), req.DocID, req.UserID, req.UserName,
docgov.UserRole(req.UserRole), req.TargetSection, cType, req.Content,
r.Context(), req.DocID, user.ID, user.Name,
s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content,
)
if err != nil {
writeDocGovError(w, err)
@@ -931,6 +957,10 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
logs, err := s.gov.ListAuditLogs(r.Context(), docID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error())
@@ -945,14 +975,31 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
// -----------------------------------------------------------------------------
// handleListChatSessions 分页查询用户/项目关联的历史会话列表
// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
userIDStr := r.URL.Query().Get("user_id")
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权查询其他用户的会话列表",
})
return
}
// 未显式传 user_id 时默认查本人,而不是任意/默认用户
if userID <= 0 {
userID = user.ID
}
projectIDStr := r.URL.Query().Get("project_id")
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。
// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。
type loginRateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
maxAttempts int
window time.Duration
}
func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter {
if maxAttempts <= 0 {
maxAttempts = 10
}
if window <= 0 {
window = 5 * time.Minute
}
return &loginRateLimiter{
attempts: make(map[string][]time.Time),
maxAttempts: maxAttempts,
window: window,
}
}
// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。
func (l *loginRateLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
// 滑出窗口的尝试丢弃
ts := l.attempts[key]
kept := ts[:0]
for _, t := range ts {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.maxAttempts {
l.attempts[key] = kept
return false
}
l.attempts[key] = append(kept, now)
return true
}
// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。
func (l *loginRateLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, key)
}
// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" {
return strings.TrimSpace(first)
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (l *loginRateLimiter) key(r *http.Request, username string) string {
return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username))
}
+46
View File
@@ -0,0 +1,46 @@
package server
import (
"testing"
"time"
)
func TestLoginRateLimiter_BlocksAfterMaxAttempts(t *testing.T) {
l := newLoginRateLimiter(3, time.Minute)
key := "1.2.3.4|alice"
for i := 0; i < 3; i++ {
if !l.allow(key) {
t.Fatalf("attempt %d should be allowed", i+1)
}
}
if l.allow(key) {
t.Fatalf("4th attempt within window should be blocked")
}
}
func TestLoginRateLimiter_ResetClearsWindow(t *testing.T) {
l := newLoginRateLimiter(2, time.Minute)
key := "1.2.3.4|bob"
l.allow(key)
l.allow(key)
if l.allow(key) {
t.Fatalf("should be blocked before reset")
}
l.reset(key)
if !l.allow(key) {
t.Fatalf("should be allowed after reset")
}
}
func TestLoginRateLimiter_WindowExpiry(t *testing.T) {
l := newLoginRateLimiter(1, 10*time.Millisecond)
key := "1.2.3.4|carol"
l.allow(key)
if l.allow(key) {
t.Fatalf("should be blocked within window")
}
time.Sleep(20 * time.Millisecond)
if !l.allow(key) {
t.Fatalf("should be allowed after window expiry")
}
}
+3
View File
@@ -79,6 +79,7 @@ type Server struct {
vaultPublisherStarted int32
vaultStop chan struct{}
reminderStop chan struct{}
loginLimiter *loginRateLimiter
}
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
@@ -114,6 +115,8 @@ func NewFromRuntime(rt *app.Runtime) *Server {
kb: rt.KB,
sysadmin: rt.SysAdmin,
intentPlans: rt.IntentPlans,
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
}
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)