Files
Agent-System/internal/authz/authorizer.go
T
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- 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
2026-09-26 02:00:14 +00:00

507 lines
15 KiB
Go
Raw 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
// 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
}