Files
WeiCHEN 9c7a5be209 feat(arch): clear G1 U3/U4 sql_write and path exceptions
Move document_records duty-slot writes into docgov, project ledger
heads through docgov/freedoc ports, and workspace deliverable I/O
through DeliverableStore so arch-check baseline drops to sql_write:1
path:13.
2026-09-22 21:40:12 +08:00

494 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
func NewAuthorizer(pool *pgxpool.Pool) *Authorizer {
return &Authorizer{pool: pool}
}
// 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 成员或创建人。
func (a *Authorizer) IsProjectMember(ctx context.Context, projectID, userID int64) (bool, error) {
if projectID <= 0 || userID <= 0 {
return false, nil
}
if !a.enabled() {
return 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(成员表或创建人)。
func (a *Authorizer) IsProjectOwner(ctx context.Context, projectID, userID int64) (bool, error) {
if projectID <= 0 || userID <= 0 {
return false, nil
}
if !a.enabled() {
return 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
}