From 5cd22c7327b7549457fd512abe20f5ce034872a0 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 07:04:57 +0000 Subject: [PATCH] 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 --- ...9-20-project-manage-members-permission.sql | 12 ++ ..._2026-09-20-project-create-idempotency.sql | 41 +++++ internal/auth/pg_store.go | 6 +- internal/authz/authorizer.go | 5 + internal/authz/catalog.go | 1 + internal/authz/service.go | 10 +- internal/httpx/envelope.go | 2 +- internal/projectworkflow/errors.go | 3 + internal/projectworkflow/service.go | 18 ++ internal/projectworkflow/service_test.go | 39 ++++ internal/projectworkflow/store.go | 169 +++++++++++++++++- internal/projectworkflow/types.go | 3 + internal/server/auth_handlers.go | 49 ++++- internal/server/cors.go | 6 +- internal/server/handlers.go | 3 + internal/server/project_handlers.go | 7 + internal/server/project_members_authz_test.go | 13 ++ internal/server/security_p0_test.go | 2 +- internal/server/sse.go | 3 + internal/server/stage_access_helpers.go | 32 ++++ internal/server/task_run_auth.go | 5 +- 21 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql create mode 100644 deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql diff --git a/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql b/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql new file mode 100644 index 00000000..d5f312e1 --- /dev/null +++ b/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql @@ -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'; diff --git a/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql b/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql new file mode 100644 index 00000000..488f2dad --- /dev/null +++ b/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql @@ -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; diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 564ce82b..43363e71 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -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 { diff --git a/internal/authz/authorizer.go b/internal/authz/authorizer.go index db9a9583..0200fb55 100644 --- a/internal/authz/authorizer.go +++ b/internal/authz/authorizer.go @@ -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 diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go index ccf55be4..f9a0cdc3 100644 --- a/internal/authz/catalog.go +++ b/internal/authz/catalog.go @@ -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"}, diff --git a/internal/authz/service.go b/internal/authz/service.go index a4b5f197..0993eb26 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -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 } diff --git a/internal/httpx/envelope.go b/internal/httpx/envelope.go index 56c6fb52..976c67b8 100644 --- a/internal/httpx/envelope.go +++ b/internal/httpx/envelope.go @@ -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 diff --git a/internal/projectworkflow/errors.go b/internal/projectworkflow/errors.go index 009b7dba..2c431665 100644 --- a/internal/projectworkflow/errors.go +++ b/internal/projectworkflow/errors.go @@ -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") ) diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index c4722eec..baee578f 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -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. diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 2bea9f7a..441f5775 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -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") + } +} + diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 4b909857..a8bf5239 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -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 diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index 18275e41..679f6911 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -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"` } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index daf3f93f..e5273b1e 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -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") diff --git a/internal/server/cors.go b/internal/server/cors.go index 01d4ac26..4a98ee5b 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -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() diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f7d333d7..79069c3b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -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 diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 3da5d98b..ecd8e53e 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -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 diff --git a/internal/server/project_members_authz_test.go b/internal/server/project_members_authz_test.go index add76f46..d266abe6 100644 --- a/internal/server/project_members_authz_test.go +++ b/internal/server/project_members_authz_test.go @@ -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", diff --git a/internal/server/security_p0_test.go b/internal/server/security_p0_test.go index 1fe2326d..f9a3c53b 100644 --- a/internal/server/security_p0_test.go +++ b/internal/server/security_p0_test.go @@ -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) } diff --git a/internal/server/sse.go b/internal/server/sse.go index e079983c..efd23790 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -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 diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index e435656b..a3f6285b 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -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 { diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index 9e474f4c..83fb543c 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -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: