Loading...
Loading...
Compare original and translation side by side
| 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构建,支持实时更新 |
| 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) | 绕过基于角色权限的直接授权/拒绝 |
npm install @djpanda/convex-authznpm install @djpanda/convex-authz// 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;// 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;// 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" });// 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" });**inherits****includes**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"] },
});inheritsincludes**inherits****includes**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"] },
});inheritsincludes// 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...
},
});// 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,
});
// 执行更新操作...
},
});AuthzIndexedAuthzAuthzIndexedAuthzAuthzAuthzIndexedAuthzAuthzhasRelationaddRelationremoveRelationAuthztype"static""deferred"can()type**canWithContext()**recomputeUser()****withTenant()**AuthzAuthzIndexedAuthzhasRelationaddRelationremoveRelationAuthztype"static""deferred"can()type**canWithContext()**recomputeUser()**withTenant()// 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 });// 添加关系
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 });defineRelationPermissionsimport { 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 });defineRelationPermissionsimport { 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 });// 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,
});// 策略始终在调用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,
});// Rebuild a single user's effective permissions after upgrading
await authz.recomputeUser(ctx, userId);// 升级后重建单个用户的有效权限
await authz.recomputeUser(ctx, userId);const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");IndexedAuthzAuthzIndexedAuthzAuthzNote:is no longer exported in v2. The import below will fail — just replace it withIndexedAuthz.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));
}
},
});注意: v2版本不再导出。以下导入会失败——只需替换为IndexedAuthz。Authz
// 之前(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()// 一次性迁移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));
}
},
});PermissionGatecheckPermissiongetUserRolesuseQueryPermissionGatecheckPermissiongetUserRolesuseQuery// 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);
},
});// 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);
},
});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>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>{ allowed, isLoading, error }{ userId?, scope? }defaultUserIduserId{ roles, isLoading, error }{ userId?, scope? }childrenfallbackloadingFallbackimport {
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>
);
}PermissionGate{ allowed, isLoading, error }{ userId?, scope? }userIddefaultUserId{ roles, isLoading, error }{ userId?, scope? }childrenfallbackloadingFallbackimport {
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>
);
}PermissionGate┌─────────────────────────────────────────────────────────────────────────────┐
│ @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 │ │
│ │ [用户, 权限, 作用域] │ [用户, 角色] │ [主体, 关系, 对象] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘// 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.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);await authz.revokeRole(ctx, userId, "admin");
// Scoped
await authz.revokeRole(ctx, userId, "admin", { type: "team", id: "team_123" });await authz.revokeRole(ctx, userId, "admin");
// 作用域角色撤销
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 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",
});const isAdmin = await authz.hasRole(ctx, userId, "admin");
// Scoped
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});const isAdmin = await authz.hasRole(ctx, userId, "admin");
// 作用域角色校验
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
type: "team",
id: "team_123",
});resource:actionresourceaction*| Pattern | Meaning | Example matches |
|---|---|---|
| All permissions | |
| All actions on | |
| Read on any resource | |
| All permissions (same as | any |
can(ctx, userId, "documents:read")require(ctx, userId, "documents:read")documents:*:readdocuments:readgrantPermissiondenyPermission// 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")defineRolesdocuments: ["read", "update"]import { matchesPermissionPattern } from "@djpanda/convex-authz";
matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read"); // true
matchesPermissionPattern("settings:read", "documents:*"); // false资源:操作资源操作*| 模式 | 含义 | 匹配示例 |
|---|---|---|
| 所有权限 | |
| 文档的所有操作 | |
| 任意资源的读权限 | |
| 所有权限(与 | 任何 |
can(ctx, userId, "documents:read")require(ctx, userId, "documents:read")documents:**:readdocuments:readgrantPermissiondenyPermission// 授予所有文档操作权限
await authz.grantPermission(ctx, userId, "documents:*", undefined, "完整文档访问权限");
// 拒绝所有资源的读权限
await authz.denyPermission(ctx, userId, "*:read", undefined, "读权限已撤销");"documents:*"can(ctx, userId, "documents:read")defineRolesdocuments: ["read", "update"]import { matchesPermissionPattern } from "@djpanda/convex-authz";
matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read"); // true
matchesPermissionPattern("settings:read", "documents:*"); // falseconst roles = await authz.getUserRoles(ctx, userId);
// Returns: [{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]const roles = await authz.getUserRoles(ctx, userId);
// 返回:[{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]const allowed = await authz.canAny(ctx, userId, [
"documents:read",
"documents:update",
"documents:delete",
], scope);
// true if the user has at least one of these permissionsconst allowed = await authz.canAny(ctx, userId, [
"documents:read",
"documents:update",
"documents:delete",
], scope);
// 如果用户至少拥有其中一个权限则返回true// 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);// 一次性分配多个角色(每次调用最多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);const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);effectiveRoleseffectivePermissionseffectiveRelationshipsconst 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 }effectiveRoleseffectivePermissionseffectiveRelationshipsconst 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 }const result = await authz.deprovisionUser(ctx, userId, {
actorId: "security-team",
enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }canAnyassignRolesrevokeRolesconst result = await authz.deprovisionUser(ctx, userId, {
actorId: "security-team",
enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }canAnyassignRolesrevokeRolesawait authz.setAttribute(ctx, userId, "department", "engineering");
await authz.setAttribute(ctx, userId, "clearanceLevel", 5);
await authz.setAttribute(ctx, userId, "location", { country: "US", state: "CA" });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 }]const attributes = await authz.getUserAttributes(ctx, userId);
// 返回:[{ key: "department", value: "engineering" }, { key: "clearanceLevel", value: 5 }]conditionbooleanPromise<boolean>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" });evaluatePolicyConditionconst policy = policies["documents:update"];
if (policy) {
const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
if (!allowed) throw new Error(policy.message ?? "Permission denied");
}conditionbooleanPromise<boolean>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" });evaluatePolicyConditionconst policy = policies["documents:update"];
if (policy) {
const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
if (!allowed) throw new Error(policy.message ?? "权限拒绝");
}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;
}Promise<boolean>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;
}Promise<boolean>(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_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" });// 使用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" });// 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" });// 使用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" });// 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"
// }(objectType, objectId, relation)// 定义权限如何通过关系流转
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: "通过关系链获得访问权限"
// }(objectType, objectId, relation)// 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!
};// 使用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可以通过关系链访问该交易!
};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" });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" });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, 权限, 作用域] │
▼ └─────────────────────────────┘
┌───────────────┐ │
│ 扩展权限 │ ◄── 循环 ▼
└──┬────────────┘ 是/否
│
▼
┌──────────────┐
│ 校验每个 │ ◄── 循环
│ 权限 │
└──┬───────────┘
│
▼
是/否| 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) |
| 操作 | 传统方式 | 索引方式 |
|---|---|---|
| 权限校验 | O(角色数 × 权限数) | O(1) |
| 角色分配 | O(1) | O(权限数) |
| 权限授予 | O(1) | O(1) |
| 内存使用 | 较低 | 较高(非规范化) |
role_assignedrole_revokedpermission_grantedpermission_deniedattribute_setattribute_removedpermission_checkrole_assignedrole_revokedpermission_grantedpermission_deniedattribute_setattribute_removedpermission_checkgetAuditLoglimit// 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,
});numItemscursor{ 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,
});
}
}getAuditLoglimit// 获取用户的所有日志
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 }// 第一页
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,
});
}
}{
_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" },
},
}{
_id: "...",
timestamp: 1704672000000,
actorId: "admin_user", // 执行变更的用户
action: "role_assigned",
userId: "target_user", // 受影响的用户
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);// 直接授予权限(绕过角色校验)
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);// Deny a permission (even if user has it via role)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "Access restricted");// 拒绝权限(即使用户通过角色拥有该权限)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "访问受限");| 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)) |
| 授权审计追踪 |
// 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"])// 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"])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 }>
}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 }>
}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 | |
scopescope: { type: "", id: "x" }AuthzError| 参数 | 规则 | 示例错误 |
|---|---|---|
| 非空字符串,最大512字符 | |
| 必须为 | |
| 提供时, | |
| 非空字符串;必须是构造函数传入的角色之一 | |
| 提供时,必须是有限数字(时间戳) | |
属性 | 非空字符串 | |
| 提供时,必须是1–1000的正整数 | |
| 提供时(分页),必须是1–1000的正整数 | 与 |
| 关系参数 | | |
| 非空数组,每个元素为有效的 | |
| 非空数组,每个角色有效,长度≤20 | |
scopescope: { type: "", id: "x" }| 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)查找 |
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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 ✓ │ │
│ │ → 允许 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘| 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查询 | 轮询 | 轮询 | 轮询 |
| 自托管 | ✅ | ✅ | ✅ | ✅ |
cd packages/authz
npm testcd packages/authz
npm testimport { 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);
});
});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);
});
});tenantIdtenantId | | |
|---|---|---|
| Purpose | Data isolation boundary | Resource-level grouping |
| Enforcement | Database-level (index prefix) | Application-level (query filter) |
| Required | Always | Optional |
| Example | | |
| | |
|---|---|---|
| 用途 | 数据隔离边界 | 资源级分组 |
| 强制方式 | 数据库级(索引前缀) | 应用级(查询过滤) |
| 必填 | 是 | 可选 |
| 示例 | | |
// 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,
});// 单租户应用 —— 传递任意常量字符串
const authz = new Authz(components.authz, {
permissions, roles,
tenantId: "my-app",
});
// 多租户应用 —— 传递当前组织/租户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);withTenant()// 返回绑定到其他租户的新Authz实例
const otherTenant = authz.withTenant("other-org-id");
await otherTenant.getUserRoles(ctx, userId);tenantIdtenantIdtenantIdtenantId// Don't: Global admin
await authz.assignRole(ctx, userId, "admin");
// Do: Scoped admin
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });// 不推荐:全局管理员
await authz.assignRole(ctx, userId, "admin");
// 推荐:作用域管理员
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });// Authz uses O(1) indexed lookups by default — no separate class needed
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });// Authz默认使用O(1)索引查找——无需单独的类
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });// CRM, document sharing, org charts → ReBAC
// Simple role assignments → RBAC// CRM、文档共享、组织架构 → ReBAC
// 简单角色分配 → RBACawait authz.assignRole(ctx, userId, "contractor", undefined,
Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days
);await authz.assignRole(ctx, userId, "contractor", undefined,
Date.now() + 30 * 24 * 60 * 60 * 1000 // 30天
);convex/crons.tsnpx convex run authz/cronSetup:ensureCleanupCronRegisteredconvex/init.tsconvex dev --run initawait ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});roleAssignmentspermissionOverrideseffectiveRoleseffectivePermissionsconvex/crons.tscomponents.authz.mutations.runScheduledCleanupconvex/crons.tsnpx convex run authz/cronSetup:ensureCleanupCronRegisteredconvex/init.tsconvex dev --run initawait ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});roleAssignmentspermissionOverrideseffectiveRoleseffectivePermissionsconvex/crons.tscomponents.authz.mutations.runScheduledCleanup| Variable | Description |
|---|---|
| Delete entries older than this many days (e.g. |
| Cap total entries by deleting oldest until count ≤ this value (e.g. |
ensureCleanupCronRegisteredcomponents.authz.mutations.runAuditRetentionCleanup{ maxAgeDays?, maxEntries? }| 环境变量 | 描述 |
|---|---|
| 删除早于此天数的条目(例如 |
| 通过删除最旧条目将总条目数限制为此值(例如 |
ensureCleanupCronRegisteredcomponents.authz.mutations.runAuditRetentionCleanup{ maxAgeDays?, maxEntries? }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"// 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");
// ...
},
});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"// 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" });// 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");
// ...
},
});definePermissionsdefineRoles// 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" });definePermissionsdefineRoles// 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 —— 合并所有领域
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" });@djpanda/convex-tenantsconvex.config.tsgraph 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
},
});@djpanda/convex-tenantsconvex.config.tsgraph 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"]// 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;// 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" });// 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
},
});undefinedundefined
---
---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/ # 示例应用