merge: fix/audit-b2-authz-hardening (audit batch 2: principal identity, docgov access, rate limit, compose secrets)
This commit is contained in:
@@ -95,7 +95,7 @@ services:
|
|||||||
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
|
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production}
|
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}
|
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
|
||||||
EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-}
|
EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-}
|
||||||
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3}
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"]
|
command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"]
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${SYSTEM_DB_USER:-app}
|
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}
|
POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432"
|
- "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432"
|
||||||
@@ -82,7 +82,7 @@ services:
|
|||||||
MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8}
|
MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8}
|
||||||
DATA_DIR: /app/data
|
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
|
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
|
PI_AGENT_RUNNER_URL: http://pi-agent:18866
|
||||||
@@ -97,7 +97,7 @@ services:
|
|||||||
RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free}
|
RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free}
|
||||||
RERANKER_API_KEY: ${RERANKER_API_KEY:-}
|
RERANKER_API_KEY: ${RERANKER_API_KEY:-}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
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_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true}
|
||||||
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
|
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||||
@@ -152,7 +152,7 @@ services:
|
|||||||
HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
||||||
EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
||||||
DATA_DIR: /app/data
|
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:
|
ports:
|
||||||
- "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}"
|
- "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublisherEnableDefaultOff(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")
|
t.Fatal("test env must not force publisher on")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) {
|
|||||||
return nil, errors.New("missing auth token")
|
return nil, errors.New("missing auth token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthLogin 用户名密码登录
|
// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429)
|
||||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
session, err := s.authSvc.Login(r.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
@@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.loginLimiter != nil {
|
||||||
|
s.loginLimiter.reset(s.loginLimiter.key(r, req.Username))
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(session)
|
_ = json.NewEncoder(w).Encode(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客)
|
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册)
|
||||||
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
var req auth.RegisterRequest
|
var req auth.RegisterRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
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)
|
resp, err := s.authSvc.Register(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"chat-agent-system/internal/auth"
|
"chat-agent-system/internal/auth"
|
||||||
"chat-agent-system/internal/authz"
|
"chat-agent-system/internal/authz"
|
||||||
"chat-agent-system/internal/ctxmgr"
|
"chat-agent-system/internal/ctxmgr"
|
||||||
|
"chat-agent-system/internal/docgov"
|
||||||
)
|
)
|
||||||
|
|
||||||
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
|
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
|
||||||
@@ -135,3 +136,65 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr
|
|||||||
}
|
}
|
||||||
return user, true
|
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("")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
user, err := s.authenticateUser(r)
|
user, err := s.authenticateUser(r)
|
||||||
if (err != nil || user == nil) && s.allowLegacyTestPrincipal() {
|
if (err != nil || user == nil) && s.allowLegacyTestPrincipal() {
|
||||||
if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil {
|
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
|
user = fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
|
|
||||||
// handleGetChatTurn 查询回合状态(断线续接)
|
// handleGetChatTurn 查询回合状态(断线续接)
|
||||||
func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
|
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")
|
clientTurnID := r.PathValue("client_turn_id")
|
||||||
sessionID := r.URL.Query().Get("session_id")
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
if clientTurnID == "" || sessionID == "" {
|
if clientTurnID == "" || sessionID == "" {
|
||||||
@@ -23,6 +27,11 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归属校验:只允许查询本人会话的回合状态
|
||||||
|
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
|
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
|
||||||
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
|
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
|
||||||
writeSafeError(w, r, http.StatusNotFound, "turn not found")
|
writeSafeError(w, r, http.StatusNotFound, "turn not found")
|
||||||
|
|||||||
@@ -427,14 +427,26 @@ func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy s
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// handleGetUserContext 获取单用户私有上下文与个性化偏好
|
// handleGetUserContext 获取单用户私有上下文与个性化偏好
|
||||||
|
// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。
|
||||||
func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
|
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")
|
userIDStr := r.URL.Query().Get("user_id")
|
||||||
projectIDStr := r.URL.Query().Get("project_id")
|
projectIDStr := r.URL.Query().Get("project_id")
|
||||||
sessionID := r.URL.Query().Get("session_id")
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
|
||||||
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
if userID <= 0 {
|
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)
|
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
||||||
|
|
||||||
@@ -544,6 +556,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
doc, err := s.gov.GetDocument(r.Context(), docID)
|
doc, err := s.gov.GetDocument(r.Context(), docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
|
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
|
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 {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
|
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -868,6 +884,10 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
comments, err := s.gov.ListComments(r.Context(), docID)
|
comments, err := s.gov.ListComments(r.Context(), docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
|
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
|
||||||
@@ -889,6 +909,7 @@ type AddCommentRequestDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
|
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
|
||||||
|
// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。
|
||||||
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
|
||||||
var req AddCommentRequestDTO
|
var req AddCommentRequestDTO
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cType := docgov.CommentType(req.CommentType)
|
cType := docgov.CommentType(req.CommentType)
|
||||||
if cType == "" {
|
if cType == "" {
|
||||||
cType = docgov.CommentGeneral
|
cType = docgov.CommentGeneral
|
||||||
}
|
}
|
||||||
|
|
||||||
comment, err := s.gov.AddComment(
|
comment, err := s.gov.AddComment(
|
||||||
r.Context(), req.DocID, req.UserID, req.UserName,
|
r.Context(), req.DocID, user.ID, user.Name,
|
||||||
docgov.UserRole(req.UserRole), req.TargetSection, cType, req.Content,
|
s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDocGovError(w, err)
|
writeDocGovError(w, err)
|
||||||
@@ -931,6 +957,10 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logs, err := s.gov.ListAuditLogs(r.Context(), docID)
|
logs, err := s.gov.ListAuditLogs(r.Context(), docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error())
|
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 分页查询用户/项目关联的历史会话列表
|
// handleListChatSessions 分页查询用户/项目关联的历史会话列表
|
||||||
|
// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。
|
||||||
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.ctxMgr == nil {
|
if s.ctxMgr == nil {
|
||||||
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
|
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, ok := s.requirePrincipal(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userIDStr := r.URL.Query().Get("user_id")
|
userIDStr := r.URL.Query().Get("user_id")
|
||||||
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
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")
|
projectIDStr := r.URL.Query().Get("project_id")
|
||||||
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ type Server struct {
|
|||||||
vaultPublisherStarted int32
|
vaultPublisherStarted int32
|
||||||
vaultStop chan struct{}
|
vaultStop chan struct{}
|
||||||
reminderStop chan struct{}
|
reminderStop chan struct{}
|
||||||
|
loginLimiter *loginRateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
|
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
|
||||||
@@ -114,6 +115,8 @@ func NewFromRuntime(rt *app.Runtime) *Server {
|
|||||||
kb: rt.KB,
|
kb: rt.KB,
|
||||||
sysadmin: rt.SysAdmin,
|
sysadmin: rt.SysAdmin,
|
||||||
intentPlans: rt.IntentPlans,
|
intentPlans: rt.IntentPlans,
|
||||||
|
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
|
||||||
|
loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
|
||||||
}
|
}
|
||||||
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
|
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
|
||||||
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)
|
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)
|
||||||
|
|||||||
Reference in New Issue
Block a user