fix(authz): harden permissions, chat gate, and project create idempotency

Add project:manage_members, CSP frame-src, task-run member view, CreateUser
tx error handling, ERR_NO_PROJECT chat gate, and creator-scoped project
name/client_key uniqueness to stop duplicate creates and auth gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 07:04:57 +00:00
co-authored by Cursor
parent 8fdcd983a6
commit 5cd22c7327
21 changed files with 415 additions and 14 deletions
@@ -0,0 +1,12 @@
-- +goose Up
-- 新增「管理项目成员」系统权限位;不默认授予既有角色,由管理员按需勾选。
INSERT INTO system_permissions (perm_key, resource_type, action, risk_level)
VALUES ('project:manage_members', 'project', 'manage_members', 'elevated')
ON CONFLICT (perm_key) DO NOTHING;
-- +goose Down
DELETE FROM system_role_permissions
WHERE permission_id IN (
SELECT id FROM system_permissions WHERE perm_key = 'project:manage_members'
);
DELETE FROM system_permissions WHERE perm_key = 'project:manage_members';
@@ -0,0 +1,41 @@
-- +goose Up
-- 创建项目幂等键 + 同创建人下项目名唯一(防双击/重试重复行)。
-- 约束粒度:同一 creator_user_id 内 name 唯一(允许不同团队同名项目)。
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS client_key TEXT NULL;
COMMENT ON COLUMN projects.client_key IS
'Optional client idempotency key; same creator+key returns the existing project.';
-- Deduplicate same-creator same-name rows before unique index (keep lowest id).
WITH dups AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY creator_user_id, lower(trim(name))
ORDER BY id ASC
) AS rn
FROM projects
WHERE creator_user_id IS NOT NULL
AND creator_user_id > 0
AND length(trim(name)) > 0
)
UPDATE projects p
SET name = p.name || ' (dup-' || p.id || ')'
FROM dups d
WHERE p.id = d.id AND d.rn > 1;
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_client_key
ON projects (creator_user_id, client_key)
WHERE client_key IS NOT NULL AND length(trim(client_key)) > 0
AND creator_user_id IS NOT NULL AND creator_user_id > 0;
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_name
ON projects (creator_user_id, lower(trim(name)))
WHERE creator_user_id IS NOT NULL AND creator_user_id > 0
AND length(trim(name)) > 0;
-- +goose Down
DROP INDEX IF EXISTS uq_projects_creator_name;
DROP INDEX IF EXISTS uq_projects_creator_client_key;
ALTER TABLE projects DROP COLUMN IF EXISTS client_key;
+4 -2
View File
@@ -160,11 +160,13 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
}
if user.RoleID != "" {
_, _ = tx.Exec(ctx, `
if _, err = tx.Exec(ctx, `
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by)
VALUES ($1, $2, 'system', 0, $1)
ON CONFLICT DO NOTHING
`, id, user.RoleID)
`, id, user.RoleID); err != nil {
return nil, fmt.Errorf("insert role binding: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
+5
View File
@@ -65,6 +65,11 @@ func (a *Authorizer) enabled() bool {
return a != nil && a.pool != nil
}
// Enabled reports whether project membership checks are backed by a database pool.
func (a *Authorizer) Enabled() bool {
return a.enabled()
}
// AddOwnerOnCreate 项目创建时写入 owner 成员关系,并默认编制职责。
func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error {
duty := docduty.DutyAuthor
+1
View File
@@ -14,6 +14,7 @@ func PermissionCatalog() []PermissionCatalogEntry {
return []PermissionCatalogEntry{
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
{Key: PermManageProjectMembers, Resource: "project", Action: "manage_members", RiskLevel: "elevated", Description: "管理项目成员(添加/调整/移出)"},
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
+9 -1
View File
@@ -8,6 +8,7 @@ import (
)
const PermManage = "perm:manage"
const PermManageProjectMembers = "project:manage_members"
// Service 统一系统 RBAC 与项目成员授权判定。
type Service struct {
@@ -90,7 +91,7 @@ func (s *Service) IsSystemAdmin(ctx context.Context, userID int64, roleID string
return s.HasSystemPermission(ctx, userID, PermManage)
}
// CanManageProjectMembers 项目 owner/创建人或系统管理员可管理成员。
// CanManageProjectMembers 项目 owner/创建人、系统管理员,或持有 project:manage_members 的用户可管理成员。
func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID int64, roleID string) (bool, error) {
if userID <= 0 || projectID <= 0 {
return false, nil
@@ -102,6 +103,13 @@ func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID
if admin {
return true, nil
}
hasPerm, err := s.HasSystemPermission(ctx, userID, PermManageProjectMembers)
if err != nil {
return false, err
}
if hasPerm {
return true, nil
}
if s.authorizer == nil {
return !s.enabled() && userID > 0, nil
}
+1 -1
View File
@@ -61,7 +61,7 @@ func SafeMessage(code string) string {
func LooksInternal(s string) bool {
low := strings.ToLower(s)
needles := []string{"sql", "pq:", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt"}
needles := []string{"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback", "transaction", "constraint", "violates"}
for _, n := range needles {
if strings.Contains(low, n) {
return true
+3
View File
@@ -24,6 +24,9 @@ var (
// ErrInvalidInput 输入参数不合法
ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed")
// ErrDuplicateProjectName 同一创建人下已存在同名项目
ErrDuplicateProjectName = errors.New("duplicate project name for creator")
// ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow")
)
+18
View File
@@ -49,6 +49,23 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
return nil, fmt.Errorf("%w: 项目名称为必填项", ErrInvalidInput)
}
clientKey := strings.TrimSpace(in.ClientKey)
if clientKey != "" {
existing, err := s.store.GetProjectByClientKey(ctx, user.ID, clientKey)
if err == nil && existing != nil {
return existing, nil
}
if err != nil && err != ErrProjectNotFound {
return nil, err
}
}
if existing, err := s.store.FindProjectByCreatorAndName(ctx, user.ID, name); err == nil && existing != nil {
return nil, fmt.Errorf("%w: 您已创建同名项目「%s」", ErrDuplicateProjectName, name)
} else if err != nil && err != ErrProjectNotFound {
return nil, err
}
code := strings.TrimSpace(in.Code)
if code == "" {
code = generateProjectCode(name)
@@ -97,6 +114,7 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
ActiveWorkflowKey: "",
WorkflowSelectionVersion: 1,
WorkflowPlanRevision: 0,
ClientKey: clientKey,
}
// initialWorkflow is catalog-validated only; activation stays on the settings path.
+39
View File
@@ -309,3 +309,42 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
t.Fatalf("expected controlled_patch rejection, got %v", err)
}
}
func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
svc := NewService(newMemStore(), NewDefaultCatalog())
ctx := context.Background()
creator := &auth.UserAccount{ID: 42, Username: "creator"}
first, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("first create: %v", err)
}
second, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目-改名也应返回同一条",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
}
if first.ID != second.ID {
t.Fatalf("expected same project id, got %d vs %d", first.ID, second.ID)
}
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{Name: "幂等项目"})
if err == nil || !errors.Is(err, ErrDuplicateProjectName) {
t.Fatalf("expected ErrDuplicateProjectName, got %v", err)
}
member := &auth.UserAccount{ID: 99, Username: "other"}
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
if err != nil {
t.Fatalf("different creator same name should succeed: %v", err)
}
if other.ID == first.ID {
t.Fatalf("different creators must get distinct projects")
}
}
+166 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
@@ -17,6 +18,8 @@ type Store interface {
CreateProject(ctx context.Context, p *Project) (*Project, error)
GetProjectByID(ctx context.Context, id int64) (*Project, error)
GetProjectByCode(ctx context.Context, code string) (*Project, error)
GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error)
FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error)
ListAllProjects(ctx context.Context) ([]*Project, error)
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
@@ -78,7 +81,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
problem_statement, target_users, target_outcome, product_shape,
current_stage, environment, status, creator_user_id, lifecycle_status,
active_workflow_key, workflow_selection_version, workflow_plan_revision,
created_at, updated_at
client_key, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
@@ -86,10 +89,16 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
$16, $17, $18, $19,
$20, $21, $22, $23, $24,
$25, $26, $27,
$28, $29
NULLIF(trim($28), ''), $29, $30
)
RETURNING id
`
var clientKey any
if strings.TrimSpace(p.ClientKey) != "" {
clientKey = strings.TrimSpace(p.ClientKey)
} else {
clientKey = nil
}
err := s.pool.QueryRow(
ctx, query,
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
@@ -98,7 +107,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape,
p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus,
p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision,
p.CreatedAt, p.UpdatedAt,
clientKey, p.CreatedAt, p.UpdatedAt,
).Scan(&p.ID)
if err != nil {
return nil, fmt.Errorf("insert project failed: %w", err)
@@ -107,6 +116,91 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
return p, nil
}
func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
clientKey = strings.TrimSpace(clientKey)
if creatorUserID <= 0 || clientKey == "" {
return nil, ErrProjectNotFound
}
query := `
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
COALESCE(product_shape, ''),
current_stage, environment, status, COALESCE(creator_user_id, 1),
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
COALESCE(client_key, '')
FROM projects
WHERE creator_user_id = $1 AND client_key = $2
LIMIT 1
`
p := &Project{}
err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).Scan(
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
&p.ClientKey,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProjectNotFound
}
if err != nil {
return nil, fmt.Errorf("get project by client_key failed: %w", err)
}
return p, nil
}
func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
name = strings.TrimSpace(name)
if creatorUserID <= 0 || name == "" {
return nil, ErrProjectNotFound
}
query := `
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
COALESCE(product_shape, ''),
current_stage, environment, status, COALESCE(creator_user_id, 1),
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
COALESCE(client_key, '')
FROM projects
WHERE creator_user_id = $1 AND lower(trim(name)) = lower(trim($2))
ORDER BY id ASC
LIMIT 1
`
p := &Project{}
err := s.pool.QueryRow(ctx, query, creatorUserID, name).Scan(
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
&p.ClientKey,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProjectNotFound
}
if err != nil {
return nil, fmt.Errorf("find project by creator+name failed: %w", err)
}
return p, nil
}
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
query := `
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
@@ -600,6 +694,8 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string,
type memStore struct {
mu sync.RWMutex
projects map[int64]*Project
members map[int64]map[int64]struct{} // projectID -> set of active member userIDs
byClientKey map[string]int64 // creatorID|clientKey -> projectID
workflows map[int64]map[string]*ProjectWorkflow
runs map[string]*WorkflowRun
audits []*WorkflowActivationAudit
@@ -613,6 +709,8 @@ type memStore struct {
func newMemStore() *memStore {
m := &memStore{
projects: make(map[int64]*Project),
members: make(map[int64]map[int64]struct{}),
byClientKey: make(map[string]int64),
workflows: make(map[int64]map[string]*ProjectWorkflow),
runs: make(map[string]*WorkflowRun),
nextProjID: 1000,
@@ -679,9 +777,67 @@ func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, err
cpy := *p
m.projects[p.ID] = &cpy
if p.CreatorUserID > 0 {
if m.members[p.ID] == nil {
m.members[p.ID] = make(map[int64]struct{})
}
m.members[p.ID][p.CreatorUserID] = struct{}{}
if key := strings.TrimSpace(p.ClientKey); key != "" {
m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID
}
}
return p, nil
}
// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN).
func (m *memStore) AddProjectMember(projectID, userID int64) {
if projectID <= 0 || userID <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.members[projectID] == nil {
m.members[projectID] = make(map[int64]struct{})
}
m.members[projectID][userID] = struct{}{}
}
func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
clientKey = strings.TrimSpace(clientKey)
if creatorUserID <= 0 || clientKey == "" {
return nil, ErrProjectNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)]
if !ok {
return nil, ErrProjectNotFound
}
p, ok := m.projects[id]
if !ok {
return nil, ErrProjectNotFound
}
cpy := *p
return &cpy, nil
}
func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
name = strings.TrimSpace(name)
if creatorUserID <= 0 || name == "" {
return nil, ErrProjectNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
want := strings.ToLower(name)
for _, p := range m.projects {
if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want {
cpy := *p
return &cpy, nil
}
}
return nil, ErrProjectNotFound
}
func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -713,6 +869,13 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
if p.CreatorUserID == userID {
cpy := *p
list = append(list, &cpy)
continue
}
if members, ok := m.members[p.ID]; ok {
if _, isMember := members[userID]; isMember {
cpy := *p
list = append(list, &cpy)
}
}
}
return list, nil
+3
View File
@@ -38,6 +38,7 @@ type Project struct {
ActiveWorkflowChangedBy *int64 `json:"active_workflow_changed_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientKey string `json:"client_key,omitempty"`
}
// ProjectWorkflow 项目挂载的工作流实例
@@ -121,6 +122,8 @@ type CreateProjectInput struct {
ProductShape string `json:"product_shape,omitempty"`
CreationToken string `json:"creation_token,omitempty"`
PlanHash string `json:"plan_hash,omitempty"`
// ClientKey 可选幂等键;同一创建人重复提交返回已有项目。
ClientKey string `json:"client_key,omitempty"`
// Deprecated: accepted for catalog existence checks only; never activates the project.
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
}
+47 -2
View File
@@ -12,6 +12,25 @@ import (
"chat-agent-system/internal/authz"
)
// authIsInfrastructureError classifies DB/driver/tx failures that must not be
// reported as client VALIDATION (400).
func authIsInfrastructureError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, needle := range []string{
"insert user:", "insert credentials:", "insert role binding:",
"commit unexpectedly", "rollback", "pgx", "pq:", "sqlstate",
"connection refused", "connection reset", "context deadline",
} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
// extractBearerToken 从 Authorization 请求头解析 Bearer Token
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
@@ -99,7 +118,11 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
resp, err := s.authSvc.Register(r.Context(), req)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
status := http.StatusBadRequest
if authIsInfrastructureError(err) {
status = http.StatusInternalServerError
}
writeSafeError(w, r, status, err.Error())
return
}
@@ -255,9 +278,31 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc != nil && s.authzSvc.Enabled() {
roles, listErr := s.authzSvc.ListRoles(r.Context())
if listErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
return
}
found := false
for _, role := range roles {
if role.ID == roleID {
found = true
break
}
}
if !found {
writeSafeError(w, r, http.StatusBadRequest, "指定的角色不存在或已停用")
return
}
}
created, err := s.authSvc.CreateUserByAdmin(r.Context(), req)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
status := http.StatusBadRequest
if authIsInfrastructureError(err) {
status = http.StatusInternalServerError
}
writeSafeError(w, r, status, err.Error())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+3 -3
View File
@@ -7,9 +7,9 @@ import (
// securityCSP keeps scripts and fonts on this origin. style-src allows inline
// styles because KaTeX and Mermaid inject them into the page. connect-src
// 'self' covers same-origin JSON and SSE. frame-ancestors none matches
// X-Frame-Options DENY.
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
// 'self' covers same-origin JSON and SSE. frame-src allows same-origin and
// blob: iframes for PDF preview. frame-ancestors none matches X-Frame-Options DENY.
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
func applySecurityHeaders(w http.ResponseWriter) {
h := w.Header()
+3
View File
@@ -221,6 +221,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
sess.SetProjectID(req.ProjectID)
}
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
return
}
if sess.Project() > 0 {
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
return
+7
View File
@@ -43,6 +43,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
return
}
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "ERR_DUPLICATE_PROJECT_NAME",
"message": "您已创建同名项目,请更换名称后重试",
})
return
}
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
return
@@ -70,6 +70,19 @@ func TestProjectMembers_CrossProjectForbidden(t *testing.T) {
}
}
func TestProjectMembers_ManageMembersPermissionKeyInCatalog(t *testing.T) {
found := false
for _, p := range authz.PermissionCatalog() {
if p.Key == authz.PermManageProjectMembers {
found = true
break
}
}
if !found {
t.Fatalf("expected %s in system permission catalog", authz.PermManageProjectMembers)
}
}
func TestProjectMembers_AnonymousReturns401(t *testing.T) {
cfg := &config.Config{
Env: "dev",
+1 -1
View File
@@ -170,7 +170,7 @@ func TestP0_SecurityHeaders(t *testing.T) {
t.Fatalf("referrer = %q", got)
}
csp := rec.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, want) {
t.Fatalf("csp %q missing %s", csp, want)
}
+3
View File
@@ -210,6 +210,9 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
sess.SetProjectID(req.ProjectID)
}
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
return
}
if sess.Project() > 0 {
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
return
+32
View File
@@ -245,6 +245,38 @@ func (s *Server) applyChatWindowStageFilter(ctx context.Context, projectID, user
s.applyChatWindowAuthFilter(authCtx, res)
}
// enforceChatRequiresProject blocks chat when the session has no project and the
// user has no active project membership (new accounts with zero projects).
// Only enforced when a DB-backed authorizer can list memberships; memory/stub
// mode without project_members must not false-positive as ERR_NO_PROJECT.
func (s *Server) enforceChatRequiresProject(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
if projectID > 0 || user == nil {
return true
}
if s.isSystemAdminUser(r.Context(), user) {
return true
}
if s.authorizer == nil || !s.authorizer.Enabled() {
return true
}
ids, err := s.authorizer.ListVisibleProjectIDs(r.Context(), user.ID)
if err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]string{
"error_code": "ERR_INTERNAL",
"message": "无法校验项目归属",
})
return false
}
if len(ids) == 0 {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_NO_PROJECT",
"message": "请先创建或加入项目后再开始对话",
})
return false
}
return true
}
// enforceChatProjectAccess 项目对话入口:成员/创建者;系统管理员按既有策略放行。
// 必须在 SSE write headers / Flush 之前调用,拒绝时写结构化 JSON 403(非 200 SSE)。
func (s *Server) enforceChatProjectAccess(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
+4 -1
View File
@@ -33,12 +33,15 @@ func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context,
}
isOwner := rec.UserID > 0 && rec.UserID == user.ID
isProjectOwner := false
isProjectMember := false
if rec.ProjectID > 0 && s.authorizer != nil {
isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID)
isProjectMember, _ = s.authorizer.IsProjectMember(ctx, rec.ProjectID, user.ID)
}
switch action {
case taskRunView:
if isOwner || isProjectOwner {
// Align with create-run RequireProjectAccess: any active project member may view.
if isOwner || isProjectOwner || isProjectMember {
return true
}
case taskRunCancel, taskRunResume: