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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user