Files
weichen 4258d88415 feat(authz): add AuthorizationService, capabilities API, and project members CRUD
Wire authz service into server, expose capabilities on /auth/me, and add project member management routes with owner/admin gates.
2026-09-13 02:50:22 +00:00

292 lines
8.1 KiB
Go

package authz
import (
"context"
"errors"
"fmt"
"time"
"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"
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"`
Status string `json:"status"`
JoinedAt string `json:"joined_at,omitempty"`
}
// 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()
}
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, 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.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()
}
// UpsertProjectMember 新增或更新成员角色(禁止 docgov 专用角色名)。
func (a *Authorizer) UpsertProjectMember(ctx context.Context, projectID, userID int64, projectRole string) error {
if projectID <= 0 || userID <= 0 {
return fmt.Errorf("authz: invalid project or user id")
}
if err := validateProjectRole(projectRole); err != nil {
return err
}
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)
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, projectRole, StatusActive)
return err
}
// SetProjectMemberStatus 更新成员状态(如 exited)。
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
}
tag, err := a.pool.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")
}
return nil
}
// 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
}