返回探索
指南VibeFix 编辑部更新于 2026年10月10日

从一个人到一个团队:vibe 项目的 RBAC 权限模型落地实战

给 vibe coder 的 RBAC 落地实战手册:最小可用的用户-角色-权限模型、Prisma 表结构、requireRole 鉴权封装、token 邀请流程、审计日志、六个亲身踩过的坑,以及向多租户演进的 checklist——全部配完整可运行代码。

RBAC 权限模型指南封面:从 owner 到 viewer 四种角色守护团队项目

为什么你现在单用户用得好好的,迟早要交权限的"学费"

大多数 vibe 项目的开始都一样:一个人、一个想法、一个提示词,项目跑起来了,自己用得挺爽。然后三个时刻几乎一定会来,一个比一个棘手。

第一个触发点:你想把项目分享给朋友看看。不是给截图,是让朋友登录进去点两下。这一刻你会发现,你的代码里根本没有"第二个用户"这个概念——要么给所有人看所有数据,要么谁都别用。于是你开始写第一行权限代码,通常是 if (user.id === ownerId),写得到处都是。

第二个触发点:你想做团队付费版。用户说"我们团队 5 个人想一起用,有团队价吗?"你一算,这可是真钱,硬着头皮接下来。然后你发现"团队"不是"5 个账号",而是:一群人共享一份数据、有人能邀请新人、有人只能看不能改、老板要随时踢人。计费倒是简单,按人头就行;难的是那层"谁是谁、谁能干什么"的模型。

第三个触发点:有人把你项目搞坏了。你给了朋友 admin 权限想让他帮你改个配置,结果他把你三个月的生产数据删了。或者更常见:前同事离职两个月了,账号还在,到今天你都没想起来 revoke。这种事故交一次学费,你这辈子都会记得权限这件事。

我踩过这三个坑的全部。结论很简单:RBAC(基于角色的访问控制)是 vibe 项目从"玩具"到"产品"最便宜的一张门票。它不复杂,半天能做完最小可用版本;但它的坑都在细节里,本文就是把这些细节一次讲透。

先给结论:个人项目建议一开始就预留四角色(owner / admin / member / viewer),但不需要一开始就实现全部。真正要实现的只有两个动作——"鉴权守卫"和"成员管理"。其余的(邀请链接、审计日志、多租户)按 checklist 逐步加上去。

RBAC 最小可用模型:四个要素,四种角色

RBAC 的完整定义可以很学术,但落地时你只需要四个要素:

  • 用户(User):登录的人。
  • 角色(Role):owner、admin、member、viewer 这样的标签。
  • 权限(Permission):具体的动作,如 project:delete、member:invite。
  • 资源(Resource):权限作用的对象,如某个项目、某条数据。

核心思想是:用户不直接拥有权限,用户拥有角色,角色拥有权限。这样当你要改"谁可以干什么"时,改的是角色的权限定义,而不是逐个改用户。

四角色设计:够用,且经得起吵架

我见过太多项目一开始就设计了八九种角色(editor、moderator、billing_admin……),三个月后自己都记不清区别。最小可用版本只需要四个:

角色能做什么不能做什么
owner一切,包括删除项目、转让所有权——
admin邀请/移除成员、改成员角色(除 owner 外)、所有业务操作不能删除项目,不能改 owner
member正常的读写业务操作不能管理成员,不能改项目设置
viewer只读不能写、不能管理

为什么是 viewer 而不是 guest?因为"分享只读链接给朋友看看"是最高频的触发点,viewer 让这个场景天然成立。为什么 owner 只有一个?因为"两个 owner 互相踢"的故事太多了——所有权转让必须是一个显式的、带确认的单独动作(后面讲坑时会细说)。

什么时候不需要 RBAC:YAGNI 判断

YAGNI(You Aren't Gonna Need It)在这里同样适用。如果你符合以下全部条件,别做 RBAC:

  • 项目明确只有你自己用,且未来三个月没有分享计划;
  • 数据不敏感,丢了不心疼;
  • 没有付费计划。

反过来,只要命中任何一个触发点(邀请协作、团队付费、分享给朋友看),就别再用 if (user.id === ownerId) 打补丁了——这种补丁超过 5 处,维护成本就超过了做 RBAC 的成本。这是经验数字,不是理论。

RBAC 四要素与四角色关系示意图RBAC 四要素关系:用户通过角色间接获得权限,改角色定义即可批量调整权限

数据库设计:PostgreSQL + Prisma 的表结构

RBAC 的数据模型核心只有两张表:membership(谁在哪个资源里是什么角色)和可选的 audit_log。别一上来就设计 permission 表、role_permission 关联表——那是给权限粒度细到"按钮级"的企业系统用的,vibe 项目 99% 不需要。角色直接用枚举,权限用代码里的常量映射,简单、可读、好改。

下面是完整可运行的 Prisma schema。注意几个设计决策:

  • membership 用复合唯一键 (projectId, userId):一个用户在一个项目里只能有一个角色,天然防止"一个人两个角色"的脏数据。
  • 邀请用单独的 invitation 表,而不是往 membership 里塞"待接受"状态:待接受的人还不是成员,混在一起会让所有查询都多一个状态判断。
  • invitation.token 存哈希不存明文:邀请链接泄露时,数据库里拿不到可用的 token。
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum ProjectRole {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

model User {
  id            String       @id @default(cuid())
  email         String       @unique
  name          String?
  memberships   Membership[]
  invitations   Invitation[] @relation("InvitedBy")
  createdAt     DateTime     @default(now())
}

model Project {
  id          String       @id @default(cuid())
  name        String
  memberships Membership[]
  invitations Invitation[]
  auditLogs   AuditLog[]
  createdAt   DateTime     @default(now())
}

model Membership {
  id        String      @id @default(cuid())
  projectId String
  userId    String
  role      ProjectRole @default(MEMBER)

  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 一个用户在一个项目里只能有一个角色
  @@unique([projectId, userId])
  @@index([userId])
}

model Invitation {
  id        String      @id @default(cuid())
  projectId String
  email     String
  role      ProjectRole @default(MEMBER)
  // token 只存哈希,明文只出现在邀请链接里
  tokenHash String      @unique
  expiresAt DateTime
  acceptedAt DateTime?
  revokedAt  DateTime?
  invitedById String

  project   Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  invitedBy User    @relation("InvitedBy", fields: [invitedById], references: [id])

  createdAt DateTime @default(now())

  @@index([projectId, email])
}

model AuditLog {
  id        String   @id @default(cuid())
  projectId String
  actorId   String   // 谁干的
  action    String   // MEMBER_INVITED / ROLE_CHANGED / MEMBER_REMOVED ...
  targetId  String?  // 被操作的对象(用户 id)
  metadata  Json?    // 变更前后快照,如 { from: "MEMBER", to: "ADMIN" }
  createdAt DateTime @default(now())

  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@index([projectId, createdAt])
}

跑 npx prisma migrate dev --name add-rbac 即可建表。这个 schema 支撑本文后面所有代码,不用再改。

后端鉴权:requireRole 模式,一处封装处处用

后端鉴权最大的坑是"散":每个 API 路由各写一套 if 判断,半年后没人敢动。正确姿势是封装一个 requireRole,所有需要鉴权的入口都走它。思路很简单:传入"当前请求 + 项目 id + 最低角色",它返回 membership,鉴权失败直接抛错(由上层统一转成 403)。

先定义角色层级。RBAC 里"admin 能不能做 member 的事"是个经典问题,答案是能——用一个有序数组表达层级,一行代码解决:

// lib/permissions.ts
import { ProjectRole } from "@prisma/client";

// 角色层级:下标越大权限越高
export const ROLE_RANK: Record<ProjectRole, number> = {
  VIEWER: 0,
  MEMBER: 1,
  ADMIN: 2,
  OWNER: 3,
};

/** 当前角色是否满足最低角色要求(高角色自动拥有低角色权限) */
export function roleAtLeast(role: ProjectRole, minimum: ProjectRole): boolean {
  return ROLE_RANK[role] >= ROLE_RANK[minimum];
}

// 角色 → 权限字符串映射(细粒度权限需要时用,不需要时只看角色层级)
export const ROLE_PERMISSIONS: Record<ProjectRole, string[]> = {
  VIEWER: ["project:read"],
  MEMBER: ["project:read", "project:write"],
  ADMIN: ["project:read", "project:write", "member:invite", "member:manage", "settings:write"],
  OWNER: ["*"],
};

export function can(role: ProjectRole, permission: string): boolean {
  const perms = ROLE_PERMISSIONS[role];
  return perms.includes("*") || perms.includes(permission);
}

然后是核心的 requireRole。它做了三件事:拿当前登录用户、查 membership、比角色。注意它不查数据库里的 permission 表——角色层级比较是 O(1),够快,够简单:

// lib/authz.ts
import { prisma } from "@/lib/prisma";
import { getSessionUser } from "@/lib/session"; // 你的登录态封装,返回 { id } 或 null
import { ProjectRole } from "@prisma/client";
import { roleAtLeast } from "./permissions";

export class ForbiddenError extends Error {
  status = 403;
  constructor(message = "权限不足") {
    super(message);
  }
}
export class UnauthorizedError extends Error {
  status = 401;
  constructor(message = "请先登录") {
    super(message);
  }
}

export interface AuthContext {
  userId: string;
  projectId: string;
  role: ProjectRole;
}

/**
 * 鉴权守卫:要求当前用户在指定项目中至少拥有 minimum 角色。
 * 成功返回上下文(含角色),失败抛 401/403。
 */
export async function requireRole(
  projectId: string,
  minimum: ProjectRole
): Promise<AuthContext> {
  const session = await getSessionUser();
  if (!session) throw new UnauthorizedError();

  const membership = await prisma.membership.findUnique({
    where: { projectId_userId: { projectId, userId: session.id } },
  });
  if (!membership) throw new ForbiddenError("你不是该项目的成员");

  if (!roleAtLeast(membership.role, minimum)) {
    throw new ForbiddenError(`该操作需要 ${minimum} 及以上角色`);
  }
  return { userId: session.id, projectId, role: membership.role };
}

API 路由守卫:三行接入

有了 requireRole,API 路由的鉴权就剩三行:守卫 + try/catch 转状态码。建议在项目里建一个统一的错误处理 helper,所有路由共用:

// app/api/projects/[projectId]/members/route.ts
import { NextRequest, NextResponse } from "next/server";
import { requireRole, ForbiddenError, UnauthorizedError } from "@/lib/authz";
import { prisma } from "@/lib/prisma";

function toErrorResponse(e: unknown) {
  if (e instanceof UnauthorizedError)
    return NextResponse.json({ error: e.message }, { status: 401 });
  if (e instanceof ForbiddenError)
    return NextResponse.json({ error: e.message }, { status: 403 });
  console.error(e);
  return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}

// GET:成员列表 —— MEMBER 及以上可见
export async function GET(
  _req: NextRequest,
  { params }: { params: { projectId: string } }
) {
  try {
    const ctx = await requireRole(params.projectId, "MEMBER");
    const members = await prisma.membership.findMany({
      where: { projectId: ctx.projectId },
      include: { user: { select: { id: true, email: true, name: true } } },
      orderBy: { createdAt: "asc" },
    });
    return NextResponse.json({ members });
  } catch (e) {
    return toErrorResponse(e);
  }
}

// POST:邀请成员 —— ADMIN 及以上
export async function POST(
  req: NextRequest,
  { params }: { params: { projectId: string } }
) {
  try {
    const ctx = await requireRole(params.projectId, "ADMIN");
    const { email, role } = await req.json();
    // 邀请逻辑见下一节……
    return NextResponse.json({ ok: true });
  } catch (e) {
    return toErrorResponse(e);
  }
}

Server Action 鉴权:同样的守卫,直接复用

Server Action 里没有路由层,但鉴权逻辑一模一样——直接调 requireRole。千万别因为"Action 是服务端代码"就跳过鉴权,浏览器里任何人都能直接调用你的 Action:

// app/projects/[projectId]/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { requireRole } from "@/lib/authz";
import { prisma } from "@/lib/prisma";

export async function updateProjectName(projectId: string, name: string) {
  // MEMBER 及以上才能改名:守卫失败直接抛错,前端 catch 展示
  const ctx = await requireRole(projectId, "MEMBER");
  await prisma.project.update({
    where: { id: ctx.projectId },
    data: { name: name.trim().slice(0, 80) },
  });
  revalidatePath(`/projects/${projectId}`);
}

中间件:只做"是否登录",不做"角色判断"

这是一个常见的误区:想在 Next.js middleware 里做角色鉴权。别。middleware 在 edge runtime 跑,连不上数据库连接池(Prisma 在 edge 下是坑),而且每个请求都查一次 membership 是浪费。middleware 只负责两件事:未登录跳登录页、已登录放行。角色判断交给路由/Action 里的 requireRole,那里有完整的 Node runtime 和数据库。

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export async function middleware(req: NextRequest) {
  const sessionToken = req.cookies.get("session")?.value;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login");

  // 只判断登录态,不碰角色
  if (!sessionToken && !isAuthPage) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/projects/:path*", "/api/projects/:path*"],
};
后端鉴权分层示意图:中间件只验登录,路由与 Action 用 requireRole 验角色鉴权分层:middleware 只验登录态,角色判断统一收敛到 requireRole

前端:成员管理 UI 与邀请流程

成员管理 UI 的四个要点

成员管理页面不需要花哨,但四个细节决定它好不好用:

  • 角色用下拉框,但 owner 的行要锁死。owner 不能被降级、不能被移除——这两个操作按钮对 owner 行直接禁用,并在旁边写清楚"所有者不可更改,请使用转让所有权"。这是用 UI 把最危险的操作挡在外面。
  • 危险操作二次确认。移除成员、降级 admin 都要 confirm 对话框,写明后果("移除后该成员立即失去访问权限")。
  • 邀请中的邮箱单独一个分组展示,带"待接受"徽标和"重新发送 / 撤销"按钮。用户需要一眼看出"谁还没进来"。
  • 当前登录用户那一行标出来("你"),并且禁止对自己做降级/移除——防止 admin 手滑把自己踢出去然后全项目没人管。

邀请流程:token 链接 + 有效期 + 幂等

邀请流程是 RBAC 里最容易写出安全漏洞的地方。正确做法:

  1. 生成 32 字节随机 token,明文只出现在邀请链接里,数据库只存 SHA-256 哈希;
  2. 设置有效期(建议 7 天),过期作废;
  3. 幂等:如果该邮箱已有未过期的有效邀请,直接返回旧链接,不创建新记录(避免邀请人连点 5 次发出 5 封邮件);
  4. 接受邀请时做三重校验:token 哈希能对上、没过期、没被撤销/接受过。
// lib/invitations.ts
import { randomBytes, createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { ProjectRole } from "@prisma/client";

const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天

export function hashToken(token: string): string {
  return createHash("sha256").update(token).digest("hex");
}

/** 创建邀请(幂等):同一项目+邮箱已有有效邀请时直接返回旧链接 */
export async function createInvitation(opts: {
  projectId: string;
  email: string;
  role: ProjectRole;
  invitedById: string;
  baseUrl: string;
}) {
  const { projectId, email, role, invitedById, baseUrl } = opts;
  const normalizedEmail = email.trim().LowerCase();

  // 幂等:如果已有有效邀请,复用它
  const existing = await prisma.invitation.findFirst({
    where: {
      projectId,
      email: normalizedEmail,
      acceptedAt: null,
      revokedAt: null,
      expiresAt: { gt: new Date() },
    },
    orderBy: { createdAt: "desc" },
  });
  if (existing) {
    // 注意:此时已拿不到明文 token,返回一个"已发送"标记即可,
    // 不要为了复用链接而把明文 token 存下来
    return { reused: true, invitationId: existing.id };
  }

  // 该邮箱已经是成员?直接拒绝,避免发无意义的邀请
  const alreadyMember = await prisma.membership.findFirst({
    where: {
      projectId,
      user: { email: normalizedEmail },
    },
  });
  if (alreadyMember) throw new Error("该用户已经是项目成员");

  const token = randomBytes(32).toString("hex");
  const invitation = await prisma.invitation.create({
    data: {
      projectId,
      email: normalizedEmail,
      role,
      tokenHash: hashToken(token),
      expiresAt: new Date(Date.now() + INVITE_TTL_MS),
      invitedById,
    },
  });
  // TODO: 在这里发邮件,链接为 `${baseUrl}/invite/${token}`
  return { reused: false, invitationId: invitation.id, token };
}

/** 接受邀请:token 校验 + 过期检查 + 创建 membership,原子事务 */
export async function acceptInvitation(token: string, userId: string) {
  const invitation = await prisma.invitation.findUnique({
    where: { tokenHash: hashToken(token) },
  });
  if (!invitation) throw new Error("邀请链接无效");
  if (invitation.revokedAt) throw new Error("邀请已被撤销");
  if (invitation.acceptedAt) throw new Error("邀请已被使用");
  if (invitation.expiresAt < new Date()) throw new Error("邀请已过期");

  await prisma.$transaction([
    // 接受即创建成员关系;复合唯一键保证重复接受不会产生脏数据
    prisma.membership.upsert({
      where: {
        projectId_userId: { projectId: invitation.projectId, userId },
      },
      update: {}, // 已是成员则什么都不做(幂等)
      create: {
        projectId: invitation.projectId,
        userId,
        role: invitation.role,
      },
    }),
    prisma.invitation.update({
      where: { id: invitation.id },
      data: { acceptedAt: new Date() },
    }),
  ]);
  return invitation.projectId;
}

待接受状态机

一条邀请的生命周期只有四个状态,画出来比文字清楚:pending → accepted、pending → revoked(邀请人撤销)、pending → expired(超时,懒判定:查询时按 expiresAt 判断即可,不需要定时任务去扫)。注意 expired 不需要落库——这是"懒过期",所有查询都带上 expiresAt > now() 条件,过期数据自然不可见。想省空间的话,加个每周跑一次的 cron 清理过期 30 天以上的记录即可。

权限变更审计:audit log 最小实现

审计日志不是"有了再说"的锦上添花——它是你半夜被叫醒时唯一的真相来源。"谁把张三从 admin 降成 member 的?""李四是什么时候被移除的?"没有 audit log,这些问题永远答不上来。最小实现只需要:在每次成员变更时写一行记录,包含谁(actor)、干了什么(action)、对谁(target)、变更前后(metadata)。

关键设计决策:audit log 只追加不修改不删除。连 admin 都不能删审计记录——否则审计就失去了意义。实现上就是:只给它写接口,不给删接口。

// lib/audit.ts
import { prisma } from "@/lib/prisma";

export type AuditAction =
  | "MEMBER_INVITED"
  | "INVITATION_REVOKED"
  | "INVITATION_ACCEPTED"
  | "ROLE_CHANGED"
  | "MEMBER_REMOVED"
  | "OWNERSHIP_TRANSFERRED";

export async function writeAuditLog(opts: {
  projectId: string;
  actorId: string;
  action: AuditAction;
  targetId?: string;
  metadata?: Record<string, unknown>;
}) {
  await prisma.auditLog.create({ data: opts });
  // 注意:这里不提供 update/delete 封装 —— 审计记录只追加
}

// 使用示例:变更成员角色(带审计的完整流程)
export async function changeMemberRole(opts: {
  projectId: string;
  actorId: string;
  actorRole: "OWNER" | "ADMIN";
  targetUserId: string;
  newRole: "ADMIN" | "MEMBER" | "VIEWER";
}) {
  const { projectId, actorId, actorRole, targetUserId, newRole } = opts;

  const target = await prisma.membership.findUnique({
    where: { projectId_userId: { projectId, userId: targetUserId } },
  });
  if (!target) throw new Error("目标不是项目成员");
  // 铁律 1:owner 的角色谁都不能动
  if (target.role === "OWNER") throw new Error("不能修改所有者的角色");
  // 铁律 2:不能给自己提权到超过操作者(admin 不能造出另一个 admin 吗?可以,但不能造 owner)
  if (newRole === "OWNER") throw new Error("请使用转让所有权流程");
  // 铁律 3:不能修改自己的角色(防手滑把自己降级)
  if (targetUserId === actorId) throw new Error("不能修改自己的角色");

  const updated = await prisma.membership.update({
    where: { id: target.id },
    data: { role: newRole },
  });

  await writeAuditLog({
    projectId,
    actorId,
    action: "ROLE_CHANGED",
    targetId: targetUserId,
    metadata: { from: target.role, to: updated.role },
  });
  return updated;
}

常见坑:我替你踩过的六个

坑 1:只在前端做鉴权

把"删除项目"按钮对 viewer 隐藏,不等于 viewer 不能删除项目。浏览器 devtools 里改个 CSS 类,按钮就出来了;直接调你的 API,连按钮都不需要。鉴权永远以后端为准,前端隐藏只是用户体验。检验标准:关掉前端,直接 curl 你的 API,viewer 身份调删除接口应该收到 403。如果没收到,你的鉴权就是纸糊的。

坑 2:角色硬编码满天飞

if (role === "admin" || role === "owner") 这种代码出现第三次,就该抽成 roleAtLeast(role, "ADMIN")。硬编码的代价不在写的时候,在改的时候——半年后你想加个"billing"角色,得全文搜索替换 40 处,漏一处就是一个越权漏洞。

坑 3:权限缓存失效

为了性能把 membership 缓存到 Redis 或 JWT 里,很常见,也很危险:你刚把捣乱的成员降级成 viewer,他的 token 里还写着 admin,接下来的 1 小时他照样能删数据。两个解法二选一:缓存 TTL 设短(5 分钟以内),或者成员角色变更时主动失效该用户的缓存(删 Redis key / 维护一个 token 版本号)。我的建议:vibe 项目直接每次查库,membership 查询是主键/唯一键查询,1ms 级别,你根本不需要这个缓存。等你真有性能问题那天,再加也不迟。

坑 4:删除成员不断其 token

移除了成员,但他的登录 session 还有效——如果你的 session 校验只查"用户存在",他换个项目 id 照样能用(虽然 membership 没了会被 requireRole 挡住,但别依赖这个)。正确做法:移除成员时,同时 revoke 该用户在该项目上下文的 session。最小实现:在 session 里存一个 tokenVersion,移除成员时给用户自增版本号,旧 session 自然失效。

// 移除成员:删 membership + 自增 tokenVersion 让旧 session 失效 + 审计
export async function removeMember(opts: {
  projectId: string;
  actorId: string;
  targetUserId: string;
}) {
  const { projectId, actorId, targetUserId } = opts;
  const target = await prisma.membership.findUnique({
    where: { projectId_userId: { projectId, userId: targetUserId } },
  });
  if (!target) throw new Error("目标不是项目成员");
  if (target.role === "OWNER") throw new Error("不能移除所有者");
  if (targetUserId === actorId) throw new Error("不能移除自己");

  await prisma.$transaction([
    prisma.membership.delete({ where: { id: target.id } }),
    // 让该用户所有旧 session 失效(session 校验时比对 tokenVersion)
    prisma.user.update({
      where: { id: targetUserId },
      data: { tokenVersion: { increment: 1 } },
    }),
    prisma.auditLog.create({
      data: {
        projectId,
        actorId,
        action: "MEMBER_REMOVED",
        targetId: targetUserId,
        metadata: { role: target.role },
      },
    }),
  ]);
}

(需要在 User 模型上加 tokenVersion Int @default(0) 字段,登录态校验时比对即可。)

坑 5:把 owner 转让写成普通更新

所有权转让是最危险的操作,必须同时满足:① 只能由现任 owner 发起;② 目标必须是现有成员;③ 需要二次确认(UI 上单独的一个危险区,不是成员列表里的下拉框);④ 转让是原子事务——旧 owner 降为 admin、新 owner 升为 owner、写审计,三步必须在一个 transaction 里,任何一步失败全部回滚。写成普通更新的结果就是:并发点两下,项目出现两个 owner,或者零个 owner。

// 转让所有权:原子事务,缺一不可
export async function transferOwnership(opts: {
  projectId: string;
  currentOwnerId: string;
  newOwnerId: string;
}) {
  const { projectId, currentOwnerId, newOwnerId } = opts;
  if (currentOwnerId === newOwnerId) throw new Error("不能转让给自己");

  const current = await prisma.membership.findUnique({
    where: { projectId_userId: { projectId, userId: currentOwnerId } },
  });
  if (!current || current.role !== "OWNER") throw new Error("只有所有者可以转让");

  const target = await prisma.membership.findUnique({
    where: { projectId_userId: { projectId, userId: newOwnerId } },
  });
  if (!target) throw new Error("转让目标必须是项目成员");

  await prisma.$transaction([
    prisma.membership.update({
      where: { id: current.id },
      data: { role: "ADMIN" }, // 老 owner 降为 admin,不是踢出去
    }),
    prisma.membership.update({
      where: { id: target.id },
      data: { role: "OWNER" },
    }),
    prisma.auditLog.create({
      data: {
        projectId,
        actorId: currentOwnerId,
        action: "OWNERSHIP_TRANSFERRED",
        targetId: newOwnerId,
        metadata: { from: currentOwnerId, to: newOwnerId },
      },
    }),
  ]);
}

坑 6:邀请链接没有过期时间

见过太多项目的邀请链接是永久有效的:/invite/abc123 一年后还能用。员工离职了、链接被转发到群里了,任何拿到链接的人都能进。7 天有效期 + 一次性使用(接受后标记 acceptedAt),这两行代码能挡掉一整类事故。

升级路线:从单项目 RBAC 到多租户的演进 checklist

当你的用户说"我们公司有 3 个团队,每个团队管自己的项目"时,单项目的 RBAC 就不够了,需要多租户(team/org 隔离)。好消息:如果你按本文的模型做的,升级是平滑的,因为抽象层次已经对了。checklist 如下:

  • 引入 Organization 表:membership 的挂载点从 projectId 变成 orgId,角色变成"在组织里的角色"。项目归属组织,权限继承:组织 admin 自动拥有名下所有项目的 admin 权限。
  • 数据隔离加一层:所有查询从 where: { projectId } 变成 where: { project: { orgId } }。requireRole 升级为 requireOrgRole(orgId, minimum),项目级守卫在其之上做二次检查(如项目私有成员)。
  • 邀请粒度:邀请链接从项目级变成组织级,接受后获得组织角色;再由组织 admin 分配具体项目权限。
  • 计费挂钩:多租户几乎总是和按席位付费一起来的——membership 表就是你的计费依据(count where orgId=X and role != VIEWER),viewer 通常不计费,这个规则要在代码里写死,别让运营口头约定。
  • 审计升级:audit log 加上 orgId,并考虑不可变存储(append-only 表 + 数据库层面 revoke update/delete 权限给应用账号)。
  • 别做的事:不要为每个租户建一套表(schema-per-tenant),除非你有合规硬性要求。共享表 + orgId 列 + 行级隔离,对 99% 的 vibe 项目是正确的选择,运维成本差一个数量级。

演进的时机判断:当你发现自己在给"第二个组织"写 if-else 特例时,就是升级的时候。在此之前,单项目 RBAC 完全够用,别提前焦虑。

结语:权限是信任的工程化

说到底,RBAC 不是安全技术,是信任的工程化表达。"我信任你看,但不信任你删"——这句话用 viewer 角色表达,比口头约定可靠一万倍。vibe coder 的优势是快,但快的代价常常是把"人"的问题留到最后。权限这件事,越早用正确的方式做,后面付的学费越少。

按本文的顺序动手:schema 建表 → permissions.ts + authz.ts → API 路由接守卫 → 成员管理 UI → 邀请流程 → 审计日志。一个下午,你的项目就从"一个人的玩具"变成了"一个团队能用的产品"。剩下的坑,checklist 会帮你一个一个填上。

浏览项目广场发布你的项目

相关文章

开发者在电脑前编写代码,屏幕上显示 API 文档与终端窗口
指南
让 Agent 能调用你的产品:Agent 友好 API 设计指南

2026 年,调用 API 的不再只是人类开发者。这篇指南以 Stripe、GitHub 为正例、两个真实反模式为戒,拆解 Agent 友好 API 的五大支柱:契约先行、错误码规范、幂等键设计、分页契约与机器凭证,并附可直接落地的检查清单、OpenAPI 描述质量评分表与自测 prompt 模板。

后端工程AI 编程实践开发工作流
等候名单转化漏斗示意图:注册、确认、预热、激活四个环节
指南
等候名单 Waitlist 落地实战:上线前 30 天攒出第一批用户

waitlist 不是许愿池,是转化漏斗。本篇给出完整打法:四种等候名单形态与决策表、一天落地的 Next.js + Supabase 最小实现(注册接口、double opt-in、排队名次、推荐计分)、上线前 30 天每周动作节奏表、反作弊与数据清洗、Launch 当天 5 个转化动作、验证通过的指标判断线,以及排队很长但 launch 当天没人来的 3 个解法。

增长与营销创业实践Next.js
云端服务器基础设施示意图,象征应用依赖的第三方云服务
指南
供应商锁定逃生预案:一人团队的依赖分级与迁移手册

Auth 会涨价、免费层会下线、大厂亲儿子也会关门。一人团队没有法务和备选供应商谈判筹码,唯一的盔甲是:给每个外部依赖打分定级,给每个核心依赖写一页逃生预案。本文给出可直接套用的分级矩阵、预案模板、选型十问,以及 Parse、Heroku、Auth0 三个真实翻车案例的解剖。

独立开发后端工程产品策略