Unify auth middleware and remove X-User-ID trust, add project_members authorization, sandbox workspaces via resolver, and admin-only reconcile by run_id. Introduce goose migrations with startup schema gate, fail closed when DB unavailable outside explicit ALLOW_IN_MEMORY, bundle web/dist in app Dockerfile, and remove frontend pseudo-login fallback.
157 lines
4.1 KiB
Go
157 lines
4.1 KiB
Go
package authz
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
// Authorizer 项目级对象授权。
|
|
type Authorizer struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewAuthorizer(pool *pgxpool.Pool) *Authorizer {
|
|
return &Authorizer{pool: pool}
|
|
}
|
|
|
|
func (a *Authorizer) enabled() bool {
|
|
return a != nil && a.pool != nil
|
|
}
|
|
|
|
// AddOwnerOnCreate 项目创建时写入 owner 成员关系。
|
|
func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error {
|
|
if !a.enabled() || projectID <= 0 || userID <= 0 {
|
|
return nil
|
|
}
|
|
_, err := a.pool.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, RoleOwner, StatusActive)
|
|
return err
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
}
|