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.
npm version License: MIT Convex Approved
一个面向Convex的全面、生产就绪的授权组件,具备RBACABACReBAC功能,支持O(1)索引查找,灵感来自Google Zanzibar
npm version License: MIT Convex Approved

Features

功能特性

FeatureDescription
RBACRole-Based Access Control with scoped roles
ABACAttribute-Based Access Control with custom policies
ReBACRelationship-Based Access Control with graph traversal
O(1) LookupsPre-computed permissions for instant checks
Type SafetyFull TypeScript support with type-safe permissions
Audit LoggingTrack all permission changes and checks
Scoped PermissionsResource-level access control
Expiring GrantsTime-limited role assignments and permissions
Convex NativeBuilt specifically for Convex, with real-time updates
功能特性描述
RBAC支持作用域角色的基于角色的访问控制
ABAC支持自定义策略的基于属性的访问控制
ReBAC支持图遍历的基于关系的访问控制
O(1) 查找预计算权限实现即时校验
类型安全完整的TypeScript支持,权限类型安全
审计日志追踪所有权限变更与校验操作
作用域权限资源级别的访问控制
过期授权支持限时角色分配与权限
Convex原生专为Convex构建,支持实时更新

Terminology

术语定义

TermDefinition
RBACRole-Based Access Control - permissions assigned via roles (admin, editor, viewer)
ABACAttribute-Based Access Control - permissions based on user/resource attributes (department=engineering)
ReBACRelationship-Based Access Control - permissions derived from relationships (member of team that owns resource)
ZanzibarGoogle's global authorization system, inspiration for OpenFGA and this component
TupleA relationship triple:
(subject, relation, object)
e.g.,
(user:alice, member, team:sales)
ScopeResource-level permission context, e.g., "admin of team:123" vs global "admin"
TraversalFollowing relationship chains to determine inherited access
O(1) LookupConstant-time permission check via pre-computed indexes
Permission OverrideDirect grant/deny that bypasses role-based permissions

术语定义
RBAC基于角色的访问控制 - 通过角色(管理员、编辑者、查看者)分配权限
ABAC基于属性的访问控制 - 基于用户/资源属性(如department=engineering)分配权限
ReBAC基于关系的访问控制 - 从实体关系(如拥有资源的团队成员)推导权限
ZanzibarGoogle的全局授权系统,是OpenFGA和本组件的设计灵感来源
元组(Tuple)关系三元组:
(主体, 关系, 对象)
例如:
(user:alice, member, team:sales)
作用域(Scope)资源级权限上下文,例如"team:123的管理员" vs 全局"管理员"
遍历(Traversal)跟随关系链判断继承访问权限
O(1) 查找通过预计算索引实现常量时间权限校验
权限覆盖(Permission Override)绕过基于角色权限的直接授权/拒绝

Installation

安装

bash
npm install @djpanda/convex-authz

bash
npm install @djpanda/convex-authz

Quick 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:
  • **inherits**
    – one parent role; effective permissions = parent’s permissions ∪ this role’s direct permissions.
  • **includes**
    – multiple roles; effective permissions = union of all included roles’ permissions ∪ this role’s direct permissions.
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:
inherits
and
includes
are reserved keys in role definitions; do not use them as permission resource names.
角色可以基于其他角色定义,避免重复权限列表:
  • **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"] },
});
注意:
inherits
includes
是角色定义中的保留关键字;请勿将它们用作权限资源名称。

3. 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
Authz
class. If you previously used
IndexedAuthz
, just rename it — the constructor signature is identical.
v2版本将所有功能整合到单个
Authz
类中。如果您之前使用
IndexedAuthz
,只需重命名即可——构造函数签名完全相同。

What changed

变更内容

  • One class:
    Authz
    replaces both the original
    Authz
    and
    IndexedAuthz
    . O(1) reads via pre-computed effective tables are now the default.
  • ReBAC on
    Authz
    :
    hasRelation
    ,
    addRelation
    , and
    removeRelation
    are available directly on every
    Authz
    instance.
  • ABAC policy types: Policies accept a
    type
    field (
    "static"
    or
    "deferred"
    ). In the current implementation, both types are evaluated at read-time when
    can()
    is called — Convex mutations cannot call queries, so write-time evaluation is not possible. The
    type
    field is reserved for future optimization but currently has no behavioral difference.
  • **canWithContext()
    **: Check deferred ABAC policies that need runtime context (e.g. IP address, time of day).
  • **recomputeUser()**
    : Rebuild a user's effective-permissions table on demand — useful after a schema change or post-deploy migration.
  • **withTenant()**
    : Get a scoped copy of the client bound to a different tenant for cross-tenant admin operations.
  • 单一类
    Authz
    替代原有的
    Authz
    IndexedAuthz
    。通过预计算有效表实现的O(1)读取现在是默认行为。
  • ReBAC集成
    hasRelation
    addRelation
    removeRelation
    直接在每个
    Authz
    实例上可用。
  • ABAC策略类型:策略接受
    type
    字段(
    "static"
    "deferred"
    )。在当前实现中,两种类型都会在调用
    can()
    时的读取阶段评估——Convex变更无法调用查询,因此无法在写入阶段评估。
    type
    字段是为未来优化预留的,目前没有行为差异。
  • **canWithContext()
    **:校验需要运行时上下文(如IP地址、时间)的延迟ABAC策略。
  • **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
defineRelationPermissions
to automatically grant permissions when relationships are created:
typescript
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 });
使用
defineRelationPermissions
在创建关系时自动授予权限:
typescript
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

迁移指南:
IndexedAuthz
Authz

Note:
IndexedAuthz
is no longer exported in v2. The import below will fail — just replace it with
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
recomputeUser()
for each existing user to backfill the effective-permissions table:
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
PermissionGate
component so your UI can check permissions and roles reactively. Your app must expose Convex queries that wrap the Authz component (e.g.
checkPermission
,
getUserRoles
). The hooks call those queries via Convex’s
useQuery
, so permission and role changes stay up to date without polling.
该包提供React钩子和
PermissionGate
组件,让您的UI可以响应式地校验权限和角色。您的应用必须暴露封装Authz组件的Convex查询(例如
checkPermission
getUserRoles
)。钩子通过Convex的
useQuery
调用这些查询,因此权限和角色变更会自动更新,无需轮询。

1. 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
    { allowed, isLoading, error }
    . Options:
    { userId?, scope? }
    . Uses
    defaultUserId
    from the provider when
    userId
    is omitted.
  • useUserRoles(options?) — Returns
    { roles, isLoading, error }
    . Options:
    { userId?, scope? }
    .
  • useRequirePermission(permission, options?) — Throws when the user is not allowed (use an error boundary to show a denied state).
  • PermissionGate — Renders
    children
    when allowed,
    fallback
    when denied, and
    loadingFallback
    (optional) while loading.
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
PermissionGate
re-run and the UI updates automatically.

  • 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的响应式特性确保后端权限或角色变更时,钩子和
PermissionGate
会重新运行,UI自动更新。

Architecture

架构

┌─────────────────────────────────────────────────────────────────────────────┐
│                           @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:
resource:action
. Either
resource
or
action
(or both) may be
*
:
PatternMeaningExample matches
*
All permissions
documents:read
,
settings:manage
documents:
*
All actions on
documents
documents:read
,
documents:update
*:read
Read on any resource
documents:read
,
settings:read
*:*
All permissions (same as
*
)
any
resource:action
Checking: When you call
can(ctx, userId, "documents:read")
or
require(ctx, userId, "documents:read")
, the backend treats any stored role or override that matches that permission as granting it. So if the user has a role with
documents:
* or an override
*:read
, they are allowed for
documents:read
.
Allocation: You can pass a pattern into
grantPermission
and
denyPermission
:
typescript
// 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
"documents:*"
(in the flattened role–permission map), then
can(ctx, userId, "documents:read")
is allowed. With
defineRoles
you typically list concrete actions per resource (e.g.
documents: ["read", "update"]
); to use patterns in roles you would supply a role-permission map that includes pattern strings for that role.
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:*"); // false
The same wildcard behavior applies to all permission checks.
权限校验和覆盖支持通配符模式,让您可以一次性授予或拒绝整组权限。
模式格式:
资源:操作
资源
操作
(或两者)可以是
*
模式含义匹配示例
*
所有权限
documents:read
,
settings:manage
documents:*
文档的所有操作
documents:read
,
documents:update
*:read
任意资源的读权限
documents:read
,
settings:read
*:*
所有权限(与
*
相同)
任何
资源:操作
校验: 当您调用
can(ctx, userId, "documents:read")
require(ctx, userId, "documents:read")
时,后端会将任何与该权限匹配的已存储角色或覆盖视为授权。因此,如果用户拥有带
documents:*
的角色或
*:read
的覆盖,他们将被允许执行
documents:read
分配: 您可以将模式传递给
grantPermission
denyPermission
typescript
// 授予所有文档操作权限
await authz.grantPermission(ctx, userId, "documents:*", undefined, "完整文档访问权限");

// 拒绝所有资源的读权限
await authz.denyPermission(ctx, userId, "*:read", undefined, "读权限已撤销");
角色定义: 组件评估权限时,会使用相同的模式规则将请求的权限与每个角色的权限列表匹配。因此,如果角色的权限包含
"documents:*"
(在扁平化的角色-权限映射中),则
can(ctx, userId, "documents:read")
会被允许。使用
defineRoles
时,您通常会列出每个资源的具体操作(例如
documents: ["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);
// 如果用户至少拥有其中一个权限则返回true

Bulk 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
effectiveRoles
,
effectivePermissions
, and
effectiveRelationships
when present:
typescript
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关系(可选作用域)。同时清除索引化的
effectiveRoles
effectivePermissions
effectiveRelationships
(如果存在):
typescript
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
canAny
up to 100 items, roles in
assignRoles
/
revokeRoles
up to 20 items. The client and component validate and throw a clear error if exceeded.

对于安全事件响应、企业用户离职或一键停用,使用deprovisionUser原子性地清除用户的所有角色、属性、关系和权限覆盖(无作用域,无选项):
typescript
const result = await authz.deprovisionUser(ctx, userId, {
  actorId: "security-team",
  enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }
批量数组有调用限制:
canAny
中的权限最多100项
assignRoles
/
revokeRoles
中的角色最多20项。客户端和组件会进行验证,如果超出限制会抛出清晰的错误。

ABAC (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
condition
function may return either a
boolean
or a
Promise<boolean>
, so you can use async logic (e.g. querying the database or calling external APIs).
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
evaluatePolicyCondition
to normalize to a Promise:
typescript
const policy = policies["documents:update"];
if (policy) {
  const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
  if (!allowed) throw new Error(policy.message ?? "Permission denied");
}
condition
函数可以返回
boolean
Promise<boolean>
,因此您可以使用异步逻辑(例如查询数据库或调用外部API)。
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条件,以便同步和异步策略都能正常工作。使用
evaluatePolicyCondition
将其规范化为Promise:
typescript
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
Promise<boolean>
for async policies.

策略会接收包含以下内容的上下文对象:
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_deal

Adding 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
(objectType, objectId, relation)
nodes so that circular relationships do not cause infinite loops.
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

权衡

OperationTraditionalIndexed
Permission CheckO(roles × perms)O(1)
Role AssignmentO(1)O(permissions)
Permission GrantO(1)O(1)
Memory UsageLowerHigher (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:
  • role_assigned
    - When a role is assigned
  • role_revoked
    - When a role is revoked
  • permission_granted
    - When a direct permission is granted
  • permission_denied
    - When a permission is explicitly denied
  • attribute_set
    - When a user attribute is set
  • attribute_removed
    - When a user attribute is removed
  • permission_check
    - (Optional) When permissions are checked
以下操作会自动记录:
  • role_assigned
    - 角色分配时
  • role_revoked
    - 角色撤销时
  • permission_granted
    - 直接授予权限时
  • permission_denied
    - 显式拒绝权限时
  • attribute_set
    - 设置用户属性时
  • attribute_removed
    - 删除用户属性时
  • permission_check
    - (可选)权限校验时

Querying the Audit Log

查询审计日志

Without pagination options,
getAuditLog
returns a simple array (optional
limit
, default 100):
typescript
// 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
numItems
(and optionally
cursor
for the next page). The return value is then
{ 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,
    });
  }
}
不传递分页选项时,
getAuditLog
返回简单数组(可选
limit
,默认100):
typescript
// 获取用户的所有日志
const logs = await authz.getAuditLog(ctx, {
  userId: "user_123",
  limit: 50,
});

// 获取指定操作类型的日志
const roleChanges = await authz.getAuditLog(ctx, {
  action: "role_assigned",
  limit: 100,
});
对于可扩展的浏览,通过传递
numItems
(可选
cursor
用于下一页)使用基于游标的分页。返回值为
{ 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

表结构

TablePurpose
roleAssignments
User role assignments
userAttributes
User attributes for ABAC
permissionOverrides
Direct permission grants/denials
relationships
ReBAC relationship tuples
effectivePermissions
Pre-computed permissions (O(1))
effectiveRoles
Pre-computed roles (O(1))
effectiveRelationships
Pre-computed relationships (O(1))
auditLog
Authorization audit trail
表名用途
roleAssignments
用户角色分配
userAttributes
ABAC用户属性
permissionOverrides
直接权限授予/拒绝
relationships
ReBAC关系元组
effectivePermissions
预计算权限(O(1))
effectiveRoles
预计算角色(O(1))
effectiveRelationships
预计算关系(O(1))
auditLog
授权审计追踪

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
Authz
validate their arguments before calling the component. Invalid inputs throw an
Error
with a clear message so you can fail fast and fix call sites.
ArgumentRuleExample error
userId
Non-empty string, max 512 characters
"userId must be a non-empty string"
permission
Must be
resource:action
(e.g.
documents:read
)
"Invalid permission format: \"read\". Expected \"resource:action\""
scope
When provided,
type
and
id
must be non-empty strings
"scope must have non-empty type when provided"
role
Non-empty string; must be one of the roles passed at construction
"Unknown role: \"superadmin\""
expiresAt
When provided, must be a finite number (timestamp)
"expiresAt must be a finite number"
Attribute
key
Non-empty string
"Attribute key must be a non-empty string"
getAuditLog
limit
When provided, positive integer 1–1000
"limit must be a positive integer when provided"
getAuditLog
numItems
When provided (pagination), positive integer 1–1000same as
limit
Relation args
subjectType
,
subjectId
,
relation
,
objectType
,
objectId
must be non-empty strings
"subjectType must be a non-empty string"
canAny
permissions
Non-empty array, each element valid
resource:action
, length ≤ 100
"permissions must not exceed 100 items"
assignRoles
/
revokeRoles
roles
Non-empty array, each role valid, length ≤ 20
"roles must not exceed 20 items"
Optional parameters are only validated when present (e.g. omitting
scope
is valid; passing
scope: { type: "", id: "x" }
throws).

Authz
的所有公共方法在调用组件前都会验证参数。无效输入会抛出
Error
并附带清晰的消息,以便快速定位并修复调用位置。
参数规则示例错误
userId
非空字符串,最大512字符
"userId必须是非空字符串"
permission
必须为
资源:操作
格式(例如
documents:read
"无效权限格式: \"read\". 预期格式为\"资源:操作\""
scope
提供时,
type
id
必须是非空字符串
"提供作用域时必须包含非空type"
role
非空字符串;必须是构造函数传入的角色之一
"未知角色: \"superadmin\""
expiresAt
提供时,必须是有限数字(时间戳)
"expiresAt必须是有限数字"
属性
key
非空字符串
"属性key必须是非空字符串"
getAuditLog
limit
提供时,必须是1–1000的正整数
"limit提供时必须是正整数"
getAuditLog
numItems
提供时(分页),必须是1–1000的正整数
limit
规则相同
关系参数
subjectType
subjectId
relation
objectType
objectId
必须是非空字符串
"subjectType必须是非空字符串"
canAny
permissions
非空数组,每个元素为有效的
资源:操作
格式,长度≤100
"permissions数量不能超过100项"
assignRoles
/
revokeRoles
roles
非空数组,每个角色有效,长度≤20
"roles数量不能超过20项"
可选参数仅在提供时验证(例如省略
scope
是有效的;传递
scope: { 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 ConceptOur ImplementationDescription
Relation Tuples
relationships
table
(user:alice, member, team:sales)
UsersetsTraversal rulesGroups defined by relationships
Check API
checkPermissionFast
O(1) "can user X do Y on Z?"
Expand API
checkRelationWithTraversal
Find all paths granting access
Read API
getSubjectRelations
List all relationships
Watch APIConvex reactivityReal-time permission updates
Computed Relations
effectivePermissions
Pre-computed for O(1) lookup
Zanzibar概念我们的实现描述
关系元组
relationships
(user:alice, member, team:sales)
用户集遍历规则由关系定义的组
Check API
checkPermissionFast
O(1) "用户X能否对Z执行Y?"
Expand API
checkRelationWithTraversal
查找所有授予访问权限的路径
Read API
getSubjectRelations
列出所有关系
Watch APIConvex响应式特性实时权限更新
计算关系
effectivePermissions
预计算实现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核心优势

  1. Consistency at Scale
  • Pre-computed permissions ensure fast, consistent checks
  • No permission drift between reads
  1. Flexible Permission Model
  • Combine RBAC, ABAC, and ReBAC as needed
  • Support complex hierarchies (org → team → project → resource)
  1. Auditability
  • Full audit log of all permission changes
  • Path tracing shows WHY access was granted
  1. Real-time Updates
  • Convex reactivity means UI updates instantly when permissions change
  • No polling required (better than Zanzibar!)

  1. 大规模一致性
  • 预计算权限确保快速、一致的校验
  • 读取操作间无权限漂移
  1. 灵活的权限模型
  • 按需组合RBAC、ABAC和ReBAC
  • 支持复杂层级(组织 → 团队 → 项目 → 资源)
  1. 可审计性
  • 所有权限变更的完整审计日志
  • 路径追踪显示访问被授予的原因
  1. 实时更新
  • Convex响应式特性意味着权限变更时UI即时更新
  • 无需轮询(比Zanzibar更优!)

Comparison with Other Solutions

与其他方案对比

Feature@djpanda/convex-authzOpenFGAOsoCerbos
RBAC
ABAC⚠️ Limited
ReBAC✅ Native⚠️
O(1) Lookups
Convex Native
Type Safety✅ TypeScriptDSLPolarYAML
Real-time✅ Convex queriesPollingPollingPolling
Self-hosted

特性@djpanda/convex-authzOpenFGAOsoCerbos
RBAC
ABAC⚠️ 有限支持
ReBAC✅ 原生支持⚠️
O(1) 查找
Convex原生
类型安全✅ TypeScriptDSLPolarYAML
实时性✅ Convex查询轮询轮询轮询
自托管

Testing

测试

Running Package Tests

运行包测试

bash
cd packages/authz
npm test
bash
cd packages/authz
npm test

Using 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
tenantId
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.
authz组件中的每个表都包含必填的
tenantId
字段,作为每个数据库索引的首列。这在数据库层面提供了租户间的数据隔离——一个租户的查询永远不会返回另一个租户的数据。

tenantId vs scope

tenantId vs scope

tenantId
scope
PurposeData isolation boundaryResource-level grouping
EnforcementDatabase-level (index prefix)Application-level (query filter)
RequiredAlwaysOptional
Example
"acme-corp"
{ type: "project", id: "proj-123" }
  • 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
用途数据隔离边界资源级分组
强制方式数据库级(索引前缀)应用级(查询过滤)
必填可选
示例
"acme-corp"
{ type: "project", id: "proj-123" }
  • 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
withTenant()
method:
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
tenantId
-first index design satisfies SOC2 and HIPAA data isolation requirements:
  • All queries are partitioned by tenant at the database index level
  • Cross-tenant data access is structurally impossible through the standard API
  • tenantId
    is required in the constructor — it cannot be accidentally omitted

tenantId
优先的索引设计满足SOC2和HIPAA的数据隔离要求:
  • 所有查询在数据库索引层面按租户分区
  • 通过标准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 → RBAC
typescript
// CRM、文档共享、组织架构 → ReBAC
// 简单角色分配 → RBAC

4. 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
convex/crons.ts
yourself. The component embeds @convex-dev/crons; run this once after installing the component to register the daily job:
bash
npx convex run authz/cronSetup:ensureCleanupCronRegistered
Or from an init script that runs on deploy (e.g.
convex/init.ts
invoked via
convex dev --run init
):
typescript
await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});
The job runs every 24 hours and cleans
roleAssignments
,
permissionOverrides
,
effectiveRoles
, and
effectivePermissions
. Optional: you can instead define the cleanup in your app's
convex/crons.ts
or run
components.authz.mutations.runScheduledCleanup
manually.
过期的角色分配和权限覆盖(及其索引行)会由定时清理任务清除,您只需启用一次——无需自行添加
convex/crons.ts
。组件嵌入了@convex-dev/crons;安装组件后运行一次以下命令注册每日任务:
bash
npx convex run authz/cronSetup:ensureCleanupCronRegistered
或者通过部署时运行的初始化脚本(例如
convex/init.ts
,通过
convex dev --run init
调用):
typescript
await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});
该任务每24小时运行一次,清理
roleAssignments
permissionOverrides
effectiveRoles
effectivePermissions
。可选:您也可以在应用的
convex/crons.ts
中定义清理任务,或手动运行
components.authz.mutations.runScheduledCleanup

6.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):
VariableDescription
AUDIT_RETENTION_DAYS
Delete entries older than this many days (e.g.
90
). Omit or
0
= do not prune by age.
AUDIT_RETENTION_MAX_ENTRIES
Cap total entries by deleting oldest until count ≤ this value (e.g.
100000
). Omit or
0
= do not prune by count.
Set at least one to enable retention. The job runs every 24 hours (same
ensureCleanupCronRegistered
flow). You can also run
components.authz.mutations.runAuditRetentionCleanup
manually with optional args
{ maxAgeDays?, maxEntries? }
to override env for that run.
为避免审计日志无限增长(合规性和成本考虑),相同的定时注册也会调度每日审计保留任务。通过Convex环境变量(控制台或CLI)配置:
环境变量描述
AUDIT_RETENTION_DAYS
删除早于此天数的条目(例如
90
)。省略或设为
0
= 不按年龄清理。
AUDIT_RETENTION_MAX_ENTRIES
通过删除最旧条目将总条目数限制为此值(例如
100000
)。省略或设为
0
= 不按数量清理。
至少设置一个变量以启用保留功能。任务每24小时运行一次(与
ensureCleanupCronRegistered
流程相同)。您也可以手动运行
components.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
Authz
instances per app.
convex/
  convex.config.ts   ← app.use(authz) — registered once
  authz.ts           ← definePermissions, defineRoles, export authz client
  documents.ts       ← import { authz } from "./authz"
  billing.ts         ← import { authz } from "./authz"
  settings.ts        ← import { authz } from "./authz"
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是全局组件——安装一次并在整个应用中共享单个客户端实例。请勿在应用中创建多个
Authz
实例。
convex/
  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
definePermissions
and
defineRoles
:
typescript
// 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.
在大型应用中,按领域拆分权限和角色定义,使用
definePermissions
defineRoles
将它们合并到单个authz客户端:
typescript
// 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.,
@djpanda/convex-tenants
) need authorization, they share the same global authz instance. The pattern:
  1. Register both components independently in
    convex.config.ts
  2. The other component exports its required permissions and roles
  3. Merge them with your app's own definitions
  4. 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组件(例如
@djpanda/convex-tenants
)需要授权时,它们共享同一个全局authz实例。模式如下:
  1. convex.config.ts
    中独立注册两个组件
  2. 其他组件导出其所需的权限和角色
  3. 将它们与您应用自身的定义合并
  4. 将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
undefined
bash
undefined

Install 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 app

packages/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