package authz import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/docduty" ) var ( ErrForbidden = errors.New("authz: forbidden") ErrProjectNotFound = errors.New("authz: project not found") ErrUnauthenticated = errors.New("authz: unauthenticated") ) const ( RoleOwner = "owner" RoleMember = "member" RoleViewer = "viewer" StatusActive = "active" StatusExited = "exited" ) var allowedProjectRoles = map[string]struct{}{ RoleOwner: {}, RoleMember: {}, RoleViewer: {}, } // ProjectMemberRecord 项目成员 API 视图。 type ProjectMemberRecord struct { ID int64 `json:"id,omitempty"` ProjectID int64 `json:"project_id,omitempty"` UserID int64 `json:"user_id"` Username string `json:"username,omitempty"` Name string `json:"name,omitempty"` ProjectRole string `json:"project_role"` DocumentDuty string `json:"document_duty,omitempty"` Status string `json:"status"` JoinedAt string `json:"joined_at,omitempty"` } // MemberUpsert 项目访问角色 + 可选文档职责。 type MemberUpsert struct { ProjectRole string DocumentDuty *string } // DutySlotWriter clears document_records reviewer/approver slots (implemented by docgov). // authz must not import docgov; the composition root injects the adapter. type DutySlotWriter interface { ClearSlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error } // Authorizer 项目级对象授权。 type Authorizer struct { pool *pgxpool.Pool dutySlots DutySlotWriter // inMemory 显式声明「无库演示模式」:仅 NewInMemoryAuthorizer 构建。 // NewAuthorizer(nil) 一律严格拒绝(FIND-SEC-02 回归),防止误配 // (无库且未显式 ALLOW_IN_MEMORY)时任意登录用户获得项目访问。 inMemory bool } func NewAuthorizer(pool *pgxpool.Pool) *Authorizer { return &Authorizer{pool: pool} } // NewInMemoryAuthorizer 仅供显式 ALLOW_IN_MEMORY 演示/测试模式:无库时 // 视任意登录用户为项目成员(单用户演示语义)。生产/DB 路径禁止使用。 func NewInMemoryAuthorizer() *Authorizer { return &Authorizer{inMemory: true} } // SetDutySlotWriter injects the document_records slot clearer (docgov adapter). func (a *Authorizer) SetDutySlotWriter(w DutySlotWriter) { if a == nil { return } a.dutySlots = w } func (a *Authorizer) clearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error { if a == nil || len(revs) == 0 { return nil } if a.dutySlots == nil { return fmt.Errorf("authz: duty slot writer required") } return a.dutySlots.ClearSlots(ctx, tx, projectID, revs) } 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 return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{ ProjectRole: RoleOwner, DocumentDuty: &duty, }) } // IsProjectMember 判断用户是否为项目 active 成员或创建人。 // FIND-SEC-02 回归:无池(且非显式内存模式)时 fail-closed 返回 false, // 不再默认放行任意登录用户;与 GetProjectMember 的禁用语义对齐。 func (a *Authorizer) IsProjectMember(ctx context.Context, projectID, userID int64) (bool, error) { if projectID <= 0 || userID <= 0 { return false, nil } if !a.enabled() { return a.inMemory && userID > 0, nil } var member bool err := a.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM project_members pm WHERE pm.project_id = $1 AND pm.user_id = $2 AND pm.status = $3 ) OR EXISTS ( SELECT 1 FROM projects p WHERE p.id = $1 AND p.creator_user_id = $2 ) `, projectID, userID, StatusActive).Scan(&member) return member, err } // RequireProjectAccess 要求用户可访问项目。 func (a *Authorizer) RequireProjectAccess(ctx context.Context, projectID, userID int64) error { if userID <= 0 { return ErrUnauthenticated } ok, err := a.IsProjectMember(ctx, projectID, userID) if err != nil { return err } if !ok { return ErrForbidden } return nil } // RequireProjectAccessOrAdmin 管理员可跨项目访问。 func (a *Authorizer) RequireProjectAccessOrAdmin(ctx context.Context, projectID, userID int64, isAdmin bool) error { if isAdmin { return nil } return a.RequireProjectAccess(ctx, projectID, userID) } // BackfillCreatorMembers 为既有项目补齐 creator owner 记录(迁移后一次性)。 func (a *Authorizer) BackfillCreatorMembers(ctx context.Context) error { if !a.enabled() { return nil } _, err := a.pool.Exec(ctx, ` INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) SELECT p.id, p.creator_user_id, $1, $2, now(), now() FROM projects p WHERE p.creator_user_id IS NOT NULL AND p.creator_user_id > 0 ON CONFLICT (project_id, user_id) DO NOTHING `, RoleOwner, StatusActive) return err } // ListVisibleProjectIDs 返回用户可见项目 ID(成员或创建人)。 func (a *Authorizer) ListVisibleProjectIDs(ctx context.Context, userID int64) ([]int64, error) { if !a.enabled() { return nil, nil } rows, err := a.pool.Query(ctx, ` SELECT DISTINCT p.id FROM projects p LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = $2 WHERE pm.user_id IS NOT NULL OR p.creator_user_id = $1 ORDER BY p.id DESC `, userID, StatusActive) if err != nil { return nil, fmt.Errorf("list visible projects: %w", err) } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func validateProjectRole(role string) error { if _, ok := allowedProjectRoles[role]; !ok { return fmt.Errorf("authz: invalid project_role %q", role) } return nil } // IsProjectOwner 判断用户是否为项目 owner(成员表或创建人)。 // FIND-SEC-02 回归:无池时 fail-closed,同 IsProjectMember。 func (a *Authorizer) IsProjectOwner(ctx context.Context, projectID, userID int64) (bool, error) { if projectID <= 0 || userID <= 0 { return false, nil } if !a.enabled() { return a.inMemory && userID > 0, nil } var owner bool err := a.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM project_members pm WHERE pm.project_id = $1 AND pm.user_id = $2 AND pm.status = $3 AND pm.project_role = $4 ) OR EXISTS ( SELECT 1 FROM projects p WHERE p.id = $1 AND p.creator_user_id = $2 ) `, projectID, userID, StatusActive, RoleOwner).Scan(&owner) return owner, err } // ListProjectMembers 列出项目 active/exited 成员。 func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([]ProjectMemberRecord, error) { if projectID <= 0 { return nil, nil } if !a.enabled() { return nil, nil } rows, err := a.pool.Query(ctx, ` SELECT pm.id, pm.project_id, pm.user_id, COALESCE(NULLIF(u.username, ''), u.account_id, '') AS username, COALESCE(u.name, '') AS name, pm.project_role, COALESCE(pm.document_duty, ''), pm.status, pm.joined_at FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = $1 ORDER BY pm.joined_at ASC, pm.user_id ASC `, projectID) if err != nil { return nil, fmt.Errorf("list project members: %w", err) } defer rows.Close() out := make([]ProjectMemberRecord, 0) for rows.Next() { var rec ProjectMemberRecord var joined time.Time if err := rows.Scan( &rec.ID, &rec.ProjectID, &rec.UserID, &rec.Username, &rec.Name, &rec.ProjectRole, &rec.DocumentDuty, &rec.Status, &joined, ); err != nil { return nil, err } rec.JoinedAt = joined.Format("2006-01-02 15:04:05") out = append(out, rec) } return out, rows.Err() } // GetProjectMember 读取单个成员(含已退出)。无记录或禁用时返回 nil, nil。 func (a *Authorizer) GetProjectMember(ctx context.Context, projectID, userID int64) (*ProjectMemberRecord, error) { if a == nil || !a.enabled() || projectID <= 0 || userID <= 0 { return nil, nil } var rec ProjectMemberRecord var joined time.Time err := a.pool.QueryRow(ctx, ` SELECT pm.id, pm.project_id, pm.user_id, COALESCE(NULLIF(u.username, ''), u.account_id, '') AS username, COALESCE(u.name, '') AS name, pm.project_role, COALESCE(pm.document_duty, ''), pm.status, pm.joined_at FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = $1 AND pm.user_id = $2 `, projectID, userID).Scan( &rec.ID, &rec.ProjectID, &rec.UserID, &rec.Username, &rec.Name, &rec.ProjectRole, &rec.DocumentDuty, &rec.Status, &joined, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get project member: %w", err) } rec.JoinedAt = joined.Format("2006-01-02 15:04:05") return &rec, nil } // UpsertProjectMember 新增或更新成员访问角色(禁止把 author/reviewer/approver 写入 project_role)。 func (a *Authorizer) UpsertProjectMember(ctx context.Context, projectID, userID int64, projectRole string) error { return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{ProjectRole: projectRole}) } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func isRetryableDutyConflict(err error) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } switch pgErr.Code { case "23505", "40P01", "40001": return true default: return false } } // UpsertProjectMemberSpec 写入访问角色,并可选独占 document_duty。 // 写入 document_duty 时,同一事务内:项目 advisory lock、读取旧/冲突 holder、 // 成员更新、精确清空 document_records 的 reviewer/approver 槽;不改 author。 // 并发抢同一职责时重试,收敛为 last-writer,并依赖部分唯一索引保证无双 reviewer。 func (a *Authorizer) UpsertProjectMemberSpec(ctx context.Context, projectID, userID int64, spec MemberUpsert) error { if projectID <= 0 || userID <= 0 { return fmt.Errorf("authz: invalid project or user id") } if err := validateProjectRole(spec.ProjectRole); err != nil { return err } var duty string var err error if spec.DocumentDuty != nil { duty, err = docduty.NormalizeDocumentDuty(*spec.DocumentDuty) if err != nil { return err } } if !a.enabled() { return nil } const maxAttempts = 4 var last error for attempt := 0; attempt < maxAttempts; attempt++ { last = a.upsertProjectMemberSpecOnce(ctx, projectID, userID, spec, duty) if last == nil || !isRetryableDutyConflict(last) { return last } } return last } func (a *Authorizer) upsertProjectMemberSpecOnce(ctx context.Context, projectID, userID int64, spec MemberUpsert, duty string) error { tx, err := a.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if spec.DocumentDuty != nil { if err := a.applyDutyWriteInTx(ctx, tx, projectID, userID, spec.ProjectRole, duty); err != nil { return err } return tx.Commit(ctx) } _, err = tx.Exec(ctx, ` INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (project_id, user_id) DO UPDATE SET project_role = EXCLUDED.project_role, status = EXCLUDED.status, updated_at = now() `, projectID, userID, spec.ProjectRole, StatusActive) if err != nil { return err } return tx.Commit(ctx) } // SetProjectMemberStatus 更新成员状态(如 exited)。 // 退出时与 upsert 相同:advisory lock、读取旧 duty、状态更新、精确清槽在同一事务。 func (a *Authorizer) SetProjectMemberStatus(ctx context.Context, projectID, userID int64, status string) error { if projectID <= 0 || userID <= 0 { return fmt.Errorf("authz: invalid project or user id") } if status != StatusActive && status != StatusExited { return fmt.Errorf("authz: invalid member status %q", status) } if !a.enabled() { return nil } const maxAttempts = 4 var last error for attempt := 0; attempt < maxAttempts; attempt++ { last = a.setProjectMemberStatusOnce(ctx, projectID, userID, status) if last == nil || !isRetryableDutyConflict(last) { return last } } return last } func (a *Authorizer) setProjectMemberStatusOnce(ctx context.Context, projectID, userID int64, status string) error { tx, err := a.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil { return err } prevDuty, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, userID) if err != nil { return err } tag, err := tx.Exec(ctx, ` UPDATE project_members SET status = $3, updated_at = now() WHERE project_id = $1 AND user_id = $2 `, projectID, userID, status) if err != nil { return err } if tag.RowsAffected() == 0 { return fmt.Errorf("authz: project member not found") } if status == StatusExited { if err := a.clearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{ UserID: userID, PreviousDuty: prevDuty, NextDuty: prevDuty, Exited: true, })); err != nil { return err } } return tx.Commit(ctx) } // applyDutyWriteInTx 假定调用方已 Begin:lock → 读旧/冲突 holder → 独占写成员 → 清槽。 func (a *Authorizer) applyDutyWriteInTx(ctx context.Context, tx pgx.Tx, projectID, userID int64, projectRole, duty string) error { if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil { return err } prevDuty, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, userID) if err != nil { return err } holders, err := docduty.LoadActiveSlotHoldersTx(ctx, tx, projectID) if err != nil { return err } if duty != docduty.DutyNone { if _, err := tx.Exec(ctx, ` UPDATE project_members SET document_duty = '', updated_at = now() WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4 `, projectID, duty, userID, StatusActive); err != nil { return err } } if _, err := tx.Exec(ctx, ` INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) VALUES ($1, $2, $3, $4, $5, now(), now()) ON CONFLICT (project_id, user_id) DO UPDATE SET project_role = EXCLUDED.project_role, document_duty = EXCLUDED.document_duty, status = EXCLUDED.status, updated_at = now() `, projectID, userID, projectRole, duty, StatusActive); err != nil { return err } return a.clearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{ UserID: userID, PreviousDuty: prevDuty, NextDuty: duty, PriorHolderIDs: holders, })) } // TableExists 检查 project_members 表是否存在。 func TableExists(ctx context.Context, pool *pgxpool.Pool, table string) (bool, error) { if pool == nil { return false, nil } var exists bool err := pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) `, table).Scan(&exists) if errors.Is(err, pgx.ErrNoRows) { return false, nil } return exists, err }