convex-authz
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinese@djpanda/convex-authz
@djpanda/convex-authz
A comprehensive, production-ready authorization component for Convex featuring RBAC, ABAC, and ReBAC with O(1) indexed lookups, inspired by Google Zanzibar.
一个面向Convex的全面、生产就绪的授权组件,具备RBAC、ABAC和ReBAC功能,支持O(1)索引查找,灵感来自Google Zanzibar。
Features
功能特性
| Feature | Description |
|---|---|
| RBAC | Role-Based Access Control with scoped roles |
| ABAC | Attribute-Based Access Control with custom policies |
| ReBAC | Relationship-Based Access Control with graph traversal |
| O(1) Lookups | Pre-computed permissions for instant checks |
| Type Safety | Full TypeScript support with type-safe permissions |
| Audit Logging | Track all permission changes and checks |
| Scoped Permissions | Resource-level access control |
| Expiring Grants | Time-limited role assignments and permissions |
| Convex Native | Built specifically for Convex, with real-time updates |
| 功能特性 | 描述 |
|---|---|
| RBAC | 支持作用域角色的基于角色的访问控制 |
| ABAC | 支持自定义策略的基于属性的访问控制 |
| ReBAC | 支持图遍历的基于关系的访问控制 |
| O(1) 查找 | 预计算权限实现即时校验 |
| 类型安全 | 完整的TypeScript支持,权限类型安全 |
| 审计日志 | 追踪所有权限变更与校验操作 |
| 作用域权限 | 资源级别的访问控制 |
| 过期授权 | 支持限时角色分配与权限 |
| Convex原生 | 专为Convex构建,支持实时更新 |
Terminology
术语定义
| Term | Definition |
|---|---|
| RBAC | Role-Based Access Control - permissions assigned via roles (admin, editor, viewer) |
| ABAC | Attribute-Based Access Control - permissions based on user/resource attributes (department=engineering) |
| ReBAC | Relationship-Based Access Control - permissions derived from relationships (member of team that owns resource) |
| Zanzibar | Google's global authorization system, inspiration for OpenFGA and this component |
| Tuple | A relationship triple: |
| Scope | Resource-level permission context, e.g., "admin of team:123" vs global "admin" |
| Traversal | Following relationship chains to determine inherited access |
| O(1) Lookup | Constant-time permission check via pre-computed indexes |
| Permission Override | Direct grant/deny that bypasses role-based permissions |
| 术语 | 定义 |
|---|---|
| RBAC | 基于角色的访问控制 - 通过角色(管理员、编辑者、查看者)分配权限 |
| ABAC | 基于属性的访问控制 - 基于用户/资源属性(如department=engineering)分配权限 |
| ReBAC | 基于关系的访问控制 - 从实体关系(如拥有资源的团队成员)推导权限 |
| Zanzibar | Google的全局授权系统,是OpenFGA和本组件的设计灵感来源 |
| 元组(Tuple) | 关系三元组: |
| 作用域(Scope) | 资源级权限上下文,例如"team:123的管理员" vs 全局"管理员" |
| 遍历(Traversal) | 跟随关系链判断继承访问权限 |
| O(1) 查找 | 通过预计算索引实现常量时间权限校验 |
| 权限覆盖(Permission Override) | 绕过基于角色权限的直接授权/拒绝 |
Installation
安装
bash
npm install @djpanda/convex-authzbash
npm install @djpanda/convex-authzQuick Start
快速开始
1. Register the Component
1. 注册组件
typescript
// 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;typescript
// 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. Define Your Permissions and Roles
2. 定义权限与角色
typescript
// 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" });typescript
// convex/authz.ts
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
// 步骤1:定义权限
const permissions = definePermissions({
documents: {
create: true,
read: true,
update: true,
delete: true,
},
settings: {
view: true,
manage: true,
},
});
// 步骤2:定义角色
const roles = defineRoles(permissions, {
admin: {
documents: ["create", "read", "update", "delete"],
settings: ["view", "manage"],
},
editor: {
documents: ["create", "read", "update"],
settings: ["view"],
},
viewer: {
documents: ["read"],
},
});
// 步骤3:创建authz客户端
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });Role inheritance and composition
角色继承与组合
Roles can be defined in terms of other roles to avoid repeating permission lists:
- – one parent role; effective permissions = parent’s permissions ∪ this role’s direct permissions.
**inherits** - – multiple roles; effective permissions = union of all included roles’ permissions ∪ this role’s direct permissions.
**includes**
Example with inheritance (admin > editor > viewer):
typescript
const roles = defineRoles(permissions, {
viewer: { documents: ["read"] },
editor: { inherits: "viewer", documents: ["create", "update"] },
admin: { inherits: "editor", documents: ["delete"], settings: ["manage"] },
});Example with composition (combine roles):
typescript
const roles = defineRoles(permissions, {
editor: { documents: ["create", "read", "update"] },
billing_admin: { billing: ["view", "manage"] },
billing_manager: { includes: ["editor", "billing_admin"], settings: ["view"] },
});Note: and are reserved keys in role definitions; do not use them as permission resource names.
inheritsincludes角色可以基于其他角色定义,避免重复权限列表:
- – 单个父角色;有效权限 = 父角色权限 ∪ 当前角色直接权限。
**inherits** - – 多个角色;有效权限 = 所有包含角色权限的并集 ∪ 当前角色直接权限。
**includes**
继承示例(admin > editor > viewer):
typescript
const roles = defineRoles(permissions, {
viewer: { documents: ["read"] },
editor: { inherits: "viewer", documents: ["create", "update"] },
admin: { inherits: "editor", documents: ["delete"], settings: ["manage"] },
});组合示例(合并角色):
typescript
const roles = defineRoles(permissions, {
editor: { documents: ["create", "read", "update"] },
billing_admin: { billing: ["view", "manage"] },
billing_manager: { includes: ["editor", "billing_admin"], settings: ["view"] },
});注意: 和 是角色定义中的保留关键字;请勿将它们用作权限资源名称。
inheritsincludes3. Use in Your Functions
3. 在函数中使用
typescript
// 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...
},
});typescript
// 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);
// 校验权限(拒绝时抛出异常)
await authz.require(ctx, userId, "documents:update");
// 带作用域的校验
await authz.require(ctx, userId, "documents:update", {
type: "document",
id: args.docId,
});
// 执行更新操作...
},
});Unified Authz v2
Unified Authz v2
v2 consolidates everything into a single class. If you previously used , just rename it — the constructor signature is identical.
AuthzIndexedAuthzv2版本将所有功能整合到单个类中。如果您之前使用,只需重命名即可——构造函数签名完全相同。
AuthzIndexedAuthzWhat changed
变更内容
- One class: replaces both the original
AuthzandAuthz. O(1) reads via pre-computed effective tables are now the default.IndexedAuthz - ReBAC on :
Authz,hasRelation, andaddRelationare available directly on everyremoveRelationinstance.Authz - ABAC policy types: Policies accept a field (
typeor"static"). In the current implementation, both types are evaluated at read-time when"deferred"is called — Convex mutations cannot call queries, so write-time evaluation is not possible. Thecan()field is reserved for future optimization but currently has no behavioral difference.type - **: Check deferred ABAC policies that need runtime context (e.g. IP address, time of day).
**canWithContext() - : Rebuild a user's effective-permissions table on demand — useful after a schema change or post-deploy migration.
**recomputeUser()** - : Get a scoped copy of the client bound to a different tenant for cross-tenant admin operations.
**withTenant()**
- 单一类:替代原有的
Authz和Authz。通过预计算有效表实现的O(1)读取现在是默认行为。IndexedAuthz - ReBAC集成:、
hasRelation和addRelation直接在每个removeRelation实例上可用。Authz - ABAC策略类型:策略接受字段(
type或"static")。在当前实现中,两种类型都会在调用"deferred"时的读取阶段评估——Convex变更无法调用查询,因此无法在写入阶段评估。can()字段是为未来优化预留的,目前没有行为差异。type - **:校验需要运行时上下文(如IP地址、时间)的延迟ABAC策略。
**canWithContext() - **:按需重建用户的有效权限表——适用于架构变更或部署后迁移场景。
**recomputeUser() - **:获取绑定到不同租户的客户端作用域副本,用于跨租户管理员操作。
**withTenant()
ReBAC example
ReBAC示例
typescript
// 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 });typescript
// 添加关系
await authz.addRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
// 校验关系
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
// 删除关系
await authz.removeRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });ReBAC → Permission Bridge
ReBAC → 权限桥接
Use to automatically grant permissions when relationships are created:
defineRelationPermissionstypescript
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 });使用在创建关系时自动授予权限:
defineRelationPermissionstypescript
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"],
}),
});
// 添加关系会自动授予作用域权限
await authz.addRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });
// can()现在会校验关系推导的权限——无需单独调用hasRelation()
const canUpdate = await authz.can(ctx, userId, "documents:update", { type: "document", id: docId }); // true
// 删除关系会撤销权限
await authz.removeRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });ABAC example
ABAC示例
typescript
// 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,
});typescript
// 策略始终在调用can()时的读取阶段评估。
// 可选的type字段("static" | "deferred")是为未来使用预留的,目前没有行为差异。
const policies = definePolicies({
"documents:read": {
condition: (ctx) => ctx.getAttribute("verified") === true,
message: "仅已验证用户可读取文档",
},
"billing:export": {
condition: (ctx) => {
const hour = new Date().getUTCHours();
return hour >= 9 && hour <= 17;
},
message: "仅工作时间可导出账单",
},
});
// 当请求上下文可用时使用canWithContext()
const allowed = await authz.canWithContext(ctx, userId, "documents:read", undefined, {
ipAllowlisted: true,
});Post-deploy rebuild
部署后重建
typescript
// Rebuild a single user's effective permissions after upgrading
await authz.recomputeUser(ctx, userId);typescript
// 升级后重建单个用户的有效权限
await authz.recomputeUser(ctx, userId);Cross-tenant operations
跨租户操作
typescript
const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");typescript
const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");Migration guide: IndexedAuthz
→ Authz
IndexedAuthzAuthz迁移指南:IndexedAuthz
→ Authz
IndexedAuthzAuthzNote:is no longer exported in v2. The import below will fail — just replace it withIndexedAuthz.Authz
typescript
// 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" });After upgrading, run for each existing user to backfill the effective-permissions table:
recomputeUser()typescript
// 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));
}
},
});注意: v2版本不再导出。以下导入会失败——只需替换为IndexedAuthz。Authz
typescript
// 之前(v1)——此导入在v2中不再有效
// import { IndexedAuthz } from "@djpanda/convex-authz";
// const authz = new IndexedAuthz(components.authz, { permissions, roles, tenantId: "my-app" });
// 之后(v2)——构造函数相同,只需重命名类
import { Authz } from "@djpanda/convex-authz";
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });升级后,为每个现有用户运行以回填有效权限表:
recomputeUser()typescript
// 一次性迁移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 integration
React集成
The package provides React hooks and a component so your UI can check permissions and roles reactively. Your app must expose Convex queries that wrap the Authz component (e.g. , ). The hooks call those queries via Convex’s , so permission and role changes stay up to date without polling.
PermissionGatecheckPermissiongetUserRolesuseQuery该包提供React钩子和组件,让您的UI可以响应式地校验权限和角色。您的应用必须暴露封装Authz组件的Convex查询(例如、)。钩子通过Convex的调用这些查询,因此权限和角色变更会自动更新,无需轮询。
PermissionGatecheckPermissiongetUserRolesuseQuery1. Expose Convex queries
1. 暴露Convex查询
Define queries that delegate to your authz client, for example:
typescript
// 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);
},
});定义委托给authz客户端的查询,例如:
typescript
// convex/app.ts(或类似文件)
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. Wrap your app with AuthzProvider
2. 使用AuthzProvider包裹应用
Pass your Convex query refs (and optionally a default user id) to the provider:
tsx
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>将Convex查询引用(可选默认用户ID)传递给提供者:
tsx
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} // 可选;钩子可在选项中传递userId
>
<App />
</AuthzProvider>3. Use hooks and PermissionGate
3. 使用钩子和PermissionGate
- useCanUser(permission, options?) — Returns . Options:
{ allowed, isLoading, error }. Uses{ userId?, scope? }from the provider whendefaultUserIdis omitted.userId - useUserRoles(options?) — Returns . Options:
{ roles, isLoading, error }.{ userId?, scope? } - useRequirePermission(permission, options?) — Throws when the user is not allowed (use an error boundary to show a denied state).
- PermissionGate — Renders when allowed,
childrenwhen denied, andfallback(optional) while loading.loadingFallback
tsx
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’s reactivity ensures that when permissions or roles change on the backend, the hooks and re-run and the UI updates automatically.
PermissionGate- useCanUser(permission, options?) — 返回。选项:
{ allowed, isLoading, error }。当省略{ userId?, scope? }时使用提供者的userId。defaultUserId - useUserRoles(options?) — 返回。选项:
{ roles, isLoading, error }。{ userId?, scope? } - useRequirePermission(permission, options?) — 用户无权限时抛出异常(使用错误边界显示拒绝状态)。
- PermissionGate — 允许时渲染,拒绝时渲染
children,加载时可选渲染fallback。loadingFallback
tsx
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>您无法查看文档。</p>;
return <div>{/* 文档列表 */}</div>;
}
function AdminPanel() {
useRequirePermission("settings:manage"); // 无权限时抛出异常;需包裹错误边界
return <div>管理员内容</div>;
}
function EditButton({ docId }: { docId: string }) {
return (
<PermissionGate
permission="documents:update"
scope={{ type: "document", id: docId }}
fallback={<span>无访问权限</span>}
loadingFallback={<span>校验中…</span>}
>
<button>编辑</button>
</PermissionGate>
);
}Convex的响应式特性确保后端权限或角色变更时,钩子和会重新运行,UI自动更新。
PermissionGateArchitecture
架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ @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] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────────────────┐
│ @djpanda/convex-authz │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ RBAC │ │ ABAC │ │ ReBAC │ │
│ │ 基于角色的访问控制 │ │ 基于属性的访问控制 │ │ 基于关系的访问控制 │ │
│ │ │ │ │ │ │ │
│ │ • 角色 │ │ • 用户属性 │ │ • 元组 (S, R, O) │ │
│ │ • 权限 │ │ • 策略 │ │ • 图遍历 │ │
│ │ • 作用域 │ │ • 条件 │ │ • 继承 │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ O(1) 索引权限缓存 │ │
│ │ │ │
│ │ effectivePermissions │ effectiveRoles │ effectiveRelationships │ │
│ │ [用户, 权限, 作用域] │ [用户, 角色] │ [主体, 关系, 对象] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘RBAC (Role-Based Access Control)
RBAC(基于角色的访问控制)
Assigning Roles
分配角色
typescript
// 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);typescript
// 全局角色
await authz.assignRole(ctx, userId, "admin");
// 作用域角色(例如,特定团队的管理员)
await authz.assignRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});
// 带过期时间(24小时)
await authz.assignRole(ctx, userId, "admin", undefined, Date.now() + 86400000);Revoking Roles
撤销角色
typescript
await authz.revokeRole(ctx, userId, "admin");
// Scoped
await authz.revokeRole(ctx, userId, "admin", { type: "team", id: "team_123" });typescript
await authz.revokeRole(ctx, userId, "admin");
// 作用域角色撤销
await authz.revokeRole(ctx, userId, "admin", { type: "team", id: "team_123" });Checking Permissions
校验权限
typescript
// 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",
});typescript
// 布尔值校验
const canEdit = await authz.can(ctx, userId, "documents:update");
// 拒绝时抛出异常
await authz.require(ctx, userId, "documents:update");
// 带作用域的校验
const canEditTeamDocs = await authz.can(ctx, userId, "documents:update", {
type: "team",
id: "team_123",
});Checking Roles
校验角色
typescript
const isAdmin = await authz.hasRole(ctx, userId, "admin");
// Scoped
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});typescript
const isAdmin = await authz.hasRole(ctx, userId, "admin");
// 作用域角色校验
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});Wildcard and pattern-matching permissions
通配符与模式匹配权限
Permission checks and overrides support wildcard patterns so you can grant or deny whole families of permissions in one go.
Pattern format: . Either or (or both) may be :
resource:actionresourceaction*| Pattern | Meaning | Example matches |
|---|---|---|
| All permissions | |
| All actions on | |
| Read on any resource | |
| All permissions (same as | any |
Checking: When you call or , the backend treats any stored role or override that matches that permission as granting it. So if the user has a role with * or an override , they are allowed for .
can(ctx, userId, "documents:read")require(ctx, userId, "documents:read")documents:*:readdocuments:readAllocation: You can pass a pattern into and :
grantPermissiondenyPermissiontypescript
// 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");Role definitions: When the component evaluates permissions, it matches the requested permission against each role’s permission list using the same pattern rules. So if a role’s permissions include (in the flattened role–permission map), then is allowed. With you typically list concrete actions per resource (e.g. ); to use patterns in roles you would supply a role-permission map that includes pattern strings for that role.
"documents:*"can(ctx, userId, "documents:read")defineRolesdocuments: ["read", "update"]Client-side helper: To test whether a pattern matches a permission without calling the backend, use the exported helper:
typescript
import { matchesPermissionPattern } from "@djpanda/convex-authz";
matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read"); // true
matchesPermissionPattern("settings:read", "documents:*"); // falseThe same wildcard behavior applies to all permission checks.
权限校验和覆盖支持通配符模式,让您可以一次性授予或拒绝整组权限。
模式格式: 。或(或两者)可以是:
资源:操作资源操作*| 模式 | 含义 | 匹配示例 |
|---|---|---|
| 所有权限 | |
| 文档的所有操作 | |
| 任意资源的读权限 | |
| 所有权限(与 | 任何 |
校验: 当您调用或时,后端会将任何与该权限匹配的已存储角色或覆盖视为授权。因此,如果用户拥有带的角色或的覆盖,他们将被允许执行。
can(ctx, userId, "documents:read")require(ctx, userId, "documents:read")documents:**:readdocuments:read分配: 您可以将模式传递给和:
grantPermissiondenyPermissiontypescript
// 授予所有文档操作权限
await authz.grantPermission(ctx, userId, "documents:*", undefined, "完整文档访问权限");
// 拒绝所有资源的读权限
await authz.denyPermission(ctx, userId, "*:read", undefined, "读权限已撤销");角色定义: 组件评估权限时,会使用相同的模式规则将请求的权限与每个角色的权限列表匹配。因此,如果角色的权限包含(在扁平化的角色-权限映射中),则会被允许。使用时,您通常会列出每个资源的具体操作(例如);要在角色中使用模式,您需要提供包含模式字符串的角色-权限映射。
"documents:*"can(ctx, userId, "documents:read")defineRolesdocuments: ["read", "update"]客户端助手: 无需调用后端即可测试模式是否匹配权限,使用导出的助手函数:
typescript
import { matchesPermissionPattern } from "@djpanda/convex-authz";
matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read"); // true
matchesPermissionPattern("settings:read", "documents:*"); // false所有权限校验都适用相同的通配符行为。
Getting User Roles
获取用户角色
typescript
const roles = await authz.getUserRoles(ctx, userId);
// Returns: [{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]typescript
const roles = await authz.getUserRoles(ctx, userId);
// 返回:[{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]Bulk operations and offboarding
批量操作与用户离职处理
For large-scale or enterprise workflows, the API supports bulk permission checks (up to 100 permissions) and role updates (up to 20 roles) in a single call.
对于大规模或企业级工作流,API支持批量权限校验(最多100个权限)和角色更新(最多20个角色)的单次调用。
Bulk permission check (canAny)
批量权限校验(canAny)
Check whether the user has any of the given permissions in one round-trip:
typescript
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单次往返校验用户是否拥有任意给定权限:
typescript
const allowed = await authz.canAny(ctx, userId, [
"documents:read",
"documents:update",
"documents:delete",
], scope);
// 如果用户至少拥有其中一个权限则返回trueBulk role assign and revoke
批量角色分配与撤销
Assign or revoke multiple roles for one user in a single transaction:
typescript
// 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);在单个事务中为用户分配或撤销多个角色:
typescript
// 一次性分配多个角色(每次调用最多20个)
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);
// 一次性撤销多个角色(每次调用最多20个)
const { revoked } = await authz.revokeRoles(ctx, userId, [
{ role: "editor", scope: { type: "team", id: "team_1" } },
{ role: "viewer" },
], actorId);Revoke all roles
撤销所有角色
Revoke every role for a user (optionally only in a given scope):
typescript
const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);撤销用户的所有角色(可选仅在给定作用域内):
typescript
const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);Full user offboarding
完整用户离职处理
Remove all roles, permission overrides, attributes, and optionally ReBAC relationships for a user in one call (optionally scoped). Also clears indexed , , and when present:
effectiveRoleseffectivePermissionseffectiveRelationshipstypescript
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 }When scope is omitted, the call performs a full deprovision: all roles, overrides, attributes, and all ReBAC relationships where the user is the subject are removed. When scope is provided, only data in that scope is removed and relationships are left unchanged.
单次调用移除用户的所有角色、权限覆盖、属性,可选移除ReBAC关系(可选作用域)。同时清除索引化的、和(如果存在):
effectiveRoleseffectivePermissionseffectiveRelationshipstypescript
const result = await authz.offboardUser(ctx, userId, {
scope: { type: "org", id: "org_1" }, // 可选:仅移除该作用域内的数据
actorId: "system",
removeAttributes: true, // 默认true
removeOverrides: true, // 默认true
removeRelationships: true, // 无作用域时默认true(完整离职)
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }当省略作用域时,调用会执行完整的解除配置:移除所有角色、覆盖、属性,以及所有用户作为主体的ReBAC关系。当提供作用域时,仅移除该作用域内的数据,关系保持不变。
User deprovisioning (full wipe)
用户解除配置(完全清除)
For security incident response, enterprise offboarding, or single-button deactivation, use deprovisionUser to atomically wipe all roles, attributes, relationships, and permission overrides for a user (no scope, no options):
typescript
const result = await authz.deprovisionUser(ctx, userId, {
actorId: "security-team",
enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }Bulk arrays are limited per call: permissions in up to 100 items, roles in / up to 20 items. The client and component validate and throw a clear error if exceeded.
canAnyassignRolesrevokeRoles对于安全事件响应、企业用户离职或一键停用,使用deprovisionUser原子性地清除用户的所有角色、属性、关系和权限覆盖(无作用域,无选项):
typescript
const result = await authz.deprovisionUser(ctx, userId, {
actorId: "security-team",
enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }批量数组有调用限制:中的权限最多100项,/中的角色最多20项。客户端和组件会进行验证,如果超出限制会抛出清晰的错误。
canAnyassignRolesrevokeRolesABAC (Attribute-Based Access Control)
ABAC(基于属性的访问控制)
Setting User Attributes
设置用户属性
typescript
await authz.setAttribute(ctx, userId, "department", "engineering");
await authz.setAttribute(ctx, userId, "clearanceLevel", 5);
await authz.setAttribute(ctx, userId, "location", { country: "US", state: "CA" });typescript
await authz.setAttribute(ctx, userId, "department", "engineering");
await authz.setAttribute(ctx, userId, "clearanceLevel", 5);
await authz.setAttribute(ctx, userId, "location", { country: "US", state: "CA" });Getting Attributes
获取属性
typescript
const attributes = await authz.getUserAttributes(ctx, userId);
// Returns: [{ key: "department", value: "engineering" }, { key: "clearanceLevel", value: 5 }]typescript
const attributes = await authz.getUserAttributes(ctx, userId);
// 返回:[{ key: "department", value: "engineering" }, { key: "clearanceLevel", value: 5 }]Defining Policies
定义策略
The function may return either a or a , so you can use async logic (e.g. querying the database or calling external APIs).
conditionbooleanPromise<boolean>typescript
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" });When you evaluate a policy (e.g. after RBAC allows), always await the condition so both sync and async policies work. Use to normalize to a Promise:
evaluatePolicyConditiontypescript
const policy = policies["documents:update"];
if (policy) {
const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
if (!allowed) throw new Error(policy.message ?? "Permission denied");
}conditionbooleanPromise<boolean>typescript
import { definePolicies, evaluatePolicyCondition } from "@djpanda/convex-authz";
const policies = definePolicies({
"documents:update": {
// 用户拥有文档则可更新(同步)
condition: (ctx) => ctx.resource?.ownerId === ctx.subject.userId,
message: "仅文档所有者可更新",
},
"reports:view": {
// 仅工程部且权限等级≥3(同步)
condition: (ctx) =>
ctx.subject.attributes.department === "engineering" &&
(ctx.subject.attributes.clearanceLevel as number) >= 3,
message: "需要工程部且权限等级3+",
},
"documents:delete": {
// 异步:例如检查外部服务或获取额外数据
condition: async (ctx) => {
const doc = await getDocument(ctx.resource?.id);
return doc != null && doc.ownerId === ctx.subject.userId;
},
message: "仅文档所有者可删除",
},
});
const authz = new Authz(components.authz, { permissions, roles, policies, tenantId: "my-app" });当您评估策略时(例如RBAC允许后),请始终await条件,以便同步和异步策略都能正常工作。使用将其规范化为Promise:
evaluatePolicyConditiontypescript
const policy = policies["documents:update"];
if (policy) {
const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
if (!allowed) throw new Error(policy.message ?? "权限拒绝");
}Policy Context
策略上下文
Policies receive a context object with:
typescript
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 note: Existing sync conditions remain valid. There is no breaking change; only the return type is widened to allow for async policies.
Promise<boolean>策略会接收包含以下内容的上下文对象:
typescript
interface PolicyContext {
subject: {
userId: string;
roles: string[];
attributes: Record<string, unknown>;
};
resource?: {
type: string;
id: string;
[key: string]: unknown; // 资源数据
};
action: string; // 正在校验的权限
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 (Relationship-Based Access Control)
ReBAC(基于关系的访问控制)
ReBAC enables access control based on relationships between entities, perfect for hierarchical systems like CRMs, document sharing, and organizational structures.
ReBAC支持基于实体间关系的访问控制,非常适合CRM、文档共享和组织结构等分层系统。
Relationship Model
关系模型
Relationships are stored as tuples:
(subject, relation, object)user:alice ──member──► team:sales
team:sales ──owner──► account:acme
account:acme ──parent──► deal:big_deal关系以元组形式存储:
(主体, 关系, 对象)user:alice ──member──► team:sales
team:sales ──owner──► account:acme
account:acme ──parent──► deal:big_dealAdding Relationships
添加关系
typescript
// 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" });typescript
// 使用Authz客户端——不要直接调用组件
// 用户是团队成员
await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// 团队拥有账户
await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme" });Checking Direct Relationships
校验直接关系
typescript
// 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" });typescript
// 使用Authz客户端——不要直接调用组件
const isMember = await authz.hasRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// 返回:true
// 删除关系
await authz.removeRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });Relationship Traversal (Inherited Access)
关系遍历(继承访问)
The real power of ReBAC is checking access through relationship chains:
typescript
// 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"
// }Traversal uses a maxDepth limit (default 5) and tracks visited nodes so that circular relationships do not cause infinite loops.
(objectType, objectId, relation)ReBAC的真正强大之处在于通过关系链校验访问权限:
typescript
// 定义权限如何通过关系流转
const traversalRules = {
// 交易查看者是可查看父账户的任何人
"deal:viewer": [
{ through: "account", via: "parent", inherit: "viewer" }
],
// 账户查看者是拥有团队的任何成员
"account:viewer": [
{ through: "team", via: "owner", inherit: "member" }
],
};
// 图遍历可通过组件查询直接使用
// 校验:Alice能否查看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,
});
// 返回:
// {
// allowed: true,
// path: [
// "account:acme -[parent]-> deal:big_deal",
// "team:sales -[owner]-> account:acme",
// "user:alice -[member]-> team:sales"
// ],
// reason: "通过关系链获得访问权限"
// }遍历使用maxDepth限制(默认5),并跟踪已访问的节点,避免循环关系导致无限循环。
(objectType, objectId, relation)CRM Example
CRM示例
typescript
// 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!
};typescript
// 使用Authz客户端设置CRM层级
const setupCRM = async (ctx) => {
// 销售代表Alice属于销售团队
await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// 销售团队拥有Acme Corp账户
await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme_corp" });
// Acme Corp有一个大额交易
await authz.addRelation(ctx, { type: "account", id: "acme_corp" }, "parent", { type: "deal", id: "big_deal" });
// 现在Alice可以通过关系链访问该交易!
};O(1) Indexed Lookups
O(1) 索引查找
For high-performance production use, the indexed system pre-computes permissions for instant lookups.
对于高性能生产环境,索引系统会预计算权限以实现即时查找。
Using the Indexed API
使用索引API
typescript
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" });typescript
import { Authz } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// O(1) 权限校验——单次索引查找
const canEdit = await authz.can(ctx, userId, "documents:update");
// O(1) 角色校验
const isAdmin = await authz.hasRole(ctx, userId, "admin");
// O(1) 关系校验
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: "sales" });How It Works
工作原理
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(n)): 索引方式 (O(1)):
┌──────┐ ┌──────┐
│ 用户 │ │ 用户 │
└──┬───┘ └──┬───┘
│ │
▼ ▼
┌──────────┐ ┌─────────────────────────────┐
│ 获取角色│ ◄── 查询 │ 索引查找: │
└──┬───────┘ │ effectivePermissions │
│ │ [用户ID, 权限, 作用域] │
▼ └─────────────────────────────┘
┌───────────────┐ │
│ 扩展权限 │ ◄── 循环 ▼
└──┬────────────┘ 是/否
│
▼
┌──────────────┐
│ 校验每个 │ ◄── 循环
│ 权限 │
└──┬───────────┘
│
▼
是/否Trade-offs
权衡
| Operation | Traditional | Indexed |
|---|---|---|
| Permission Check | O(roles × perms) | O(1) |
| Role Assignment | O(1) | O(permissions) |
| Permission Grant | O(1) | O(1) |
| Memory Usage | Lower | Higher (denormalized) |
Use Indexed for production workloads with many permission checks.
| 操作 | 传统方式 | 索引方式 |
|---|---|---|
| 权限校验 | O(角色数 × 权限数) | O(1) |
| 角色分配 | O(1) | O(权限数) |
| 权限授予 | O(1) | O(1) |
| 内存使用 | 较低 | 较高(非规范化) |
生产环境中权限校验较多时使用索引方式。
Audit Logging
审计日志
All authorization changes are logged for compliance and debugging.
所有授权变更都会被记录,用于合规性检查和调试。
Automatic Logging
自动日志记录
The following actions are automatically logged:
- - When a role is assigned
role_assigned - - When a role is revoked
role_revoked - - When a direct permission is granted
permission_granted - - When a permission is explicitly denied
permission_denied - - When a user attribute is set
attribute_set - - When a user attribute is removed
attribute_removed - - (Optional) When permissions are checked
permission_check
以下操作会自动记录:
- - 角色分配时
role_assigned - - 角色撤销时
role_revoked - - 直接授予权限时
permission_granted - - 显式拒绝权限时
permission_denied - - 设置用户属性时
attribute_set - - 删除用户属性时
attribute_removed - - (可选)权限校验时
permission_check
Querying the Audit Log
查询审计日志
Without pagination options, returns a simple array (optional , default 100):
getAuditLoglimittypescript
// 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,
});For scalable browsing, use cursor-based pagination by passing (and optionally for the next page). The return value is then :
numItemscursor{ page, isDone, continueCursor }typescript
// 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,
});
}
}不传递分页选项时,返回简单数组(可选,默认100):
getAuditLoglimittypescript
// 获取用户的所有日志
const logs = await authz.getAuditLog(ctx, {
userId: "user_123",
limit: 50,
});
// 获取指定操作类型的日志
const roleChanges = await authz.getAuditLog(ctx, {
action: "role_assigned",
limit: 100,
});对于可扩展的浏览,通过传递(可选用于下一页)使用基于游标的分页。返回值为:
numItemscursor{ page, isDone, continueCursor }typescript
// 第一页
const result = await authz.getAuditLog(ctx, { numItems: 50 });
if (!Array.isArray(result)) {
console.log(result.page);
if (!result.isDone) {
// 下一页
const next = await authz.getAuditLog(ctx, {
numItems: 50,
cursor: result.continueCursor,
});
}
}Log Entry Structure
日志条目结构
typescript
{
_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" },
},
}typescript
{
_id: "...",
timestamp: 1704672000000,
actorId: "admin_user", // 执行变更的用户
action: "role_assigned",
userId: "target_user", // 受影响的用户
details: {
role: "editor",
scope: { type: "team", id: "team_123" },
},
}Permission Overrides
权限覆盖
Grant or deny specific permissions that override role-based assignments.
授予或拒绝特定权限,覆盖基于角色的分配。
Granting Permissions
授予权限
typescript
// 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);typescript
// 直接授予权限(绕过角色校验)
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "迁移临时访问权限");
// 带作用域
await authz.grantPermission(ctx, userId, "documents:delete", { type: "team", id: "team_123" });
// 带过期时间
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "临时权限", Date.now() + 3600000);Denying Permissions
拒绝权限
typescript
// Deny a permission (even if user has it via role)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "Access restricted");typescript
// 拒绝权限(即使用户通过角色拥有该权限)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "访问受限");Schema Reference
架构参考
Tables
表结构
| Table | Purpose |
|---|---|
| User role assignments |
| User attributes for ABAC |
| Direct permission grants/denials |
| ReBAC relationship tuples |
| Pre-computed permissions (O(1)) |
| Pre-computed roles (O(1)) |
| Pre-computed relationships (O(1)) |
| Authorization audit trail |
| 表名 | 用途 |
|---|---|
| 用户角色分配 |
| ABAC用户属性 |
| 直接权限授予/拒绝 |
| ReBAC关系元组 |
| 预计算权限(O(1)) |
| 预计算角色(O(1)) |
| 预计算关系(O(1)) |
| 授权审计追踪 |
Indexes
索引
All tables have optimized indexes for common query patterns:
typescript
// 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"])所有表都针对常见查询模式优化了索引:
typescript
// roleAssignments
.index("by_user", ["userId"])
.index("by_role", ["role"])
.index("by_user_and_role", ["userId", "role"])
// effectivePermissions (O(1) 查找)
.index("by_user_permission_scope", ["userId", "permission", "scopeKey"])
// relationships
.index("by_subject_relation_object", ["subjectType", "subjectId", "relation", "objectType", "objectId"])API Reference
API参考
Authz Client
Authz客户端
typescript
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 }>
}typescript
class Authz<P, R, Policy> {
// 权限校验
can(ctx, userId, permission, scope?): Promise<boolean>
canAny(ctx, userId, permissions: string[], scope?): Promise<boolean> // 批量:任意N个权限(最多100个)
require(ctx, userId, permission, scope?): Promise<void>
// 角色管理
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[] }> // 批量,最多20个
revokeRole(ctx, userId, role, scope?, actorId?): Promise<boolean>
revokeRoles(ctx, userId, roles: RoleScopeItem[], actorId?): Promise<{ revoked: number }> // 批量,最多20个
revokeAllRoles(ctx, userId, scope?, actorId?): Promise<number>
getUserRoles(ctx, userId, scope?): Promise<Role[]>
getUserPermissions(ctx, userId, scope?): Promise<PermissionResult>
// 用户离职处理
offboardUser(ctx, userId, options?: { scope?, actorId?, removeAttributes?, removeOverrides?, removeRelationships? }): Promise<OffboardResult>
deprovisionUser(ctx, userId, options?: { actorId?, enableAudit? }): Promise<OffboardResult> // 完全清除:角色、覆盖、属性、关系
// 属性管理
setAttribute(ctx, userId, key, value, actorId?): Promise<string>
removeAttribute(ctx, userId, key, actorId?): Promise<boolean>
getUserAttributes(ctx, userId): Promise<Attribute[]>
// 权限覆盖
grantPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
denyPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
// 审计
getAuditLog(ctx, options?): Promise<AuditEntry[] | { page: AuditEntry[]; isDone: boolean; continueCursor: string }>
}Argument validation
参数验证
All public methods on validate their arguments before calling the component. Invalid inputs throw an with a clear message so you can fail fast and fix call sites.
AuthzError| Argument | Rule | Example error |
|---|---|---|
| Non-empty string, max 512 characters | |
| Must be | |
| When provided, | |
| Non-empty string; must be one of the roles passed at construction | |
| When provided, must be a finite number (timestamp) | |
Attribute | Non-empty string | |
| When provided, positive integer 1–1000 | |
| When provided (pagination), positive integer 1–1000 | same as |
| Relation args | | |
| Non-empty array, each element valid | |
| Non-empty array, each role valid, length ≤ 20 | |
Optional parameters are only validated when present (e.g. omitting is valid; passing throws).
scopescope: { type: "", id: "x" }AuthzError| 参数 | 规则 | 示例错误 |
|---|---|---|
| 非空字符串,最大512字符 | |
| 必须为 | |
| 提供时, | |
| 非空字符串;必须是构造函数传入的角色之一 | |
| 提供时,必须是有限数字(时间戳) | |
属性 | 非空字符串 | |
| 提供时,必须是1–1000的正整数 | |
| 提供时(分页),必须是1–1000的正整数 | 与 |
| 关系参数 | | |
| 非空数组,每个元素为有效的 | |
| 非空数组,每个角色有效,长度≤20 | |
可选参数仅在提供时验证(例如省略是有效的;传递会抛出错误)。
scopescope: { type: "", id: "x" }Inspired by Google Zanzibar
灵感源自Google Zanzibar
This component implements concepts from Google Zanzibar, Google's global authorization system that powers Google Drive, YouTube, Cloud, and more.
本组件实现了Google Zanzibar的概念,Google Zanzibar是Google的全局授权系统,为Google Drive、YouTube、Cloud等服务提供支持。
Zanzibar Concepts Implemented
已实现的Zanzibar概念
| Zanzibar Concept | Our Implementation | Description |
|---|---|---|
| Relation Tuples | | |
| Usersets | Traversal rules | Groups defined by relationships |
| Check API | | O(1) "can user X do Y on Z?" |
| Expand API | | Find all paths granting access |
| Read API | | List all relationships |
| Watch API | Convex reactivity | Real-time permission updates |
| Computed Relations | | Pre-computed for O(1) lookup |
| Zanzibar概念 | 我们的实现 | 描述 |
|---|---|---|
| 关系元组 | | |
| 用户集 | 遍历规则 | 由关系定义的组 |
| Check API | | O(1) "用户X能否对Z执行Y?" |
| Expand API | | 查找所有授予访问权限的路径 |
| Read API | | 列出所有关系 |
| Watch API | Convex响应式特性 | 实时权限更新 |
| 计算关系 | | 预计算实现O(1)查找 |
How Zanzibar Works
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 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────────────────┐
│ Google Zanzibar模型 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 关系元组(存储): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ (user:alice, member, team:sales) │ │
│ │ (team:sales, owner, account:acme) │ │
│ │ (account:acme, parent, deal:big_deal) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 授权模型(定义继承): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ type deal │ │
│ │ relations │ │
│ │ define parent: [account] │ │
│ │ define viewer: viewer from parent ← 计算关系 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 校验:"Alice能否查看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包含user:alice ✓ │ │
│ │ → 允许 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Key Zanzibar Benefits We Provide
我们提供的Zanzibar核心优势
- Consistency at Scale
- Pre-computed permissions ensure fast, consistent checks
- No permission drift between reads
- Flexible Permission Model
- Combine RBAC, ABAC, and ReBAC as needed
- Support complex hierarchies (org → team → project → resource)
- Auditability
- Full audit log of all permission changes
- Path tracing shows WHY access was granted
- Real-time Updates
- Convex reactivity means UI updates instantly when permissions change
- No polling required (better than Zanzibar!)
- 大规模一致性
- 预计算权限确保快速、一致的校验
- 读取操作间无权限漂移
- 灵活的权限模型
- 按需组合RBAC、ABAC和ReBAC
- 支持复杂层级(组织 → 团队 → 项目 → 资源)
- 可审计性
- 所有权限变更的完整审计日志
- 路径追踪显示访问被授予的原因
- 实时更新
- Convex响应式特性意味着权限变更时UI即时更新
- 无需轮询(比Zanzibar更优!)
Comparison with Other Solutions
与其他方案对比
| Feature | @djpanda/convex-authz | OpenFGA | Oso | Cerbos |
|---|---|---|---|---|
| RBAC | ✅ | ✅ | ✅ | ✅ |
| ABAC | ✅ | ⚠️ Limited | ✅ | ✅ |
| ReBAC | ✅ | ✅ Native | ✅ | ⚠️ |
| O(1) Lookups | ✅ | ✅ | ✅ | ✅ |
| Convex Native | ✅ | ❌ | ❌ | ❌ |
| Type Safety | ✅ TypeScript | DSL | Polar | YAML |
| Real-time | ✅ Convex queries | Polling | Polling | Polling |
| Self-hosted | ✅ | ✅ | ✅ | ✅ |
| 特性 | @djpanda/convex-authz | OpenFGA | Oso | Cerbos |
|---|---|---|---|---|
| RBAC | ✅ | ✅ | ✅ | ✅ |
| ABAC | ✅ | ⚠️ 有限支持 | ✅ | ✅ |
| ReBAC | ✅ | ✅ 原生支持 | ✅ | ⚠️ |
| O(1) 查找 | ✅ | ✅ | ✅ | ✅ |
| Convex原生 | ✅ | ❌ | ❌ | ❌ |
| 类型安全 | ✅ TypeScript | DSL | Polar | YAML |
| 实时性 | ✅ Convex查询 | 轮询 | 轮询 | 轮询 |
| 自托管 | ✅ | ✅ | ✅ | ✅ |
Testing
测试
Running Package Tests
运行包测试
bash
cd packages/authz
npm testbash
cd packages/authz
npm testUsing with convex-test
与convex-test配合使用
typescript
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);
});
});typescript
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("授权", () => {
it("应分配并校验角色", 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);
});
});Multi-Tenant Data Isolation
多租户数据隔离
Every table in the authz component includes a required field as the leading column in every database index. This provides database-level data isolation between tenants — queries for one tenant can never return data from another.
tenantIdauthz组件中的每个表都包含必填的字段,作为每个数据库索引的首列。这在数据库层面提供了租户间的数据隔离——一个租户的查询永远不会返回另一个租户的数据。
tenantIdtenantId vs scope
tenantId vs scope
| | |
|---|---|---|
| Purpose | Data isolation boundary | Resource-level grouping |
| Enforcement | Database-level (index prefix) | Application-level (query filter) |
| Required | Always | Optional |
| Example | | |
- tenantId answers: "whose data is this?" — the organization/customer boundary
- scope answers: "within this tenant, what resource does this apply to?" — e.g. admin of a specific project
| | |
|---|---|---|
| 用途 | 数据隔离边界 | 资源级分组 |
| 强制方式 | 数据库级(索引前缀) | 应用级(查询过滤) |
| 必填 | 是 | 可选 |
| 示例 | | |
- tenantId 回答:"这是谁的数据?" —— 组织/客户边界
- scope 回答:"在该租户内,此权限适用于哪个资源?" —— 例如特定项目的管理员
Configuration
配置
typescript
// 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,
});typescript
// 单租户应用 —— 传递任意常量字符串
const authz = new Authz(components.authz, {
permissions, roles,
tenantId: "my-app",
});
// 多租户应用 —— 传递当前组织/租户ID
const authz = new Authz(components.authz, {
permissions, roles,
tenantId: currentOrgId,
});Cross-tenant operations
跨租户操作
For rare admin operations that need to access a different tenant's data, use the method:
withTenant()typescript
// Returns a new Authz instance scoped to a different tenant
const otherTenant = authz.withTenant("other-org-id");
await otherTenant.getUserRoles(ctx, userId);对于需要访问其他租户数据的罕见管理员操作,使用方法:
withTenant()typescript
// 返回绑定到其他租户的新Authz实例
const otherTenant = authz.withTenant("other-org-id");
await otherTenant.getUserRoles(ctx, userId);Compliance
合规性
The -first index design satisfies SOC2 and HIPAA data isolation requirements:
tenantId- All queries are partitioned by tenant at the database index level
- Cross-tenant data access is structurally impossible through the standard API
- is required in the constructor — it cannot be accidentally omitted
tenantId
tenantId- 所有查询在数据库索引层面按租户分区
- 通过标准API无法跨租户访问数据
- 构造函数必填——无法意外省略
tenantId
Best Practices
最佳实践
1. Use Scoped Roles for Multi-tenancy
1. 多租户场景使用作用域角色
typescript
// Don't: Global admin
await authz.assignRole(ctx, userId, "admin");
// Do: Scoped admin
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });typescript
// 不推荐:全局管理员
await authz.assignRole(ctx, userId, "admin");
// 推荐:作用域管理员
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });2. Use the Authz Client for Production
2. 生产环境使用Authz客户端
typescript
// Authz uses O(1) indexed lookups by default — no separate class needed
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });typescript
// Authz默认使用O(1)索引查找——无需单独的类
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });3. Use ReBAC for Complex Hierarchies
3. 复杂层级使用ReBAC
typescript
// CRM, document sharing, org charts → ReBAC
// Simple role assignments → RBACtypescript
// CRM、文档共享、组织架构 → ReBAC
// 简单角色分配 → RBAC4. Set Expiration for Temporary Access
4. 临时访问设置过期时间
typescript
await authz.assignRole(ctx, userId, "contractor", undefined,
Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days
);typescript
await authz.assignRole(ctx, userId, "contractor", undefined,
Date.now() + 30 * 24 * 60 * 60 * 1000 // 30天
);5. Always Use Audit Logging
5. 始终启用审计日志
The audit log is invaluable for:
- Compliance (SOC2, GDPR)
- Debugging access issues
- Security incident investigation
审计日志对于以下场景至关重要:
- 合规性(SOC2、GDPR)
- 调试访问问题
- 安全事件调查
6. Cleanup of Expired Data (Scheduled via Component)
6. 过期数据清理(通过组件调度)
Expired role assignments and permission overrides (and their indexed rows) are purged by a scheduled cleanup job that you enable once—no need to add yourself. The component embeds @convex-dev/crons; run this once after installing the component to register the daily job:
convex/crons.tsbash
npx convex run authz/cronSetup:ensureCleanupCronRegisteredOr from an init script that runs on deploy (e.g. invoked via ):
convex/init.tsconvex dev --run inittypescript
await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});The job runs every 24 hours and cleans , , , and . Optional: you can instead define the cleanup in your app's or run manually.
roleAssignmentspermissionOverrideseffectiveRoleseffectivePermissionsconvex/crons.tscomponents.authz.mutations.runScheduledCleanup过期的角色分配和权限覆盖(及其索引行)会由定时清理任务清除,您只需启用一次——无需自行添加。组件嵌入了@convex-dev/crons;安装组件后运行一次以下命令注册每日任务:
convex/crons.tsbash
npx convex run authz/cronSetup:ensureCleanupCronRegistered或者通过部署时运行的初始化脚本(例如,通过调用):
convex/init.tsconvex dev --run inittypescript
await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});该任务每24小时运行一次,清理、、和。可选:您也可以在应用的中定义清理任务,或手动运行。
roleAssignmentspermissionOverrideseffectiveRoleseffectivePermissionsconvex/crons.tscomponents.authz.mutations.runScheduledCleanup6.1. Audit log retention
6.1. 审计日志保留
To avoid unbounded growth of the audit log (compliance and cost), the same cron registration also schedules a daily audit retention job. Configure it with Convex environment variables (Dashboard or CLI):
| Variable | Description |
|---|---|
| Delete entries older than this many days (e.g. |
| Cap total entries by deleting oldest until count ≤ this value (e.g. |
Set at least one to enable retention. The job runs every 24 hours (same flow). You can also run manually with optional args to override env for that run.
ensureCleanupCronRegisteredcomponents.authz.mutations.runAuditRetentionCleanup{ maxAgeDays?, maxEntries? }为避免审计日志无限增长(合规性和成本考虑),相同的定时注册也会调度每日审计保留任务。通过Convex环境变量(控制台或CLI)配置:
| 环境变量 | 描述 |
|---|---|
| 删除早于此天数的条目(例如 |
| 通过删除最旧条目将总条目数限制为此值(例如 |
至少设置一个变量以启用保留功能。任务每24小时运行一次(与流程相同)。您也可以手动运行并传入可选参数,以覆盖本次运行的环境变量设置。
ensureCleanupCronRegisteredcomponents.authz.mutations.runAuditRetentionCleanup{ maxAgeDays?, maxEntries? }7. Use Authz as a Global Singleton
7. 将Authz作为全局单例使用
Authz is a global component — install it once and share a single client instance across your entire app. Do not create multiple instances per app.
Authzconvex/
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"typescript
// 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" });typescript
// 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");
// ...
},
});Authz是全局组件——安装一次并在整个应用中共享单个客户端实例。请勿在应用中创建多个实例。
Authzconvex/
convex.config.ts ← app.use(authz) —— 注册一次
authz.ts ← definePermissions, defineRoles, 导出authz客户端
documents.ts ← import { authz } from "./authz"
billing.ts ← import { authz } from "./authz"
settings.ts ← import { authz } from "./authz"typescript
// convex/authz.ts —— 单一数据源
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"],
},
});
// 导出单个authz客户端——在所有地方导入此实例
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });typescript
// convex/documents.ts —— 使用共享客户端
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. Organize Permissions by Domain
7. 按领域组织权限
In larger apps, split permission and role definitions by domain and merge them into a single authz client using and :
definePermissionsdefineRolestypescript
// 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 },
};typescript
// 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" });This keeps each domain self-contained while producing a single, type-safe authz client.
在大型应用中,按领域拆分权限和角色定义,使用和将它们合并到单个authz客户端:
definePermissionsdefineRolestypescript
// 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 },
};typescript
// convex/authz.ts —— 合并所有领域
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. Integrating with Other Convex Components
8. 与其他Convex组件集成
When other Convex components (e.g., ) need authorization, they share the same global authz instance. The pattern:
@djpanda/convex-tenants- Register both components independently in
convex.config.ts - The other component exports its required permissions and roles
- Merge them with your app's own definitions
- Pass the authz client to the other component's API factory
mermaid
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"]typescript
// 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;typescript
// 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" });typescript
// 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
},
});This way every part of your app — your own functions and third-party components — shares a single, consistent authorization layer.
当其他Convex组件(例如)需要授权时,它们共享同一个全局authz实例。模式如下:
@djpanda/convex-tenants- 在中独立注册两个组件
convex.config.ts - 其他组件导出其所需的权限和角色
- 将它们与您应用自身的定义合并
- 将authz客户端传递给其他组件的API工厂
mermaid
graph LR
subgraph app ["您的应用 (convex.config.ts)"]
AuthzComp["authz组件"]
TenantsComp["tenants组件"]
end
subgraph authzSetup ["convex/authz.ts"]
AppPerms["应用权限"]
TenantPerms["租户权限"]
Merge["definePermissions + defineRoles"]
Client["authz客户端(单例)"]
AppPerms --> Merge
TenantPerms --> Merge
Merge --> Client
end
Client -->|"import { authz }"| Docs["convex/documents.ts"]
Client -->|"import { authz }"| Billing["convex/billing.ts"]
Client -->|"传递给makeTenantsAPI"| TenantsAPI["convex/tenants.ts"]typescript
// convex/convex.config.ts —— 注册两个组件
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;typescript
// convex/authz.ts —— 合并应用 + 组件权限
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { TENANTS_PERMISSIONS, TENANTS_ROLES } from "@djpanda/convex-tenants";
import { components } from "./_generated/api";
// 您应用自身的权限
const appPermissions = {
documents: { create: true, read: true, update: true, delete: true },
};
const appRoles = {
editor: { documents: ["create", "read", "update"] as const },
};
// 与租户组件的权限合并
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" });typescript
// convex/tenants.ts —— 传递共享authz客户端
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) => {
// 返回当前用户ID
},
});这样,应用的每个部分——您自己的函数和第三方组件——都共享单个一致的授权层。
Development
开发
bash
undefinedbash
undefinedInstall dependencies
安装依赖
npm install
npm install
Run development mode
运行开发模式
npm run dev
npm run dev
Run tests
运行测试
npm test
npm test
Build for production
生产构建
npm run build
npm run build
Type check
类型检查
npm run typecheck
---npm run typecheck
---File Structure
文件结构
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 apppackages/authz/
├── package.json # 包配置
├── README.md # 本文档
├── src/
│ ├── client/
│ │ ├── index.ts # 主导出(Authz、助手函数)
│ │ └── index.test.ts # 客户端测试
│ ├── component/
│ │ ├── convex.config.ts # 组件注册
│ │ ├── schema.ts # 数据库表和索引
│ │ ├── helpers.ts # 共享工具
│ │ ├── queries.ts # 查询函数
│ │ ├── mutations.ts # 变更函数
│ │ ├── rebac.ts # ReBAC关系函数
│ │ ├── indexed.ts # O(1)索引函数
│ │ ├── authz.test.ts # RBAC/ABAC测试
│ │ ├── rebac.test.ts # ReBAC测试
│ │ ├── indexed.test.ts # O(1)索引测试
│ │ └── _generated/ # 自动生成的类型
│ └── test.ts # 测试助手
└── example/ # 示例应用License
许可证
MIT
MIT
Contributing
贡献
Contributions are welcome! Please read our CONTRIBUTING.md before submitting a PR.
欢迎贡献!提交PR前请阅读我们的CONTRIBUTING.md。