mcpField
C
convex-authz

convex-authz

github.com/dbjpanda安全11170 次安装25 周前更新
代码仓库

关于 convex-authz

@djpanda/convex-authz 是供 Convex 应用使用的 TypeScript 授权组件,整合基于角色、属性和关系的访问控制。它提供索引权限检查,并支持 React 响应式集成。

主要功能

  1. 01支持 RBAC、ABAC 和 ReBAC
  2. 02索引化有效权限检查
  3. 03作用域角色与资源权限
  4. 04权限审计日志和限时授权
  5. 05关系遍历与权限映射
  6. 06React 响应式 Hooks 和 PermissionGate

适用场景

  1. 01控制文档和资源的访问权限
  2. 02建模团队或组织关系
  3. 03应用基于属性的访问策略
  4. 04架构变更后重建权限

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dbjpanda/convex-tenants --skill convex-authz

安装后在 Claude Code 中输入/convex-authz确认已加载。

详细介绍

@djpanda/convex-authz

一个全面、可用于生产环境的 Convex 授权组件,支持 RBAC、ABAC 和 ReBAC,并提供 O(1) 索引查找,灵感来自 Google Zanzibar。

npm 版本 许可证:MIT Convex 认证组件

功能

功能描述
RBAC基于角色的访问控制,支持限定范围的角色
ABAC基于属性的访问控制,支持自定义策略
ReBAC基于关系的访问控制,支持图遍历
O(1) 查找预计算权限,实现即时检查
类型安全完整的 TypeScript 支持,权限具有类型安全性
审计日志跟踪所有权限变更和检查
限定范围的权限资源级访问控制
可过期授权有时限的角色分配和权限
Convex 原生支持专为 Convex 构建,支持实时更新

术语

术语定义
RBAC基于角色的访问控制 - 通过角色(管理员、编辑者、查看者)分配权限
ABAC基于属性的访问控制 - 根据用户/资源属性(department=engineering)授予权限
ReBAC基于关系的访问控制 - 根据关系派生权限(属于拥有某资源的团队)
ZanzibarGoogle 的全局授权系统,是 OpenFGA 和此组件的灵感来源
元组一个关系三元组:(subject, relation, object),例如 (user:alice, member, team:sales)
范围资源级权限上下文,例如“team:123 的管理员”与全局“管理员”
遍历沿关系链追踪,以确定继承的访问权限
O(1) 查找通过预计算索引,以恒定时间检查权限
权限覆盖绕过基于角色的权限,直接授予/拒绝访问

安装

npm install @djpanda/convex-authz

快速开始

1. 注册组件
// convex/convex.config.ts
import { defineApp } from "convex/server";
import authz from "@djpanda/convex-authz/convex.config";

const app = defineApp();
app.use(authz);

export default app;
2. 定义权限和角色
// convex/authz.ts
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";

// Step 1: Define permissions
const permissions = definePermissions({
  documents: {
    create: true,
    read: true,
    update: true,
    delete: true,
  },
  settings: {
    view: true,
    manage: true,
  },
});

// Step 2: Define roles
const roles = defineRoles(permissions, {
  admin: {
    documents: ["create", "read", "update", "delete"],
    settings: ["view", "manage"],
  },
  editor: {
    documents: ["create", "read", "update"],
    settings: ["view"],
  },
  viewer: {
    documents: ["read"],
  },
});

// Step 3: Create the authz client
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
角色继承与组合

可以根据其他角色定义角色,以避免重复列出权限:

  • **inherits** – 一个父角色;有效权限 = 父角色的权限 ∪ 此角色的直接权限。
  • **includes** – 多个角色;有效权限 = 所有包含角色的权限并集 ∪ 此角色的直接权限。

继承示例(admin > editor > viewer):

const roles = defineRoles(permissions, {
  viewer: { documents: ["read"] },
  editor: { inherits: "viewer", documents: ["create", "update"] },
  admin: { inherits: "editor", documents: ["delete"], settings: ["manage"] },
});

组合示例(合并角色):

const roles = defineRoles(permissions, {
  editor: { documents: ["create", "read", "update"] },
  billing_admin: { billing: ["view", "manage"] },
  billing_manager: { includes: ["editor", "billing_admin"], settings: ["view"] },
});

注意: inherits 和 includes 是角色定义中的保留键;请勿将它们用作权限资源名称。

3. 在函数中使用
// convex/documents.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { authz } from "./authz";
import { getAuthUserId } from "@convex-dev/auth/server";

export const updateDocument = mutation({
  args: { docId: v.id("documents"), content: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    
    // Check permission (throws if denied)
    await authz.require(ctx, userId, "documents:update");
    
    // Or with scope
    await authz.require(ctx, userId, "documents:update", {
      type: "document",
      id: args.docId,
    });
    
    // Proceed with update...
  },
});

统一授权 v2

v2 将所有内容整合到一个 Authz 类中。如果你之前使用的是 IndexedAuthz,只需将其重命名即可——构造函数签名相同。

变更内容
  • 一个类:Authz 取代了原来的 Authz 和 IndexedAuthz。通过预先计算有效权限表实现的 O(1) 读取现在是默认行为。
  • Authz 上的 ReBAC:每个 Authz 实例都可以直接使用 hasRelation、addRelation 和 removeRelation。
  • ABAC 策略类型:策略接受一个 type 字段("static" 或 "deferred")。在当前实现中,调用 can() 时会在读取阶段评估这两种类型——Convex mutation 无法调用 query,因此无法在写入时进行评估。type 字段是为未来优化预留的,目前不会造成任何行为差异。
  • **canWithContext()**:检查需要运行时上下文(例如 IP 地址、一天中的时间)的 deferred ABAC 策略。
  • **recomputeUser()**:按需重建用户的有效权限表——适用于架构变更后或部署后的迁移。
  • **withTenant()**:获取绑定到其他租户的客户端副本,用于跨租户管理操作。
ReBAC 示例
// Add a relationship
await authz.addRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });

// Check a relationship
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });

// Remove a relationship
await authz.removeRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: teamId });
ReBAC → 权限桥接

使用 defineRelationPermissions,在创建关系时自动授予权限:

import { defineRelationPermissions } from "@djpanda/convex-authz";

const authz = new Authz(components.authz, {
  permissions, roles, tenantId: "my-app",
  relationPermissions: defineRelationPermissions({
    "document:viewer": ["documents:read"],
    "document:editor": ["documents:read", "documents:update"],
    "document:owner": ["documents:read", "documents:update", "documents:delete"],
  }),
});

// Adding a relation automatically grants scoped permissions
await authz.addRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });

// can() now checks relation-derived permissions — no separate hasRelation() needed
const canUpdate = await authz.can(ctx, userId, "documents:update", { type: "document", id: docId }); // true

// Removing the relation revokes the permissions
await authz.removeRelation(ctx, { type: "user", id: userId }, "editor", { type: "document", id: docId });
ABAC 示例
// Policies are always evaluated at read-time when can() is called.
// The optional type field ("static" | "deferred") is reserved for future use
// and currently has no behavioral effect.
const policies = definePolicies({
  "documents:read": {
    condition: (ctx) => ctx.getAttribute("verified") === true,
    message: "Only verified users can read documents",
  },
  "billing:export": {
    condition: (ctx) => {
      const hour = new Date().getUTCHours();
      return hour >= 9 && hour <= 17;
    },
    message: "Billing exports only during business hours",
  },
});

// Use canWithContext() when request context is available
const allowed = await authz.canWithContext(ctx, userId, "documents:read", undefined, {
  ipAllowlisted: true,
});
部署后重建
// Rebuild a single user's effective permissions after upgrading
await authz.recomputeUser(ctx, userId);
跨租户操作
const otherTenantAuthz = authz.withTenant("other-tenant-id");
const allowed = await otherTenantAuthz.can(ctx, userId, "documents:read");
迁移指南:IndexedAuthz → Authz

注意: v2 不再导出 IndexedAuthz。下面的导入会失败——只需将其替换为 Authz。

// Before (v1) — this import no longer works in v2
// import { IndexedAuthz } from "@djpanda/convex-authz";
// const authz = new IndexedAuthz(components.authz, { permissions, roles, tenantId: "my-app" });

// After (v2) — same constructor, just rename the class
import { Authz } from "@djpanda/convex-authz";
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });

升级后,对每个现有用户运行 recomputeUser(),以回填有效权限表:

// one-time migration mutation
export const backfillEffectivePermissions = mutation({
  args: {},
  handler: async (ctx) => {
    const users = await ctx.db.query("users").collect();
    for (const user of users) {
      await authz.recomputeUser(ctx, String(user._id));
    }
  },
});

React 集成

此软件包提供 React hooks 和 PermissionGate 组件,让你的 UI 能够以响应式方式检查权限和角色。你的应用必须公开封装 Authz 组件的 Convex queries(例如 checkPermission、getUserRoles)。这些 hooks 通过 Convex 的 useQuery 调用这些 queries,因此权限和角色变更会及时更新,无需轮询。

1. 公开 Convex queries

定义委托给 authz 客户端的 queries,例如:

// convex/app.ts (or similar)
import { query } from "./_generated/server";
import { v } from "convex/values";
import { authz } from "./authz";

export const checkPermission = query({
  args: {
    userId: v.string(),
    permission: v.string(),
    scope: v.optional(v.object({ type: v.string(), id: v.string() })),
  },
  handler: async (ctx, args) => {
    return authz.can(ctx, args.userId, args.permission, args.scope);
  },
});

export const getUserRoles = query({
  args: {
    userId: v.string(),
    scope: v.optional(v.object({ type: v.string(), id: v.string() })),
  },
  handler: async (ctx, args) => {
    return authz.getUserRoles(ctx, args.userId, args.scope);
  },
});
2. 使用 AuthzProvider 包装应用

将 Convex query refs(以及可选的默认用户 ID)传递给 provider:

import { AuthzProvider } from "@djpanda/convex-authz/react";
import { api } from "./convex/_generated/api";

<AuthzProvider
  queryRefs={{
    checkPermission: api.app.checkPermission,
    getUserRoles: api.app.getUserRoles,
  }}
  defaultUserId={currentUserId}  // optional; hooks can pass userId in options
>
  <App />
</AuthzProvider>
3. 使用 hooks 和 PermissionGate
  • useCanUser(permission, options?) — 返回 { allowed, isLoading, error }。选项:{ userId?, scope? }。省略 userId 时,使用 provider 中的 defaultUserId。
  • useUserRoles(options?) — 返回 { roles, isLoading, error }。选项:{ userId?, scope? }。
  • useRequirePermission(permission, options?) — 用户未获准访问时抛出错误(使用错误边界显示拒绝访问状态)。
  • PermissionGate — 获准时渲染 children,被拒绝时渲染 fallback,加载期间渲染 loadingFallback(可选)。
import {
  useCanUser,
  useUserRoles,
  useRequirePermission,
  PermissionGate,
} from "@djpanda/convex-authz/react";

function DocumentList() {
  const { allowed, isLoading } = useCanUser("documents:read");

  if (isLoading) return <Spinner />;
  if (!allowed) return <p>You cannot view documents.</p>;
  return <div>{/* list */}</div>;
}

function AdminPanel() {
  useRequirePermission("settings:manage"); // throws if denied; wrap in error boundary
  return <div>Admin content</div>;
}

function EditButton({ docId }: { docId: string }) {
  return (
    <PermissionGate
      permission="documents:update"
      scope={{ type: "document", id: docId }}
      fallback={<span>No access</span>}
      loadingFallback={<span>Checking…</span>}
    >
      <button>Edit</button>
    </PermissionGate>
  );
}

Convex 的响应式机制可确保后端的权限或角色变更时,hooks 和 PermissionGate 会重新运行,并自动更新 UI。


架构

┌─────────────────────────────────────────────────────────────────────────────┐
│                           @djpanda/convex-authz                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────────┐   │
│  │      RBAC        │  │      ABAC        │  │         ReBAC            │   │
│  │  Role-Based      │  │  Attribute-Based │  │  Relationship-Based      │   │
│  │  Access Control  │  │  Access Control  │  │  Access Control          │   │
│  │                  │  │                  │  │                          │   │
│  │  • Roles         │  │  • User attrs    │  │  • Tuples (S, R, O)      │   │
│  │  • Permissions   │  │  • Policies      │  │  • Graph traversal       │   │
│  │  • Scopes        │  │  • Conditions    │  │  • Inheritance           │   │
│  └──────────────────┘  └──────────────────┘  └──────────────────────────┘   │
│           │                    │                         │                  │
│           ▼                    ▼                         ▼                  │
│  ┌──────────────────────────────────────────────────────────────────────┐   │
│  │                    O(1) Indexed Permission Cache                     │   │
│  │                                                                      │   │
│  │   effectivePermissions  │  effectiveRoles  │  effectiveRelationships │   │
│  │   [user, perm, scope]   │  [user, role]    │  [subject, rel, object] │   │
│  └──────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

RBAC(基于角色的访问控制)

分配角色
// Global role
await authz.assignRole(ctx, userId, "admin");

// Scoped role (e.g., admin of a specific team)
await authz.assignRole(ctx, userId, "admin", {
  type: "team",
  id: "team_123",
});

// With expiration (24 hours)
await authz.assignRole(ctx, userId, "admin", undefined, Date.now() + 86400000);
撤销角色
await authz.revokeRole(ctx, userId, "admin");

// Scoped
await authz.revokeRole(ctx, userId, "admin", { type: "team", id: "team_123" });
检查权限
// Boolean check
const canEdit = await authz.can(ctx, userId, "documents:update");

// Throws if denied
await authz.require(ctx, userId, "documents:update");

// With scope
const canEditTeamDocs = await authz.can(ctx, userId, "documents:update", {
  type: "team",
  id: "team_123",
});
检查角色
const isAdmin = await authz.hasRole(ctx, userId, "admin");

// Scoped
const isTeamAdmin = await authz.hasRole(ctx, userId, "admin", {
  type: "team",
  id: "team_123",
});
通配符和模式匹配权限

权限检查和覆盖支持通配符模式,因此你可以一次性授予或拒绝一整类权限。

模式格式:resource:action。resource 或 action(也可以两者)可以是 *:

模式含义匹配示例
*所有权限documents:read、settings:manage
documents:*documents 上的所有操作documents:read、documents:update
*:read对任意资源的读取权限documents:read、settings:read
*:*所有权限(与 * 相同)任意 resource:action

**检查:*调用 can(ctx, userId, "documents:read") 或 require(ctx, userId, "documents:read") 时,后端会将任何与该权限匹配的已存储角色或覆盖视为授予了该权限。因此,如果用户拥有包含 documents: 的角色,或有 *:read 覆盖,则其可以执行 documents:read。

**分配:**你可以将模式传递给 grantPermission 和 denyPermission:

// Grant all document actions
await authz.grantPermission(ctx, userId, "documents:*", undefined, "Full document access");

// Deny read on any resource
await authz.denyPermission(ctx, userId, "*:read", undefined, "Read access revoked");

**角色定义:**组件评估权限时,会使用相同的模式规则,将请求的权限与每个角色的权限列表进行匹配。因此,如果角色的权限包含 "documents:*"(在扁平化的角色–权限映射中),则允许调用 can(ctx, userId, "documents:read")。使用 defineRoles 时,通常会列出每个资源的具体操作(例如 documents: ["read", "update"]);若要在角色中使用模式,则应提供包含该角色模式字符串的角色–权限映射。

**客户端辅助函数:**若要在不调用后端的情况下测试某个模式是否与权限匹配,请使用导出的辅助函数:

import { matchesPermissionPattern } from "@djpanda/convex-authz";

matchesPermissionPattern("documents:read", "documents:*"); // true
matchesPermissionPattern("documents:read", "*:read");      // true
matchesPermissionPattern("settings:read", "documents:*"); // false

所有权限检查都适用相同的通配符行为。

获取用户角色
const roles = await authz.getUserRoles(ctx, userId);
// Returns: [{ role: "admin", scopeKey: "global" }, { role: "editor", scopeKey: "team:123", scope: { type: "team", id: "123" } }]

批量操作和用户离职处理

对于大规模或企业级工作流,API 支持批量权限检查(最多 100 项权限)和角色更新(最多 20 个角色),并可在一次调用中完成。

批量权限检查(canAny)

通过一次往返检查用户是否拥有给定权限中的任意一项:

const allowed = await authz.canAny(ctx, userId, [
  "documents:read",
  "documents:update",
  "documents:delete",
], scope);
// true if the user has at least one of these permissions
批量分配和撤销角色

在单个事务中为一个用户分配或撤销多个角色:

// Assign multiple roles at once (max 20 per call)
const { assigned, assignmentIds } = await authz.assignRoles(ctx, userId, [
  { role: "admin" },
  { role: "editor", scope: { type: "team", id: "team_1" } },
  { role: "viewer", scope: { type: "org", id: "org_1" }, expiresAt: Date.now() + 86400000 },
], actorId);

// Revoke multiple roles at once (max 20 per call)
const { revoked } = await authz.revokeRoles(ctx, userId, [
  { role: "editor", scope: { type: "team", id: "team_1" } },
  { role: "viewer" },
], actorId);
撤销所有角色

撤销用户的所有角色(可选地仅限于指定范围):

const count = await authz.revokeAllRoles(ctx, userId);
const countScoped = await authz.revokeAllRoles(ctx, userId, { type: "team", id: "team_1" }, actorId);
完整的用户离职处理

通过一次调用移除用户的所有角色、权限覆盖、属性,以及可选的 ReBAC 关系(可选地限定范围)。如果存在,也会清除已索引的 effectiveRoles、effectivePermissions 和 effectiveRelationships:

const result = await authz.offboardUser(ctx, userId, {
  scope: { type: "org", id: "org_1" },  // optional: only remove data in this scope
  actorId: "system",
  removeAttributes: true,   // default true
  removeOverrides: true,    // default true
  removeRelationships: true, // default true when no scope (full offboard)
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }

省略范围时,该调用会执行完整停用:移除所有角色、覆盖、属性,以及所有以该用户为主体的 ReBAC 关系。提供范围时,只会移除该范围内的数据,关系保持不变。

用户停用(完全清除)

对于安全事件响应、企业级离职处理或一键停用,请使用 deprovisionUser 原子性地清除用户的所有角色、属性、关系和权限覆盖(不指定范围或选项):

const result = await authz.deprovisionUser(ctx, userId, {
  actorId: "security-team",
  enableAudit: true,
});
// result: { rolesRevoked, overridesRemoved, attributesRemoved, relationshipsRemoved, effectiveRolesRemoved, effectivePermissionsRemoved, effectiveRelationshipsRemoved }

每次调用的批量数组数量有限制:canAny 中的权限最多 100 项,assignRoles / revokeRoles 中的角色最多 20 项。客户端和组件会进行校验,超出限制时会抛出明确的错误。


ABAC(基于属性的访问控制)

设置用户属性
await authz.setAttribute(ctx, userId, "department", "engineering");
await authz.setAttribute(ctx, userId, "clearanceLevel", 5);
await authz.setAttribute(ctx, userId, "location", { country: "US", state: "CA" });
获取属性
const attributes = await authz.getUserAttributes(ctx, userId);
// Returns: [{ key: "department", value: "engineering" }, { key: "clearanceLevel", value: 5 }]
定义策略

condition 函数可以返回 boolean 或 Promise<boolean>,因此你可以使用异步逻辑(例如查询数据库或调用外部 API)。

import { definePolicies, evaluatePolicyCondition } from "@djpanda/convex-authz";

const policies = definePolicies({
  "documents:update": {
    // User can update if they own the document (sync)
    condition: (ctx) => ctx.resource?.ownerId === ctx.subject.userId,
    message: "Only document owners can update",
  },
  "reports:view": {
    // Only engineering department with clearance >= 3 (sync)
    condition: (ctx) => 
      ctx.subject.attributes.department === "engineering" &&
      (ctx.subject.attributes.clearanceLevel as number) >= 3,
    message: "Requires engineering department with clearance level 3+",
  },
  "documents:delete": {
    // Async: e.g. check external service or fetch extra data
    condition: async (ctx) => {
      const doc = await getDocument(ctx.resource?.id);
      return doc != null && doc.ownerId === ctx.subject.userId;
    },
    message: "Only document owners can delete",
  },
});

const authz = new Authz(components.authz, { permissions, roles, policies, tenantId: "my-app" });

评估策略时(例如 RBAC 允许之后),务必始终对条件使用 await,这样同步和异步策略都能正常运行。使用 evaluatePolicyCondition 将其规范化为 Promise:

const policy = policies["documents:update"];
if (policy) {
  const allowed = await evaluatePolicyCondition(policy.condition, policyCtx);
  if (!allowed) throw new Error(policy.message ?? "Permission denied");
}
策略上下文

策略接收一个上下文对象,其中包含:

interface PolicyContext {
  subject: {
    userId: string;
    roles: string[];
    attributes: Record<string, unknown>;
  };
  resource?: {
    type: string;
    id: string;
    [key: string]: unknown; // Resource data
  };
  action: string; // The permission being checked
  environment?: {
    timestamp: number;
    ip?: string;
  };
  hasRole: (role: string) => boolean;
  hasAttribute: (key: string) => boolean;
  getAttribute: <T = unknown>(key: string, defaultValue?: T) => T | undefined;
}

API 说明: 现有的同步条件仍然有效。没有破坏性变更;仅扩展返回类型,以支持异步策略返回 Promise<boolean>。


ReBAC(基于关系的访问控制)

ReBAC 根据实体之间的关系实现访问控制,非常适合 CRM、文档共享和组织结构等分层系统。

关系模型

关系以元组形式存储:(subject, relation, object)

user:alice  ──member──►  team:sales
team:sales  ──owner──►   account:acme
account:acme ──parent──► deal:big_deal
添加关系
// Use the Authz client — NOT direct component calls
// User is member of team
await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });

// Team owns account
await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme" });
检查直接关系
// Use the Authz client — NOT direct component calls
const isMember = await authz.hasRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
// Returns: true

// To remove a relationship
await authz.removeRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });
关系遍历(继承访问权限)

ReBAC 的真正优势在于能够通过关系链检查访问权限:

// Define how permissions flow through relationships
const traversalRules = {
  // A deal viewer is anyone who can view the parent account
  "deal:viewer": [
    { through: "account", via: "parent", inherit: "viewer" }
  ],
  // An account viewer is any member of the owning team
  "account:viewer": [
    { through: "team", via: "owner", inherit: "member" }
  ],
};

// Graph traversal is available via the component query directly
// Check: Can alice view big_deal?
const result = await ctx.runQuery(components.authz.rebac.checkRelationWithTraversal, {
  subjectType: "user",
  subjectId: "alice",
  relation: "viewer",
  objectType: "deal",
  objectId: "big_deal",
  traversalRules,
  maxDepth: 5,
});

// Returns:
// {
//   allowed: true,
//   path: [
//     "account:acme -[parent]-> deal:big_deal",
//     "team:sales -[owner]-> account:acme",
//     "user:alice -[member]-> team:sales"
//   ],
//   reason: "Access via relationship chain"
// }

遍历使用 maxDepth 限制(默认为 5),并跟踪已访问的 (objectType, objectId, relation) 节点,以避免循环关系导致无限循环。

CRM 示例
// Setup CRM hierarchy using the Authz client
const setupCRM = async (ctx) => {
  // Sales rep Alice is on sales team
  await authz.addRelation(ctx, { type: "user", id: "alice" }, "member", { type: "team", id: "sales" });

  // Sales team owns Acme Corp account
  await authz.addRelation(ctx, { type: "team", id: "sales" }, "owner", { type: "account", id: "acme_corp" });

  // Acme Corp has a big deal
  await authz.addRelation(ctx, { type: "account", id: "acme_corp" }, "parent", { type: "deal", id: "big_deal" });

  // Now Alice can access the deal through the relationship chain!
};

O(1) 索引查找

对于高性能的生产环境,索引系统会预先计算权限,从而实现即时查找。

使用索引 API
import { Authz } from "@djpanda/convex-authz";
import { components } from "./_generated/api";

const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });

// O(1) permission check - single index lookup
const canEdit = await authz.can(ctx, userId, "documents:update");

// O(1) role check
const isAdmin = await authz.hasRole(ctx, userId, "admin");

// O(1) relationship check
const isMember = await authz.hasRelation(ctx, { type: "user", id: userId }, "member", { type: "team", id: "sales" });
工作原理
Traditional (O(n)):                    Indexed (O(1)):
┌──────┐                               ┌──────┐
│ User │                               │ User │
└──┬───┘                               └──┬───┘
   │                                      │
   ▼                                      ▼
┌──────────┐                           ┌─────────────────────────────┐
│ Get Roles│ ◄── Query                 │ Index Lookup:               │
└──┬───────┘                           │ effectivePermissions        │
   │                                   │ [userId, permission, scope] │
   ▼                                   └─────────────────────────────┘
┌───────────────┐                                  │
│ Expand Perms  │ ◄── Loop                         ▼
└──┬────────────┘                              true/false
   │
   ▼
┌──────────────┐
│ Check Each   │ ◄── Loop
│ Permission   │
└──┬───────────┘
   │
   ▼
true/false
权衡
操作传统方式索引方式
权限检查O(角色数 × 权限数)O(1)
角色分配O(1)O(权限数)
授予权限O(1)O(1)
内存用量较低较高(反规范化)

生产环境中需要执行大量权限检查时,请使用索引方式。


审计日志

所有授权变更都会记录日志,以满足合规要求并便于调试。

自动记录日志

以下操作会自动记录:

  • role_assigned - 分配角色时
  • role_revoked - 撤销角色时
  • permission_granted - 授予直接权限时
  • permission_denied - 显式拒绝权限时
  • attribute_set - 设置用户属性时
  • attribute_removed - 移除用户属性时
  • permission_check - (可选)检查权限时
查询审计日志

不提供分页选项时,getAuditLog 会返回简单数组(可选的 limit 默认值为 100):

// Get all logs for a user
const logs = await authz.getAuditLog(ctx, {
  userId: "user_123",
  limit: 50,
});

// Get logs by action type
const roleChanges = await authz.getAuditLog(ctx, {
  action: "role_assigned",
  limit: 100,
});

若要进行可扩展的浏览,请传入 numItems(以及可选的 cursor 以获取下一页),使用基于游标的分页。此时返回值为 { page, isDone, continueCursor }:

// First page
const result = await authz.getAuditLog(ctx, { numItems: 50 });
if (!Array.isArray(result)) {
  console.log(result.page);
  if (!result.isDone) {
    // Next page
    const next = await authz.getAuditLog(ctx, {
      numItems: 50,
      cursor: result.continueCursor,
    });
  }
}
日志条目结构
{
  _id: "...",
  timestamp: 1704672000000,
  actorId: "admin_user",  // Who made the change
  action: "role_assigned",
  userId: "target_user",  // Who was affected
  details: {
    role: "editor",
    scope: { type: "team", id: "team_123" },
  },
}

权限覆盖

授予或拒绝特定权限,以覆盖基于角色的分配。

授予权限
// Grant a permission directly (bypasses role checks)
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "Temporary access for migration");

// With scope
await authz.grantPermission(ctx, userId, "documents:delete", { type: "team", id: "team_123" });

// With expiration
await authz.grantPermission(ctx, userId, "documents:delete", undefined, "Temporary", Date.now() + 3600000);
拒绝权限
// Deny a permission (even if user has it via role)
await authz.denyPermission(ctx, userId, "documents:delete", undefined, "Access restricted");

架构参考

表
表用途
roleAssignments用户角色分配
userAttributes用于 ABAC 的用户属性
permissionOverrides直接授予或拒绝权限
relationshipsReBAC 关系元组
effectivePermissions预先计算的权限(O(1))
effectiveRoles预先计算的角色(O(1))
effectiveRelationships预先计算的关系(O(1))
auditLog授权审计记录
索引

所有表都针对常见查询模式配置了优化索引:

// roleAssignments
.index("by_user", ["userId"])
.index("by_role", ["role"])
.index("by_user_and_role", ["userId", "role"])

// effectivePermissions (O(1) lookup)
.index("by_user_permission_scope", ["userId", "permission", "scopeKey"])

// relationships
.index("by_subject_relation_object", ["subjectType", "subjectId", "relation", "objectType", "objectId"])

API 参考

Authz 客户端
class Authz<P, R, Policy> {
  // Permission checks
  can(ctx, userId, permission, scope?): Promise<boolean>
  canAny(ctx, userId, permissions: string[], scope?): Promise<boolean>   // bulk: any of N permissions (max 100)
  require(ctx, userId, permission, scope?): Promise<void>
  
  // Role management
  hasRole(ctx, userId, role, scope?): Promise<boolean>
  assignRole(ctx, userId, role, scope?, expiresAt?, actorId?): Promise<string>
  assignRoles(ctx, userId, roles: RoleAssignItem[], actorId?): Promise<{ assigned: number; assignmentIds: string[] }>  // bulk, max 20
  revokeRole(ctx, userId, role, scope?, actorId?): Promise<boolean>
  revokeRoles(ctx, userId, roles: RoleScopeItem[], actorId?): Promise<{ revoked: number }>  // bulk, max 20
  revokeAllRoles(ctx, userId, scope?, actorId?): Promise<number>
  getUserRoles(ctx, userId, scope?): Promise<Role[]>
  getUserPermissions(ctx, userId, scope?): Promise<PermissionResult>

  // Offboarding
  offboardUser(ctx, userId, options?: { scope?, actorId?, removeAttributes?, removeOverrides?, removeRelationships? }): Promise<OffboardResult>
  deprovisionUser(ctx, userId, options?: { actorId?, enableAudit? }): Promise<OffboardResult>  // full wipe: roles, overrides, attributes, relationships
  
  // Attribute management
  setAttribute(ctx, userId, key, value, actorId?): Promise<string>
  removeAttribute(ctx, userId, key, actorId?): Promise<boolean>
  getUserAttributes(ctx, userId): Promise<Attribute[]>
  
  // Permission overrides
  grantPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
  denyPermission(ctx, userId, permission, scope?, reason?, expiresAt?, actorId?): Promise<string>
  
  // Audit
  getAuditLog(ctx, options?): Promise<AuditEntry[] | { page: AuditEntry[]; isDone: boolean; continueCursor: string }>
}
参数验证

Authz 上的所有公共方法都会在调用组件前验证参数。无效输入会抛出一条清晰的 Error 消息,以便你快速失败并修复调用位置。

参数规则错误示例
userId非空字符串,最多 512 个字符"userId must be a non-empty string"
permission必须为 resource:action 格式(例如 documents:read)"Invalid permission format: \"read\". Expected \"resource:action\""
scope提供时,type 和 id 必须为非空字符串"scope must have non-empty type when provided"
role非空字符串;必须是构造时传入的角色之一"Unknown role: \"superadmin\""
expiresAt提供时,必须为有限数值(时间戳)"expiresAt must be a finite number"
属性 key非空字符串"Attribute key must be a non-empty string"
getAuditLog limit提供时,必须为 1–1000 的正整数"limit must be a positive integer when provided"
getAuditLog numItems提供时(分页),必须为 1–1000 的正整数与 limit 相同
关系参数subjectType、subjectId、relation、objectType、objectId 必须为非空字符串"subjectType must be a non-empty string"
canAny permissions非空数组,每个元素均为有效的 resource:action 格式,长度 ≤ 100"permissions must not exceed 100 items"
assignRoles / revokeRoles roles非空数组,每个角色均有效,长度 ≤ 20"roles must not exceed 20 items"

仅在提供可选参数时才会对其进行验证(例如,省略 scope 有效;传入 scope: { type: "", id: "x" } 会抛出错误)。


灵感源自 Google Zanzibar

此组件实现了 Google Zanzibar 的相关概念。Google Zanzibar 是 Google 的全局授权系统,为 Google Drive、YouTube、Cloud 等提供支持。

已实现的 Zanzibar 概念
Zanzibar 概念我们的实现描述
关系元组relationships 表(user:alice, member, team:sales)
用户集遍历规则由关系定义的组
Check APIcheckPermissionFastO(1)“用户 X 能否对 Z 执行 Y?”
Expand APIcheckRelationWithTraversal查找所有授予访问权限的路径
Read APIgetSubjectRelations列出所有关系
Watch APIConvex 响应式实时更新权限
计算关系effectivePermissions预先计算,实现 O(1) 查询
Zanzibar 的工作原理
┌─────────────────────────────────────────────────────────────────────────────┐
│                        Google Zanzibar Model                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Relation Tuples (stored):                                                  │
│  ┌────────────────────────────────────────────────────────────────────┐     │
│  │  (user:alice, member, team:sales)                                  │     │
│  │  (team:sales, owner, account:acme)                                 │     │
│  │  (account:acme, parent, deal:big_deal)                             │     │
│  └────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
│  Authorization Model (defines inheritance):                                  │
│  ┌────────────────────────────────────────────────────────────────────┐     │
│  │  type deal                                                         │     │
│  │    relations                                                       │     │
│  │      define parent: [account]                                      │     │
│  │      define viewer: viewer from parent  ← Computed relation        │     │
│  └────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
│  Check: "Can alice view deal:big_deal?"                                     │
│  ┌────────────────────────────────────────────────────────────────────┐     │
│  │  1. deal:big_deal.viewer = viewer from parent                      │     │
│  │  2. parent = account:acme                                          │     │
│  │  3. account:acme.viewer = member from owner                        │     │
│  │  4. owner = team:sales                                             │     │
│  │  5. team:sales.member includes user:alice ✓                        │     │
│  │  → ALLOWED                                                         │     │
│  └────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘
我们提供的 Zanzibar 核心优势
  1. 大规模场景下的一致性
  • 预先计算权限,确保检查快速且一致
  • 读取之间不会出现权限漂移
  1. 灵活的权限模型
  • 根据需要组合 RBAC、ABAC 和 ReBAC
  • 支持复杂的层级结构(组织 → 团队 → 项目 → 资源)
  1. 可审计性
  • 完整记录所有权限变更的审计日志
  • 路径追踪会说明授予访问权限的原因
  1. 实时更新
  • Convex 响应式特性意味着权限变更时 UI 会立即更新
  • 无需轮询(优于 Zanzibar!)

与其他解决方案的比较

功能@djpanda/convex-authzOpenFGAOsoCerbos
RBAC✅✅✅✅
ABAC✅⚠️ 受限✅✅
ReBAC✅✅ 原生✅⚠️
O(1) 查询✅✅✅✅
Convex 原生✅❌❌❌
类型安全✅ TypeScriptDSLPolarYAML
实时✅ Convex 查询轮询轮询轮询
可自托管✅✅✅✅

测试

运行包测试
cd packages/authz
npm test
与 convex-test 搭配使用
import { convexTest } from "convex-test";
import { describe, expect, it } from "vitest";
import schema from "./component/schema.js";
import { api } from "./component/_generated/api.js";

describe("authorization", () => {
  it("should assign and check roles", async () => {
    const t = convexTest(schema, modules);

    await t.mutation(api.mutations.assignRole, {
      userId: "user_123",
      role: "admin",
    });

    const hasRole = await t.query(api.queries.hasRole, {
      userId: "user_123",
      role: "admin",
    });

    expect(hasRole).toBe(true);
  });
});

多租户数据隔离

authz 组件中的每个表都包含必填的 tenantId 字段,并将其作为每个数据库索引的首列。这可在数据库层面实现租户间的数据隔离——针对一个租户的查询绝不会返回另一个租户的数据。

tenantId 与 scope 的区别
tenantIdscope
用途数据隔离边界资源级分组
强制方式数据库级(索引前缀)应用级(查询筛选)
是否必填始终必填可选
示例"acme-corp"{ type: "project", id: "proj-123" }
  • tenantId 回答“这些数据属于谁?”——即组织/客户边界
  • scope 回答“在此租户内,这适用于什么资源?”——例如特定项目的管理员
配置
// Single-tenant apps — pass any constant string
const authz = new Authz(components.authz, {
  permissions, roles,
  tenantId: "my-app",
});

// Multi-tenant apps — pass the current organization/tenant ID
const authz = new Authz(components.authz, {
  permissions, roles,
  tenantId: currentOrgId,
});
跨租户操作

对于需要访问其他租户数据的少数管理操作,请使用 withTenant() 方法:

// Returns a new Authz instance scoped to a different tenant
const otherTenant = authz.withTenant("other-org-id");
await otherTenant.getUserRoles(ctx, userId);
合规性

以 tenantId 开头的索引设计满足 SOC2 和 HIPAA 的数据隔离要求:

  • 所有查询都在数据库索引层面按租户分区
  • 通过标准 API 在结构上不可能访问跨租户数据
  • 构造函数中必须提供 tenantId——不会意外遗漏

最佳实践

1. 使用限定范围的角色实现多租户
// Don't: Global admin
await authz.assignRole(ctx, userId, "admin");

// Do: Scoped admin
await authz.assignRole(ctx, userId, "admin", { type: "org", id: orgId });
2. 在生产环境中使用 Authz 客户端
// Authz uses O(1) indexed lookups by default — no separate class needed
const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
3. 使用 ReBAC 处理复杂层级结构
// CRM, document sharing, org charts → ReBAC
// Simple role assignments → RBAC
4. 为临时访问设置过期时间
await authz.assignRole(ctx, userId, "contractor", undefined, 
  Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days
);
5. 始终使用审计日志

审计日志对于以下工作非常有价值:

  • 合规(SOC2、GDPR)
  • 访问问题调试
  • 安全事件调查
6. 清理过期数据(通过组件定时调度)

过期的角色分配和权限覆盖(以及其索引行)会由定时清理任务清除,只需启用一次,无需自行添加 convex/crons.ts。该组件内置了 @convex-dev/crons;安装组件后,运行以下命令一次以注册每日任务:

npx convex run authz/cronSetup:ensureCleanupCronRegistered

也可以在部署时运行的初始化脚本中执行(例如,通过 convex dev --run init 调用的 convex/init.ts):

await ctx.runMutation(components.authz.cronSetup.ensureCleanupCronRegistered, {});

该任务每 24 小时运行一次,并清理 roleAssignments、permissionOverrides、effectiveRoles 和 effectivePermissions。可选:你也可以在应用的 convex/crons.ts 中定义清理任务,或手动运行 components.authz.mutations.runScheduledCleanup。

6.1. 审计日志保留

为避免审计日志无限增长(影响合规和成本),同一 cron 注册还会安排每日审计日志保留任务。通过 Convex 环境变量配置(Dashboard 或 CLI):

变量描述
AUDIT_RETENTION_DAYS删除超过指定天数的条目(例如 90)。省略或设为 0 = 不按时间清理。
AUDIT_RETENTION_MAX_ENTRIES将条目总数限制在指定值以内,删除最早的条目,直至数量 ≤ 该值(例如 100000)。省略或设为 0 = 不按数量清理。

至少设置其中一个变量以启用保留策略。该任务每 24 小时运行一次(通过相同的 ensureCleanupCronRegistered 流程)。你也可以手动运行 components.authz.mutations.runAuditRetentionCleanup,并传入可选参数 { maxAgeDays?, maxEntries? },以覆盖本次运行的环境变量设置。

7. 将 Authz 用作全局单例

Authz 是一个全局组件——只需安装一次,并在整个应用中共享同一个客户端实例。不要在每个应用中创建多个 Authz 实例。

convex/
  convex.config.ts   ← app.use(authz) — registered once
  authz.ts           ← definePermissions, defineRoles, export authz client
  documents.ts       ← import { authz } from "./authz"
  billing.ts         ← import { authz } from "./authz"
  settings.ts        ← import { authz } from "./authz"
// convex/authz.ts — single source of truth
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";

const permissions = definePermissions({
  documents: { create: true, read: true, update: true, delete: true },
  billing: { view: true, manage: true },
  settings: { view: true, manage: true },
});

const roles = defineRoles(permissions, {
  admin: {
    documents: ["create", "read", "update", "delete"],
    billing: ["view", "manage"],
    settings: ["view", "manage"],
  },
  viewer: {
    documents: ["read"],
    settings: ["view"],
  },
});

// Export the single authz client — import this everywhere
export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// convex/documents.ts — uses the shared client
import { mutation } from "./_generated/server";
import { authz } from "./authz";

export const deleteDocument = mutation({
  args: { docId: v.id("documents") },
  handler: async (ctx, args) => {
    await authz.require(ctx, userId, "documents:delete");
    // ...
  },
});
7. 按领域组织权限

对于较大的应用,可按领域拆分权限和角色定义,再使用 definePermissions 和 defineRoles 将它们合并到单个 authz 客户端中:

// convex/permissions/documents.ts
export const documentPermissions = {
  documents: { create: true, read: true, update: true, delete: true },
};
export const documentRoles = {
  editor: { documents: ["create", "read", "update"] as const },
  viewer: { documents: ["read"] as const },
};

// convex/permissions/billing.ts
export const billingPermissions = {
  billing: { view: true, manage: true },
};
export const billingRoles = {
  billing_admin: { billing: ["view", "manage"] as const },
};
// convex/authz.ts — merge all domains
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { components } from "./_generated/api";
import { documentPermissions, documentRoles } from "./permissions/documents";
import { billingPermissions, billingRoles } from "./permissions/billing";

const permissions = definePermissions(documentPermissions, billingPermissions);
const roles = defineRoles(permissions, documentRoles, billingRoles);

export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });

这样可以让每个领域保持独立,同时生成单个类型安全的 authz 客户端。

8. 与其他 Convex 组件集成

当其他 Convex 组件(例如 @djpanda/convex-tenants)需要授权时,它们会共享同一个全局 authz 实例。具体步骤如下:

  1. 在 convex.config.ts 中分别注册这两个组件
  2. 另一个组件导出其所需的权限和角色
  3. 将它们与应用自身的定义合并
  4. 将 authz 客户端传递给另一个组件的 API 工厂
graph LR
  subgraph app ["Your App (convex.config.ts)"]
    AuthzComp["authz component"]
    TenantsComp["tenants component"]
  end

  subgraph authzSetup ["convex/authz.ts"]
    AppPerms["App permissions"]
    TenantPerms["Tenant permissions"]
    Merge["definePermissions + defineRoles"]
    Client["authz client (singleton)"]
    AppPerms --> Merge
    TenantPerms --> Merge
    Merge --> Client
  end

  Client -->|"import { authz }"| Docs["convex/documents.ts"]
  Client -->|"import { authz }"| Billing["convex/billing.ts"]
  Client -->|"passed to makeTenantsAPI"| TenantsAPI["convex/tenants.ts"]
// convex/convex.config.ts — register both components
import { defineApp } from "convex/server";
import authz from "@djpanda/convex-authz/convex.config";
import tenants from "@djpanda/convex-tenants/convex.config";

const app = defineApp();
app.use(authz);
app.use(tenants);
export default app;
// convex/authz.ts — merge app + component permissions
import { Authz, definePermissions, defineRoles } from "@djpanda/convex-authz";
import { TENANTS_PERMISSIONS, TENANTS_ROLES } from "@djpanda/convex-tenants";
import { components } from "./_generated/api";

// Your app's own permissions
const appPermissions = {
  documents: { create: true, read: true, update: true, delete: true },
};
const appRoles = {
  editor: { documents: ["create", "read", "update"] as const },
};

// Merge with tenant component's permissions
const permissions = definePermissions(appPermissions, TENANTS_PERMISSIONS);
const roles = defineRoles(permissions, appRoles, TENANTS_ROLES);

export const authz = new Authz(components.authz, { permissions, roles, tenantId: "my-app" });
// convex/tenants.ts — pass the shared authz client
import { makeTenantsAPI } from "@djpanda/convex-tenants";
import { components } from "./_generated/api";
import { authz } from "./authz";

export const {
  createOrg,
  inviteMember,
  removeMember,
  // ...
} = makeTenantsAPI(components.tenants, {
  authz,
  creatorRole: "owner",
  auth: async (ctx) => {
    // return the current user ID
  },
});

这样,应用的每个部分——包括自有函数和第三方组件——都能共享一致的授权层。



开发

# Install dependencies
npm install

# Run development mode
npm run dev

# Run tests
npm test

# Build for production
npm run build

# Type check
npm run typecheck

文件结构

packages/authz/
├── package.json          # Package configuration
├── README.md             # This documentation
├── src/
│   ├── client/
│   │   ├── index.ts      # Main exports (Authz, helpers)
│   │   └── index.test.ts # Client tests
│   ├── component/
│   │   ├── convex.config.ts  # Component registration
│   │   ├── schema.ts         # Database tables and indexes
│   │   ├── helpers.ts        # Shared utilities
│   │   ├── queries.ts        # Query functions
│   │   ├── mutations.ts      # Mutation functions
│   │   ├── rebac.ts          # ReBAC relationship functions
│   │   ├── indexed.ts        # O(1) indexed functions
│   │   ├── authz.test.ts     # RBAC/ABAC tests
│   │   ├── rebac.test.ts     # ReBAC tests
│   │   ├── indexed.test.ts   # O(1) indexed tests
│   │   └── _generated/       # Auto-generated types
│   └── test.ts           # Test helpers
└── example/              # Example app

许可证

MIT


贡献

欢迎贡献!提交 PR 前,请阅读我们的 CONTRIBUTING.md。

评论

支持 Markdown