关于 convex-authz
@djpanda/convex-authz 是供 Convex 应用使用的 TypeScript 授权组件,整合基于角色、属性和关系的访问控制。它提供索引权限检查,并支持 React 响应式集成。
主要功能
- 01支持 RBAC、ABAC 和 ReBAC
- 02索引化有效权限检查
- 03作用域角色与资源权限
- 04权限审计日志和限时授权
- 05关系遍历与权限映射
- 06React 响应式 Hooks 和 PermissionGate
适用场景
- 01控制文档和资源的访问权限
- 02建模团队或组织关系
- 03应用基于属性的访问策略
- 04架构变更后重建权限
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dbjpanda/ convex-tenants --skill convex-authz安装后在 Claude Code 中输入/convex-authz确认已加载。
详细介绍
@djpanda/convex-authz
一个全面、可用于生产环境的 Convex 授权组件,支持 RBAC、ABAC 和 ReBAC,并提供 O(1) 索引查找,灵感来自 Google Zanzibar。
功能
| 功能 | 描述 |
|---|---|
| RBAC | 基于角色的访问控制,支持限定范围的角色 |
| ABAC | 基于属性的访问控制,支持自定义策略 |
| ReBAC | 基于关系的访问控制,支持图遍历 |
| O(1) 查找 | 预计算权限,实现即时检查 |
| 类型安全 | 完整的 TypeScript 支持,权限具有类型安全性 |
| 审计日志 | 跟踪所有权限变更和检查 |
| 限定范围的权限 | 资源级访问控制 |
| 可过期授权 | 有时限的角色分配和权限 |
| Convex 原生支持 | 专为 Convex 构建,支持实时更新 |
术语
| 术语 | 定义 |
|---|---|
| RBAC | 基于角色的访问控制 - 通过角色(管理员、编辑者、查看者)分配权限 |
| ABAC | 基于属性的访问控制 - 根据用户/资源属性(department=engineering)授予权限 |
| ReBAC | 基于关系的访问控制 - 根据关系派生权限(属于拥有某资源的团队) |
| Zanzibar | Google 的全局授权系统,是 OpenFGA 和此组件的灵感来源 |
| 元组 | 一个关系三元组:(subject, relation, object),例如 (user:alice, member, team:sales) |
| 范围 | 资源级权限上下文,例如“team:123 的管理员”与全局“管理员” |
| 遍历 | 沿关系链追踪,以确定继承的访问权限 |
| O(1) 查找 | 通过预计算索引,以恒定时间检查权限 |
| 权限覆盖 | 绕过基于角色的权限,直接授予/拒绝访问 |
安装
npm install @djpanda/convex-authz
快速开始
1. 注册组件
// convex/convex.config.ts
import { defineApp } from "convex/server";
import authz from "@djpanda/convex-authz/convex.config";
const app = defineApp();
app.use(authz);
export default app;
2. 定义权限和角色
// convex/authz.ts
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
// Step 1: Define permissions
const permissions = definePermissions({
documents: {
create: true,
read: true,
update: true,
delete: true,
},
settings: {
view: true,
manage: true,
},
});
// Step 2: Define roles
const roles = defineRoles(permissions, {
admin: {
documents: ["create", "read", "update", "delete"],
settings: ["view", "manage"],
},
editor: {
documents: ["create", "read", "update"],
settings: ["view"],
},
viewer: {
documents: ["read"],
},
});
// Step 3: Create the authz client
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
角色继承与组合
可以根据其他角色定义角色,以避免重复列出权限:
**inherits**– 一个父角色;有效权限 = 父角色的权限 ∪ 此角色的直接权限。**includes**– 多个角色;有效权限 = 所有包含角色的权限并集 ∪ 此角色的直接权限。
继承示例(admin > editor > viewer):
const roles = defineRoles(permissions, {
viewer: { documents: ["read"] },
editor: { inherits: "viewer", documents: ["create", "update"] },
admin: { inherits: "editor", documents: ["delete"], settings: ["manage"] },
});
组合示例(合并角色):
const roles = defineRoles(permissions, {
editor: { documents: ["create", "read", "update"] },
billing_admin: { billing: ["view", "manage"] },
billing_manager: { includes: ["editor", "billing_admin"], settings: ["view"] },
});
注意: inherits 和 includes 是角色定义中的保留键;请勿将它们用作权限资源名称。
3. 在函数中使用
// convex/documents.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { authz } from "./authz";
import { getAuthUserId } from "@convex-dev/auth/server";
export const updateDocument = mutation({
args: { docId: v.id("documents"), content: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
// Check permission (throws if denied)
await authz.require(ctx, userId, "documents:update");
// Or with scope
await authz.require(ctx, userId, "documents:update", {
type: "document",
id: args.docId,
});
// Proceed with update...
},
});
统一授权 v2
v2 将所有内容整合到一个 Authz 类中。如果你之前使用的是 IndexedAuthz,只需将其重命名即可——构造函数签名相同。
变更内容
- 一个类:
Authz取代了原来的Authz和IndexedAuthz。通过预先计算有效权限表实现的 O(1) 读取现在是默认行为。 Authz上的 ReBAC:每个Authz实例都可以直接使用hasRelation、addRelation和removeRelation。- ABAC 策略类型:策略接受一个
type字段("static"或"deferred")。在当前实现中,调用can()时会在读取阶段评估这两种类型——Convex mutation 无法调用 query,因此无法在写入时进行评估。type字段是为未来优化预留的,目前不会造成任何行为差异。 **canWithContext()**:检查需要运行时上下文(例如 IP 地址、一天中的时间)的 deferred ABAC 策略。**recomputeUser()**:按需重建用户的有效权限表——适用于架构变更后或部署后的迁移。**withTenant()**:获取绑定到其他租户的客户端副本,用于跨租户管理操作。
ReBAC 示例
// Add a relationship
await authz.addRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
// Check a relationship
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
// Remove a relationship
await authz.removeRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
ReBAC → 权限桥接
使用 defineRelationPermissions,在创建关系时自动授予权限:
import { defineRelationPermissions } from "@djpanda/convex-authz";
const authz = new Authz(components.authz, {
permissions, roles, tenantId: "my-app",
relationPermissions: defineRelationPermissions({
"document:viewer": ["documents:read"],
"document:editor": ["documents:read", "documents:update"],
"document:owner": ["documents:read", "documents:update", "documents:delete"],
}),
});
// Adding a relation automatically grants scoped permissions
await authz.addRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });
// can() now checks relation-derived permissions — no separate hasRelation() needed
const canUpdate = await authz.can(ctx, userId, "documents:update", { type: "document", id: docId }); // true
// Removing the relation revokes the permissions
await authz.removeRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });
ABAC 示例
// Policies are always evaluated at read-time when can() is called.
// The optional type field ("static" | "deferred") is reserved for future use
// and currently has no behavioral effect.
const policies = definePolicies({
"documents:read": {
condition: (ctx) => ctx.getAttribute("verified") === true,
message: "Only verified users can read documents",
},
"billing:export": {
condition: (ctx) => {
const hour = new Date().getUTCHours();
return hour >= 9 && hour <= 17;
},
message: "Billing exports only during business hours",
},
});
// Use canWithContext() when request context is available
const allowed = await authz.canWithContext(ctx, userId, "documents:read", undefined, {
ipAllowlisted: true,
});
部署后重建
// Rebuild a single user's effective permissions after upgrading
await authz.recomputeUser(ctx, userId);
跨租户操作
const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");
迁移指南:IndexedAuthz → Authz
注意: v2 不再导出
IndexedAuthz。下面的导入会失败——只需将其替换为Authz。
// Before (v1) — this import no longer works in v2
// import { IndexedAuthz } from "@djpanda/convex-authz";
// const authz = new IndexedAuthz(components.authz, { permissions, roles, tenantId: "my-app" });
// After (v2) — same constructor, just rename the class
import { Authz } from "@djpanda/convex-authz";
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
升级后,对每个现有用户运行 recomputeUser(),以回填有效权限表:
// one-time migration mutation
export const backfillEffectivePermissions = mutation({
args: {},
handler: async (ctx) => {
const users = await ctx.db.query("users").collect();
for (const user of users) {
await authz.recomputeUser(ctx, String(user._id));
}
},
});
React 集成
此软件包提供 React hooks 和 PermissionGate 组件,让你的 UI 能够以响应式方式检查权限和角色。你的应用必须公开封装 Authz 组件的 Convex queries(例如 checkPermission、getUserRoles)。这些 hooks 通过 Convex 的 useQuery 调用这些 queries,因此权限和角色变更会及时更新,无需轮询。
1. 公开 Convex queries
定义委托给 authz 客户端的 queries,例如:
// convex/app.ts (or similar)
import { query } from "./_generated/server";
import { v } from "convex/values";
import { authz } from "./authz";
export const checkPermission = query({
args: {
userId: v.string(),
permission: v.string(),
scope: v.optional(v.object({ type: v.string(), id: v.string() })),
},
handler: async (ctx, args) => {
return authz.can(ctx, args.userId, args.permission, args.scope);
},
});
export const getUserRoles = query({
args: {
userId: v.string(),
scope: v.optional(v.object({ type: v.string(), id: v.string() })),
},
handler: async (ctx, args) => {
return authz.getUserRoles(ctx, args.userId, args.scope);
},
});
2. 使用 AuthzProvider 包装应用
将 Convex query refs(以及可选的默认用户 ID)传递给 provider:
import { AuthzProvider } from "@djpanda/convex-authz/react";
import { api } from "./convex/_generated/api";
<AuthzProvider
queryRefs={{
checkPermission: api.app.checkPermission,
getUserRoles: api.app.getUserRoles,
}}
defaultUserId={currentUserId} // optional; hooks can pass userId in options
>
<App />
</AuthzProvider>
3. 使用 hooks 和 PermissionGate
- useCanUser(permission, options?) — 返回
{ allowed, isLoading, error }。选项:{ userId?, scope? }。省略userId时,使用 provider 中的defaultUserId。 - useUserRoles(options?) — 返回
{ roles, isLoading, error }。选项:{ userId?, scope? }。 - useRequirePermission(permission, options?) — 用户未获准访问时抛出错误(使用错误边界显示拒绝访问状态)。
- PermissionGate — 获准时渲染
children,被拒绝时渲染fallback,加载期间渲染loadingFallback(可选)。
import {
useCanUser,
useUserRoles,
useRequirePermission,
PermissionGate,
} from "@djpanda/convex-authz/react";
function DocumentList() {
const { allowed, isLoading } = useCanUser("documents:read");
if (isLoading) return <Spinner />;
if (!allowed) return <p>You cannot view documents.</p>;
return <div>{/* list */}</div>;
}
function AdminPanel() {
useRequirePermission("settings:manage"); // throws if denied; wrap in error boundary
return <div>Admin content</div>;
}
function EditButton({ docId }: { docId: string }) {
return (
<PermissionGate
permission="documents:update"
scope={{ type: "document", id: docId }}
fallback={<span>No access</span>}
loadingFallback={<span>Checking…</span>}
>
<button>Edit</button>
</PermissionGate>
);
}
Convex 的响应式机制可确保后端的权限或角色变更时,hooks 和 PermissionGate 会重新运行,并自动更新 UI。
架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ @djpanda/convex-authz │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ RBAC │ │ ABAC │ │ ReBAC │ │
│ │ Role-Based │ │ Attribute-Based │ │ Relationship-Based │ │
│ │ Access Control │ │ Access Control │ │ Access Control │ │
│ │ │ │ │ │ │ │
│ │ • Roles │ │ • User attrs │ │ • Tuples (S, R, O) │ │
│ │ • Permissions │ │ • Policies │ │ • Graph traversal │ │
│ │ • Scopes │ │ • Conditions │ │ • Inheritance │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ O(1) Indexed Permission Cache │ │
│ │ │ │
│ │ effectivePermissions │ effectiveRoles │ effectiveRelationships │ │
│ │ [user, perm, scope] │ [user, role] │ [subject, rel, object] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
RBAC(基于角色的访问控制)
分配角色
// Global role
await authz.assignRole(ctx, userId, "admin");
// Scoped role (e.g., admin of a specific team)
await authz.assignRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});
// With expiration (24 hours)
await authz.assignRole(ctx, userId, "admin", undefined, Date.now() + 86400000);
撤销角色
await authz.revokeRole(ctx, userId, "admin");
// Scoped
await authz.revokeRole(ctx, userId, "admin", { type: "team", id: "team_123" });
检查权限
// Boolean check
const canEdit = await authz.can(ctx, userId, "documents:update");
// Throws if denied
await authz.require(ctx, userId, "documents:update");
// With scope
const canEditTeamDocs = await authz.can(ctx, userId, "documents:update", {
type: "team",
id: "team_123",
});
检查角色
const isAdmin = await authz.hasRole(ctx, userId, "admin");
// Scoped
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});
通配符和模式匹配权限
权限检查和覆盖支持通配符模式,因此你可以一次性授予或拒绝一整类权限。
模式格式:resource:action。resource 或 action(也可以两者)可以是 *:
| 模式 | 含义 | 匹配示例 |
|---|---|---|
* | 所有权限 | documents:read、settings:manage |
documents:* | documents 上的所有操作 | documents:read、documents:update |
*:read | 对任意资源的读取权限 | documents:read、settings:read |
*:* | 所有权限(与 * 相同) | 任意 resource:action |
**检查:*调用 can(ctx, userId, "documents:read") 或 require(ctx, userId, "documents:read") 时,后端会将任何与该权限匹配的已存储角色或覆盖视为授予了该权限。因此,如果用户拥有包含 documents: 的角色,或有 *:read 覆盖,则其可以执行 documents:read。
**分配:**你可以将模式传递给 grantPermission 和 denyPermission:
// Grant all document actions
await authz.grantPermission(ctx, userId, "documents:*", undefined, "Full document access");
// Deny read on any resource
await authz.denyPermission(ctx, userId, "*:read", undefined, "Read access revoked");
**角色定义:**组件评估权限时,会使用相同的模式规则,将请求的权限与每个角色的权限列表进行匹配。因此,如果角色的权限包含 "documents:*"(在扁平化的角色–权限映射中),则允许调用 can(ctx, userId, "documents:read")。使用 defineRoles 时,通常会列出每个资源的具体操作(例如 documents: ["read", "update"]);若要在角色中使用模式,则应提供包含该角色模式字符串的角色–权限映射。
**客户端辅助函数:**若要在不调用后端的情况下测试某个模式是否与权限匹配,请使用导出的辅助函数:
import { matchesPermissionPattern } from "@djpanda/convex-authz";
matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read"); // true
matchesPermissionPattern("settings:read", "documents:*"); // false
所有权限检查都适用相同的通配符行为。
获取用户角色
const roles = await authz.getUserRoles(ctx, userId);
// Returns: [{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]
批量操作和用户离职处理
对于大规模或企业级工作流,API 支持批量权限检查(最多 100 项权限)和角色更新(最多 20 个角色),并可在一次调用中完成。
批量权限检查(canAny)
通过一次往返检查用户是否拥有给定权限中的任意一项:
const allowed = await authz.canAny(ctx, userId, [
"documents:read",
"documents:update",
"documents:delete",
], scope);
// true if the user has at least one of these permissions
批量分配和撤销角色
在单个事务中为一个用户分配或撤销多个角色:
// Assign multiple roles at once (max 20 per call)
const { assigned, assignmentIds } = await authz.assignRoles(ctx, userId, [
{ role: "admin" },
{ role: "editor", scope: { type: "team", id: "team_1" } },
{ role: "viewer", scope: { type: "org", id: "org_1" }, expiresAt: Date.now() + 86400000 },
], actorId);
// Revoke multiple roles at once (max 20 per call)
const { revoked } = await authz.revokeRoles(ctx, userId, [
{ role: "editor", scope: { type: "team", id: "team_1" } },
{ role: "viewer" },
], actorId);
撤销所有角色
撤销用户的所有角色(可选地仅限于指定范围):
const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);
完整的用户离职处理
通过一次调用移除用户的所有角色、权限覆盖、属性,以及可选的 ReBAC 关系(可选地限定范围)。如果存在,也会清除已索引的 effectiveRoles、effectivePermissions 和 effectiveRelationships:
const result = await authz.offboardUser(ctx, userId, {
scope: { type: "org", id: "org_1" }, // optional: only remove data in this scope
actorId: "system",
removeAttributes: true, // default true
removeOverrides: true, // default true
removeRelationships: true, // default true when no scope (full offboard)
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }
省略范围时,该调用会执行完整停用:移除所有角色、覆盖、属性,以及所有以该用户为主体的 ReBAC 关系。提供范围时,只会移除该范围内的数据,关系保持不变。
用户停用(完全清除)
对于安全事件响应、企业级离职处理或一键停用,请使用 deprovisionUser 原子性地清除用户的所有角色、属性、关系和权限覆盖(不指定范围或选项):
const result = await authz.deprovisionUser(ctx, userId, {
actorId: "security-team",
enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }
每次调用的批量数组数量有限制:canAny 中的权限最多 100 项,assignRoles / revokeRoles 中的角色最多 20 项。客户端和组件会进行校验,超出限制时会抛出明确的错误。
ABAC(基于属性的访问控制)
设置用户属性
await authz.setAttribute(ctx, userId, "department", "engineering");
await authz.setAttribute(ctx, userId, "clearanceLevel", 5);
await authz.setAttribute(ctx, userId, "location", { country: "US", state: "CA" });
获取属性
const attributes = await authz.getUserAttributes(ctx, userId);
// Returns: [{ key: "department", value: "engineering" }, { key: "clearanceLevel", value: 5 }]
定义策略
condition 函数可以返回 boolean 或 Promise<boolean>,因此你可以使用异步逻辑(例如查询数据库或调用外部 API)。
import { definePolicies, evaluatePolicyCondition } from "@djpanda/convex-authz";
const policies = definePolicies({
"documents:update": {
// User can update if they own the document (sync)
condition: (ctx) => ctx.resource?.ownerId === ctx.subject.userId,
message: "Only document owners can update",
},
"reports:view": {
// Only engineering department with clearance >= 3 (sync)
condition: (ctx) =>
ctx.subject.attributes.department === "engineering" &&
(ctx.subject.attributes.clearanceLevel as number) >= 3,
message: "Requires engineering department with clearance level 3+",
},
"documents:delete": {
// Async: e.g. check external service or fetch extra data
condition: async (ctx) => {
const doc = await getDocument(ctx.resource?.id);
return doc != null && doc.ownerId === ctx.subject.userId;
},
message: "Only document owners can delete",
},
});
const authz = new Authz(components.authz, { permissions, roles, policies, tenantId: "my-app" });
评估策略时(例如 RBAC 允许之后),务必始终对条件使用 await,这样同步和异步策略都能正常运行。使用 evaluatePolicyCondition 将其规范化为 Promise:
const policy = policies["documents:update"];
if (policy) {
const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
if (!allowed) throw new Error(policy.message ?? "Permission denied");
}
策略上下文
策略接收一个上下文对象,其中包含:
interface PolicyContext {
subject: {
userId: string;
roles: string[];
attributes: Record<string, unknown>;
};
resource?: {
type: string;
id: string;
[key: string]: unknown; // Resource data
};
action: string; // The permission being checked
environment?: {
timestamp: number;
ip?: string;
};
hasRole: (role: string) => boolean;
hasAttribute: (key: string) => boolean;
getAttribute: <T = unknown>(key: string, defaultValue?: T) => T | undefined;
}
API 说明: 现有的同步条件仍然有效。没有破坏性变更;仅扩展返回类型,以支持异步策略返回 Promise<boolean>。
ReBAC(基于关系的访问控制)
ReBAC 根据实体之间的关系实现访问控制,非常适合 CRM、文档共享和组织结构等分层系统。
关系模型
关系以元组形式存储:(subject, relation, object)
user:alice ──member──► team:sales
team:sales ──owner──► account:acme
account:acme ──parent──► deal:big_deal
添加关系
// Use the Authz client — NOT direct component calls
// User is member of team
await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// Team owns account
await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme" });
检查直接关系
// Use the Authz client — NOT direct component calls
const isMember = await authz.hasRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// Returns: true
// To remove a relationship
await authz.removeRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
关系遍历(继承访问权限)
ReBAC 的真正优势在于能够通过关系链检查访问权限:
// Define how permissions flow through relationships
const traversalRules = {
// A deal viewer is anyone who can view the parent account
"deal:viewer": [
{ through: "account", via: "parent", inherit: "viewer" }
],
// An account viewer is any member of the owning team
"account:viewer": [
{ through: "team", via: "owner", inherit: "member" }
],
};
// Graph traversal is available via the component query directly
// Check: Can alice view big_deal?
const result = await ctx.runQuery(components.authz.rebac.checkRelationWithTraversal, {
subjectType: "user",
subjectId: "alice",
relation: "viewer",
objectType: "deal",
objectId: "big_deal",
traversalRules,
maxDepth: 5,
});
// Returns:
// {
// allowed: true,
// path: [
// "account:acme -[parent]-> deal:big_deal",
// "team:sales -[owner]-> account:acme",
// "user:alice -[member]-> team:sales"
// ],
// reason: "Access via relationship chain"
// }
遍历使用 maxDepth 限制(默认为 5),并跟踪已访问的 (objectType, objectId, relation) 节点,以避免循环关系导致无限循环。
CRM 示例
// Setup CRM hierarchy using the Authz client
const setupCRM = async (ctx) => {
// Sales rep Alice is on sales team
await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// Sales team owns Acme Corp account
await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme_corp" });
// Acme Corp has a big deal
await authz.addRelation(ctx, { type: "account", id: "acme_corp" }, "parent", { type: "deal", id: "big_deal" });
// Now Alice can access the deal through the relationship chain!
};
O(1) 索引查找
对于高性能的生产环境,索引系统会预先计算权限,从而实现即时查找。
使用索引 API
import { Authz } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// O(1) permission check - single index lookup
const canEdit = await authz.can(ctx, userId, "documents:update");
// O(1) role check
const isAdmin = await authz.hasRole(ctx, userId, "admin");
// O(1) relationship check
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: "sales" });
工作原理
Traditional (O(n)): Indexed (O(1)):
┌──────┐ ┌──────┐
│ User │ │ User │
└──┬───┘ └──┬───┘
│ │
▼ ▼
┌──────────┐ ┌─────────────────────────────┐
│ Get Roles│ ◄── Query │ Index Lookup: │
└──┬───────┘ │ effectivePermissions │
│ │ [userId, permission, scope] │
▼ └─────────────────────────────┘
┌───────────────┐ │
│ Expand Perms │ ◄── Loop ▼
└──┬────────────┘ true/false
│
▼
┌──────────────┐
│ Check Each │ ◄── Loop
│ Permission │
└──┬───────────┘
│
▼
true/false
权衡
| 操作 | 传统方式 | 索引方式 |
|---|---|---|
| 权限检查 | O(角色数 × 权限数) | O(1) |
| 角色分配 | O(1) | O(权限数) |
| 授予权限 | O(1) | O(1) |
| 内存用量 | 较低 | 较高(反规范化) |
生产环境中需要执行大量权限检查时,请使用索引方式。
审计日志
所有授权变更都会记录日志,以满足合规要求并便于调试。
自动记录日志
以下操作会自动记录:
role_assigned- 分配角色时role_revoked- 撤销角色时permission_granted- 授予直接权限时permission_denied- 显式拒绝权限时attribute_set- 设置用户属性时attribute_removed- 移除用户属性时permission_check- (可选)检查权限时
查询审计日志
不提供分页选项时,getAuditLog 会返回简单数组(可选的 limit 默认值为 100):
// Get all logs for a user
const logs = await authz.getAuditLog(ctx, {
userId: "user_123",
limit: 50,
});
// Get logs by action type
const roleChanges = await authz.getAuditLog(ctx, {
action: "role_assigned",
limit: 100,
});
若要进行可扩展的浏览,请传入 numItems(以及可选的 cursor 以获取下一页),使用基于游标的分页。此时返回值为 { page, isDone, continueCursor }:
// First page
const result = await authz.getAuditLog(ctx, { numItems: 50 });
if (!Array.isArray(result)) {
console.log(result.page);
if (!result.isDone) {
// Next page
const next = await authz.getAuditLog(ctx, {
numItems: 50,
cursor: result.continueCursor,
});
}
}
日志条目结构
{
_id: "...",
timestamp: 1704672000000,
actorId: "admin_user", // Who made the change
action: "role_assigned",
userId: "target_user", // Who was affected
details: {
role: "editor",
scope: { type: "team", id: "team_123" },
},
}
权限覆盖
授予或拒绝特定权限,以覆盖基于角色的分配。
授予权限
// Grant a permission directly (bypasses role checks)
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "Temporary access for migration");
// With scope
await authz.grantPermission(ctx, userId, "documents:delete", { type: "team", id: "team_123" });
// With expiration
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "Temporary", Date.now() + 3600000);
拒绝权限
// Deny a permission (even if user has it via role)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "Access restricted");
架构参考
表
| 表 | 用途 |
|---|---|
roleAssignments | 用户角色分配 |
userAttributes | 用于 ABAC 的用户属性 |
permissionOverrides | 直接授予或拒绝权限 |
relationships | ReBAC 关系元组 |
effectivePermissions | 预先计算的权限(O(1)) |
effectiveRoles | 预先计算的角色(O(1)) |
effectiveRelationships | 预先计算的关系(O(1)) |
auditLog | 授权审计记录 |
索引
所有表都针对常见查询模式配置了优化索引:
// roleAssignments
.index("by_user", ["userId"])
.index("by_role", ["role"])
.index("by_user_and_role", ["userId", "role"])
// effectivePermissions (O(1) lookup)
.index("by_user_permission_scope", ["userId", "permission", "scopeKey"])
// relationships
.index("by_subject_relation_object", ["subjectType", "subjectId", "relation", "objectType", "objectId"])
API 参考
Authz 客户端
class Authz<P, R, Policy> {
// Permission checks
can(ctx, userId, permission, scope?): Promise<boolean>
canAny(ctx, userId, permissions: string[], scope?): Promise<boolean> // bulk: any of N permissions (max 100)
require(ctx, userId, permission, scope?): Promise<void>
// Role management
hasRole(ctx, userId, role, scope?): Promise<boolean>
assignRole(ctx, userId, role, scope?, expiresAt?, actorId?): Promise<string>
assignRoles(ctx, userId, roles: RoleAssignItem[], actorId?): Promise<{ assigned: number; assignmentIds: string[] }> // bulk, max 20
revokeRole(ctx, userId, role, scope?, actorId?): Promise<boolean>
revokeRoles(ctx, userId, roles: RoleScopeItem[], actorId?): Promise<{ revoked: number }> // bulk, max 20
revokeAllRoles(ctx, userId, scope?, actorId?): Promise<number>
getUserRoles(ctx, userId, scope?): Promise<Role[]>
getUserPermissions(ctx, userId, scope?): Promise<PermissionResult>
// Offboarding
offboardUser(ctx, userId, options?: { scope?, actorId?, removeAttributes?, removeOverrides?, removeRelationships? }): Promise<OffboardResult>
deprovisionUser(ctx, userId, options?: { actorId?, enableAudit? }): Promise<OffboardResult> // full wipe: roles, overrides, attributes, relationships
// Attribute management
setAttribute(ctx, userId, key, value, actorId?): Promise<string>
removeAttribute(ctx, userId, key, actorId?): Promise<boolean>
getUserAttributes(ctx, userId): Promise<Attribute[]>
// Permission overrides
grantPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
denyPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
// Audit
getAuditLog(ctx, options?): Promise<AuditEntry[] | { page: AuditEntry[]; isDone: boolean; continueCursor: string }>
}
参数验证
Authz 上的所有公共方法都会在调用组件前验证参数。无效输入会抛出一条清晰的 Error 消息,以便你快速失败并修复调用位置。
| 参数 | 规则 | 错误示例 |
|---|---|---|
userId | 非空字符串,最多 512 个字符 | "userId must be a non-empty string" |
permission | 必须为 resource:action 格式(例如 documents:read) | "Invalid permission format: \"read\". Expected \"resource:action\"" |
scope | 提供时,type 和 id 必须为非空字符串 | "scope must have non-empty type when provided" |
role | 非空字符串;必须是构造时传入的角色之一 | "Unknown role: \"superadmin\"" |
expiresAt | 提供时,必须为有限数值(时间戳) | "expiresAt must be a finite number" |
属性 key | 非空字符串 | "Attribute key must be a non-empty string" |
getAuditLog limit | 提供时,必须为 1–1000 的正整数 | "limit must be a positive integer when provided" |
getAuditLog numItems | 提供时(分页),必须为 1–1000 的正整数 | 与 limit 相同 |
| 关系参数 | subjectType、subjectId、relation、objectType、objectId 必须为非空字符串 | "subjectType must be a non-empty string" |
canAny permissions | 非空数组,每个元素均为有效的 resource:action 格式,长度 ≤ 100 | "permissions must not exceed 100 items" |
assignRoles / revokeRoles roles | 非空数组,每个角色均有效,长度 ≤ 20 | "roles must not exceed 20 items" |
仅在提供可选参数时才会对其进行验证(例如,省略 scope 有效;传入 scope: { type: "", id: "x" } 会抛出错误)。
灵感源自 Google Zanzibar
此组件实现了 Google Zanzibar 的相关概念。Google Zanzibar 是 Google 的全局授权系统,为 Google Drive、YouTube、Cloud 等提供支持。
已实现的 Zanzibar 概念
| Zanzibar 概念 | 我们的实现 | 描述 |
|---|---|---|
| 关系元组 | relationships 表 | (user:alice, member, team:sales) |
| 用户集 | 遍历规则 | 由关系定义的组 |
| Check API | checkPermissionFast | O(1)“用户 X 能否对 Z 执行 Y?” |
| Expand API | checkRelationWithTraversal | 查找所有授予访问权限的路径 |
| Read API | getSubjectRelations | 列出所有关系 |
| Watch API | Convex 响应式 | 实时更新权限 |
| 计算关系 | effectivePermissions | 预先计算,实现 O(1) 查询 |
Zanzibar 的工作原理
┌─────────────────────────────────────────────────────────────────────────────┐
│ Google Zanzibar Model │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Relation Tuples (stored): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ (user:alice, member, team:sales) │ │
│ │ (team:sales, owner, account:acme) │ │
│ │ (account:acme, parent, deal:big_deal) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Authorization Model (defines inheritance): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ type deal │ │
│ │ relations │ │
│ │ define parent: [account] │ │
│ │ define viewer: viewer from parent ← Computed relation │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Check: "Can alice view deal:big_deal?" │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 1. deal:big_deal.viewer = viewer from parent │ │
│ │ 2. parent = account:acme │ │
│ │ 3. account:acme.viewer = member from owner │ │
│ │ 4. owner = team:sales │ │
│ │ 5. team:sales.member includes user:alice ✓ │ │
│ │ → ALLOWED │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
我们提供的 Zanzibar 核心优势
- 大规模场景下的一致性
- 预先计算权限,确保检查快速且一致
- 读取之间不会出现权限漂移
- 灵活的权限模型
- 根据需要组合 RBAC、ABAC 和 ReBAC
- 支持复杂的层级结构(组织 → 团队 → 项目 → 资源)
- 可审计性
- 完整记录所有权限变更的审计日志
- 路径追踪会说明授予访问权限的原因
- 实时更新
- Convex 响应式特性意味着权限变更时 UI 会立即更新
- 无需轮询(优于 Zanzibar!)
与其他解决方案的比较
| 功能 | @djpanda/convex-authz | OpenFGA | Oso | Cerbos |
|---|---|---|---|---|
| RBAC | ✅ | ✅ | ✅ | ✅ |
| ABAC | ✅ | ⚠️ 受限 | ✅ | ✅ |
| ReBAC | ✅ | ✅ 原生 | ✅ | ⚠️ |
| O(1) 查询 | ✅ | ✅ | ✅ | ✅ |
| Convex 原生 | ✅ | ❌ | ❌ | ❌ |
| 类型安全 | ✅ TypeScript | DSL | Polar | YAML |
| 实时 | ✅ Convex 查询 | 轮询 | 轮询 | 轮询 |
| 可自托管 | ✅ | ✅ | ✅ | ✅ |
测试
运行包测试
cd packages/authz
npm test
与 convex-test 搭配使用
import { convexTest } from "convex-test";
import { describe, expect, it } from "vitest";
import schema from "./component/schema.js";
import { api } from "./component/_generated/api.js";
describe("authorization", () => {
it("should assign and check roles", async () => {
const t = convexTest(schema, modules);
await t.mutation(api.mutations.assignRole, {
userId: "user_123",
role: "admin",
});
const hasRole = await t.query(api.queries.hasRole, {
userId: "user_123",
role: "admin",
});
expect(hasRole).toBe(true);
});
});
多租户数据隔离
authz 组件中的每个表都包含必填的 tenantId 字段,并将其作为每个数据库索引的首列。这可在数据库层面实现租户间的数据隔离——针对一个租户的查询绝不会返回另一个租户的数据。
tenantId 与 scope 的区别
tenantId | scope | |
|---|---|---|
| 用途 | 数据隔离边界 | 资源级分组 |
| 强制方式 | 数据库级(索引前缀) | 应用级(查询筛选) |
| 是否必填 | 始终必填 | 可选 |
| 示例 | "acme-corp" | { type: "project", id: "proj-123" } |
- tenantId 回答“这些数据属于谁?”——即组织/客户边界
- scope 回答“在此租户内,这适用于什么资源?”——例如特定项目的管理员
配置
// Single-tenant apps — pass any constant string
const authz = new Authz(components.authz, {
permissions, roles,
tenantId: "my-app",
});
// Multi-tenant apps — pass the current organization/tenant ID
const authz = new Authz(components.authz, {
permissions, roles,
tenantId: currentOrgId,
});
跨租户操作
对于需要访问其他租户数据的少数管理操作,请使用 withTenant() 方法:
// Returns a new Authz instance scoped to a different tenant
const otherTenant = authz.withTenant("other-org-id");
await otherTenant.getUserRoles(ctx, userId);
合规性
以 tenantId 开头的索引设计满足 SOC2 和 HIPAA 的数据隔离要求:
- 所有查询都在数据库索引层面按租户分区
- 通过标准 API 在结构上不可能访问跨租户数据
- 构造函数中必须提供
tenantId——不会意外遗漏
最佳实践
1. 使用限定范围的角色实现多租户
// Don't: Global admin
await authz.assignRole(ctx, userId, "admin");
// Do: Scoped admin
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });
2. 在生产环境中使用 Authz 客户端
// Authz uses O(1) indexed lookups by default — no separate class needed
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
3. 使用 ReBAC 处理复杂层级结构
// CRM, document sharing, org charts → ReBAC
// Simple role assignments → RBAC
4. 为临时访问设置过期时间
await authz.assignRole(ctx, userId, "contractor", undefined,
Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days
);
5. 始终使用审计日志
审计日志对于以下工作非常有价值:
- 合规(SOC2、GDPR)
- 访问问题调试
- 安全事件调查
6. 清理过期数据(通过组件定时调度)
过期的角色分配和权限覆盖(以及其索引行)会由定时清理任务清除,只需启用一次,无需自行添加 convex/crons.ts。该组件内置了 @convex-dev/crons;安装组件后,运行以下命令一次以注册每日任务:
npx convex run authz/cronSetup:ensureCleanupCronRegistered
也可以在部署时运行的初始化脚本中执行(例如,通过 convex dev --run init 调用的 convex/init.ts):
await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});
该任务每 24 小时运行一次,并清理 roleAssignments、permissionOverrides、effectiveRoles 和 effectivePermissions。可选:你也可以在应用的 convex/crons.ts 中定义清理任务,或手动运行 components.authz.mutations.runScheduledCleanup。
6.1. 审计日志保留
为避免审计日志无限增长(影响合规和成本),同一 cron 注册还会安排每日审计日志保留任务。通过 Convex 环境变量配置(Dashboard 或 CLI):
| 变量 | 描述 |
|---|---|
AUDIT_RETENTION_DAYS | 删除超过指定天数的条目(例如 90)。省略或设为 0 = 不按时间清理。 |
AUDIT_RETENTION_MAX_ENTRIES | 将条目总数限制在指定值以内,删除最早的条目,直至数量 ≤ 该值(例如 100000)。省略或设为 0 = 不按数量清理。 |
至少设置其中一个变量以启用保留策略。该任务每 24 小时运行一次(通过相同的 ensureCleanupCronRegistered 流程)。你也可以手动运行 components.authz.mutations.runAuditRetentionCleanup,并传入可选参数 { maxAgeDays?, maxEntries? },以覆盖本次运行的环境变量设置。
7. 将 Authz 用作全局单例
Authz 是一个全局组件——只需安装一次,并在整个应用中共享同一个客户端实例。不要在每个应用中创建多个 Authz 实例。
convex/
convex.config.ts ← app.use(authz) — registered once
authz.ts ← definePermissions, defineRoles, export authz client
documents.ts ← import { authz } from "./authz"
billing.ts ← import { authz } from "./authz"
settings.ts ← import { authz } from "./authz"
// convex/authz.ts — single source of truth
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
const permissions = definePermissions({
documents: { create: true, read: true, update: true, delete: true },
billing: { view: true, manage: true },
settings: { view: true, manage: true },
});
const roles = defineRoles(permissions, {
admin: {
documents: ["create", "read", "update", "delete"],
billing: ["view", "manage"],
settings: ["view", "manage"],
},
viewer: {
documents: ["read"],
settings: ["view"],
},
});
// Export the single authz client — import this everywhere
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// convex/documents.ts — uses the shared client
import { mutation } from "./_generated/server";
import { authz } from "./authz";
export const deleteDocument = mutation({
args: { docId: v.id("documents") },
handler: async (ctx, args) => {
await authz.require(ctx, userId, "documents:delete");
// ...
},
});
7. 按领域组织权限
对于较大的应用,可按领域拆分权限和角色定义,再使用 definePermissions 和 defineRoles 将它们合并到单个 authz 客户端中:
// convex/permissions/documents.ts
export const documentPermissions = {
documents: { create: true, read: true, update: true, delete: true },
};
export const documentRoles = {
editor: { documents: ["create", "read", "update"] as const },
viewer: { documents: ["read"] as const },
};
// convex/permissions/billing.ts
export const billingPermissions = {
billing: { view: true, manage: true },
};
export const billingRoles = {
billing_admin: { billing: ["view", "manage"] as const },
};
// convex/authz.ts — merge all domains
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
import { documentPermissions, documentRoles } from "./permissions/documents";
import { billingPermissions, billingRoles } from "./permissions/billing";
const permissions = definePermissions(documentPermissions, billingPermissions);
const roles = defineRoles(permissions, documentRoles, billingRoles);
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
这样可以让每个领域保持独立,同时生成单个类型安全的 authz 客户端。
8. 与其他 Convex 组件集成
当其他 Convex 组件(例如 @djpanda/convex-tenants)需要授权时,它们会共享同一个全局 authz 实例。具体步骤如下:
- 在
convex.config.ts中分别注册这两个组件 - 另一个组件导出其所需的权限和角色
- 将它们与应用自身的定义合并
- 将 authz 客户端传递给另一个组件的 API 工厂
graph LR
subgraph app ["Your App (convex.config.ts)"]
AuthzComp["authz component"]
TenantsComp["tenants component"]
end
subgraph authzSetup ["convex/authz.ts"]
AppPerms["App permissions"]
TenantPerms["Tenant permissions"]
Merge["definePermissions + defineRoles"]
Client["authz client (singleton)"]
AppPerms --> Merge
TenantPerms --> Merge
Merge --> Client
end
Client -->|"import { authz }"| Docs["convex/documents.ts"]
Client -->|"import { authz }"| Billing["convex/billing.ts"]
Client -->|"passed to makeTenantsAPI"| TenantsAPI["convex/tenants.ts"]
// convex/convex.config.ts — register both components
import { defineApp } from "convex/server";
import authz from "@djpanda/convex-authz/convex.config";
import tenants from "@djpanda/convex-tenants/convex.config";
const app = defineApp();
app.use(authz);
app.use(tenants);
export default app;
// convex/authz.ts — merge app + component permissions
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { TENANTS_PERMISSIONS, TENANTS_ROLES } from "@djpanda/convex-tenants";
import { components } from "./_generated/api";
// Your app's own permissions
const appPermissions = {
documents: { create: true, read: true, update: true, delete: true },
};
const appRoles = {
editor: { documents: ["create", "read", "update"] as const },
};
// Merge with tenant component's permissions
const permissions = definePermissions(appPermissions, TENANTS_PERMISSIONS);
const roles = defineRoles(permissions, appRoles, TENANTS_ROLES);
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// convex/tenants.ts — pass the shared authz client
import { makeTenantsAPI } from "@djpanda/convex-tenants";
import { components } from "./_generated/api";
import { authz } from "./authz";
export const {
createOrg,
inviteMember,
removeMember,
// ...
} = makeTenantsAPI(components.tenants, {
authz,
creatorRole: "owner",
auth: async (ctx) => {
// return the current user ID
},
});
这样,应用的每个部分——包括自有函数和第三方组件——都能共享一致的授权层。
开发
# Install dependencies
npm install
# Run development mode
npm run dev
# Run tests
npm test
# Build for production
npm run build
# Type check
npm run typecheck
文件结构
packages/authz/
├── package.json # Package configuration
├── README.md # This documentation
├── src/
│ ├── client/
│ │ ├── index.ts # Main exports (Authz, helpers)
│ │ └── index.test.ts # Client tests
│ ├── component/
│ │ ├── convex.config.ts # Component registration
│ │ ├── schema.ts # Database tables and indexes
│ │ ├── helpers.ts # Shared utilities
│ │ ├── queries.ts # Query functions
│ │ ├── mutations.ts # Mutation functions
│ │ ├── rebac.ts # ReBAC relationship functions
│ │ ├── indexed.ts # O(1) indexed functions
│ │ ├── authz.test.ts # RBAC/ABAC tests
│ │ ├── rebac.test.ts # ReBAC tests
│ │ ├── indexed.test.ts # O(1) indexed tests
│ │ └── _generated/ # Auto-generated types
│ └── test.ts # Test helpers
└── example/ # Example app
许可证
MIT
贡献
欢迎贡献!提交 PR 前,请阅读我们的 CONTRIBUTING.md。