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

为什么你现在单用户用得好好的,迟早要交权限的"学费"
大多数 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 四要素关系:用户通过角色间接获得权限,改角色定义即可批量调整权限
数据库设计: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*"],
};
鉴权分层:middleware 只验登录态,角色判断统一收敛到 requireRole
前端:成员管理 UI 与邀请流程
成员管理 UI 的四个要点
成员管理页面不需要花哨,但四个细节决定它好不好用:
- 角色用下拉框,但 owner 的行要锁死。owner 不能被降级、不能被移除——这两个操作按钮对 owner 行直接禁用,并在旁边写清楚"所有者不可更改,请使用转让所有权"。这是用 UI 把最危险的操作挡在外面。
- 危险操作二次确认。移除成员、降级 admin 都要 confirm 对话框,写明后果("移除后该成员立即失去访问权限")。
- 邀请中的邮箱单独一个分组展示,带"待接受"徽标和"重新发送 / 撤销"按钮。用户需要一眼看出"谁还没进来"。
- 当前登录用户那一行标出来("你"),并且禁止对自己做降级/移除——防止 admin 手滑把自己踢出去然后全项目没人管。
邀请流程:token 链接 + 有效期 + 幂等
邀请流程是 RBAC 里最容易写出安全漏洞的地方。正确做法:
- 生成 32 字节随机 token,明文只出现在邀请链接里,数据库只存 SHA-256 哈希;
- 设置有效期(建议 7 天),过期作废;
- 幂等:如果该邮箱已有未过期的有效邀请,直接返回旧链接,不创建新记录(避免邀请人连点 5 次发出 5 封邮件);
- 接受邀请时做三重校验: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 会帮你一个一个填上。
相关文章

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

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

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