- FIND-SEC-01: GetUserContext requires session ownership (handler 403 + SQL/memory owner filter); in-memory HTTP + isolated-PG regressions - FIND-ARCH-01: archtest fails closed on untracked table writes (+noise exemption) and registers 50 existing writer tables in table_owners - FIND-SEC-02: Authorizer without pool denies by default; explicit NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition - FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact basename allowlist match; prefix-spoof and no-shell-eval tests - FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed hard-refuses validated_only steps (new error codes + HTTP mapping) - FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
507 lines
15 KiB
Go
507 lines
15 KiB
Go
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
|
||
}
|