跳转至

权限门控(RBAC)

登录解决「你是谁」,这个形状解决「你能干什么」:一张 角色 × 权限矩阵 当唯一事实源,UI 端用 can() 禁用按钮、RoleGate 隐藏整块面板,服务端用 requireRole / requirePermission 把真正的门守住。不同用户该看到/做到不同的事,就用它。

试这几下:

  • 右上角切访客 / 编辑 / 管理员 —— 工具栏按钮、行内操作、整个「危险操作」面板跟着底部那张矩阵变
  • 访客/编辑看不到危险面板的位置,显示的是 RoleGatefallback 占位
  • 每个按钮问的都是同一个 can(role, perm) —— 页面里没有一处 if (role === "admin")

规矩

  • 矩阵是唯一事实源:所有门控(禁用、隐藏、服务端守卫)都查 ROLE_PERMISSIONS,绝不散落角色字面量判断 —— 加角色/改权限只动一张表
  • UI 门控是体验,不是安全:真正的边界在服务端 —— server fn 里 requireUser() 之后 requirePermission(role, perm),不满足抛 FORBIDDEN(fail closed)。demo 里「服务端拒绝」的 toast 演示的就是这层语义
  • rbac.ts 保持客户端安全(纯函数、不 import 数据库/认证),can/hasRole 才能在浏览器里跑
  • 真实版把 role 存进用户表(默认 viewer),从路由上下文透出

蓝本:add-rbac.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-rbac.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:权限门控(RBAC)</title>
<!--
  demo-rbac.html —— 「角色与权限」形状的最小可玩实现(蓝本:open-dashboard 的 add-rbac)。
  复现的不变量:
  - ROLE_PERMISSIONS 矩阵是唯一事实源:所有门控都问 can(role, perm),绝不散落 if (role === "admin")
  - UI 门控只是体验(禁用/隐藏),不是安全 —— 服务端必须用 requireRole/requirePermission 再查一遍(fail closed)
  - RoleGate 组件按角色/权限包住整块 UI(此处的「危险操作」面板只有管理员可见)
  切换角色,看工具栏按钮的可用性、行内操作和面板的可见性怎么跟着矩阵走。
  运行时:/vendor 的 React 18 UMD + htm,样式共用 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .matrix td, .matrix th { text-align: center; }
  .matrix td:first-child, .matrix th:first-child { text-align: left; }
  .yes { color: var(--chip-on-fg); font-weight: 650; }
  .no { color: var(--muted); }
  .panel { padding: 14px 16px; margin-top: 12px; }
  .panel h2 { margin: 0 0 6px; font-size: 14px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect } = React;
const html = htm.bind(React.createElement);

// ---- rbac.ts 的形状:矩阵是唯一事实源,helpers 纯函数、客户端安全 ----
const PERMS = ["orders:view", "orders:edit", "orders:delete", "settings:manage"];
const PERM_LABEL = { "orders:view": "查看订单", "orders:edit": "编辑订单",
                     "orders:delete": "删除订单", "settings:manage": "管理设置" };
const ROLE_PERMISSIONS = {
  viewer: ["orders:view"],
  editor: ["orders:view", "orders:edit"],
  admin:  ["orders:view", "orders:edit", "orders:delete", "settings:manage"],
};
const ROLE_LABEL = { viewer: "访客", editor: "编辑", admin: "管理员" };
const can = (role, perm) => ROLE_PERMISSIONS[role].includes(perm);

// RoleGate:按权限包住整块 UI;服务端仍要 requirePermission 再查(UI 门控 ≠ 安全)
function RoleGate({ role, need, fallback = null, children }) {
  return can(role, need) ? children : fallback;
}

const ORDERS = [
  { id: "SO-1041", customer: "长风文创", total: 5200,  status: "待发货" },
  { id: "SO-1042", customer: "禾木书店", total: 1180,  status: "已完成" },
  { id: "SO-1043", customer: "青崖民宿", total: 20400, status: "待付款" },
];

function App() {
  const [role, setRole] = useState("viewer");
  const [toast, setToast] = useState("");

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  const act = (perm, label) => {
    // 演示服务端语义:真实版在 server fn 里 requirePermission(role, perm),不满足抛 FORBIDDEN
    if (!can(role, perm)) { setToast("服务端拒绝:FORBIDDEN"); return; }
    setToast(`已执行「${label}」`);
  };

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>订单中心</h1>
          <p class="sub">切角色看门控变化 —— 全部按钮都问同一张 ROLE_PERMISSIONS 矩阵</p>
        </div>
        <div class="seg">
          ${Object.keys(ROLE_PERMISSIONS).map(r => html`
            <button key=${r} class=${role === r ? "on" : ""} onClick=${() => setRole(r)}>${ROLE_LABEL[r]}</button>`)}
        </div>
      </header>

      <div class="toolbar">
        <span class="sub">当前角色:<span class="chip chip-on">${ROLE_LABEL[role]}</span></span>
        <span class="grow"></span>
        <button class="btn" disabled=${!can(role, "orders:edit")}
                onClick=${() => act("orders:edit", "新建订单")}>新建订单</button>
        <button class="btn" disabled=${!can(role, "orders:delete")}
                onClick=${() => act("orders:delete", "清理已取消订单")}>清理已取消</button>
      </div>

      <table class="list">
        <thead><tr><th>订单号</th><th>客户</th><th class="num">金额</th><th>状态</th><th class="ops">操作</th></tr></thead>
        <tbody>
          ${ORDERS.map(o => html`
            <tr key=${o.id}>
              <td style=${{ fontWeight: 550 }}>${o.id}</td>
              <td>${o.customer}</td>
              <td class="num">¥ ${o.total.toLocaleString("zh-CN")}</td>
              <td><span class="chip chip-warn">${o.status}</span></td>
              <td class="ops">
                <button class="link" onClick=${() => act("orders:view", "查看 " + o.id)}>查看</button>
                <button class="link" disabled=${!can(role, "orders:edit")}
                        onClick=${() => act("orders:edit", "编辑 " + o.id)}>编辑</button>
                <button class="link danger" disabled=${!can(role, "orders:delete")}
                        onClick=${() => act("orders:delete", "删除 " + o.id)}>删除</button>
              </td>
            </tr>`)}
        </tbody>
      </table>

      <${RoleGate} role=${role} need="settings:manage"
        fallback=${html`<p class="sub" style=${{ marginTop: "12px" }}>⛔ 「危险操作」面板已按角色隐藏(RoleGate fallback)</p>`}>
        <div class="card panel">
          <h2>危险操作(仅管理员可见 —— RoleGate 包住整块)</h2>
          <button class="btn" style=${{ color: "var(--danger)" }}
                  onClick=${() => act("settings:manage", "归档本季度订单")}>归档本季度订单</button>
        </div>
      <//>

      <div class="card panel">
        <h2>ROLE_PERMISSIONS 矩阵(唯一事实源)</h2>
        <table class="list matrix">
          <thead><tr><th>权限</th>${Object.keys(ROLE_PERMISSIONS).map(r => html`<th key=${r}>${ROLE_LABEL[r]}</th>`)}</tr></thead>
          <tbody>
            ${PERMS.map(p => html`
              <tr key=${p}>
                <td>${PERM_LABEL[p]} <span class="sub" style=${{ fontSize: "11px" }}>${p}</span></td>
                ${Object.keys(ROLE_PERMISSIONS).map(r => html`
                  <td key=${r} class=${can(r, p) ? "yes" : "no"}>${can(r, p) ? "✓" : "—"}</td>`)}
              </tr>`)}
          </tbody>
        </table>
        <p class="sub" style=${{ marginTop: "8px" }}>
          UI 门控只是体验;真实边界在服务端 —— server fn 里 requirePermission(role, perm) 不满足抛 FORBIDDEN(fail closed)。
        </p>
      </div>

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>