首页
/ PostHog 仪表盘 Widget 的权限与共享机制:团队作用域、双层 RBAC 与公共占位符实现

PostHog 仪表盘 Widget 的权限与共享机制:团队作用域、双层 RBAC 与公共占位符实现

2026-09-09 15:34:40作者:凌朦慧Richard

PostHog 的仪表盘(Dashboard)除了传统的洞察图块外,还支持一类产品级「widget tile」(如错误追踪列表、实验列表、会话回放列表等),它们由 DashboardWidget 模型承载、由 run_widgets API 拉取数据。这类 widget 的访问控制不是单一检查,而是团队作用域 + 仪表盘 RBAC + 产品级 RBAC 三层叠加,并且前端展示行为会随仪表盘摆放位置(私有 / 公开链接 / 导出 / 订阅快照)变化。本文基于仓库中的实现文档 permissions-and-sharing.md 与对应源码,讲清楚这套权限与共享体系的设计规则、关键代码路径,以及新增 widget 类型时必须遵守的约束。

读完本文,你可以掌握:如何在后端 registry 中声明 required_product_access 并让前后端共用同一套门禁;为什么前端 locked 状态单独不够安全;widget 在公开共享链接下为什么只渲染元数据而不执行查询;以及复制/移动/跨项目迁移 widget 时的深克隆语义。

团队作用域:所有读写都按 team_id 过滤

widget 权限的第一道边界是团队隔离。DashboardWidget 模型继承自 TeamScopedRootMixin,持有指向 posthog.Team 的外键:

# products/dashboards/backend/models/dashboard_widget.py
class DashboardWidget(ModelActivityMixin, TeamScopedRootMixin, UUIDModel):
    widget_type = models.CharField(max_length=64)
    name = models.CharField(max_length=400, null=True, blank=True)
    config = models.JSONField(default=dict)
    team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE)

    all_teams = models.Manager()  # noqa: DJ012

    class Meta(TeamScopedRootMixin.Meta):
        db_table = "posthog_dashboardwidget"
        default_manager_name = "all_teams"

DashboardWidget 模型。由此带来两条平台规则:

  1. DashboardWidget.team 是必填字段——widget 永远属于某个项目(team),所有读/写操作都按 team_id 过滤,跨项目的 widget 在模型层面不可达。
  2. tile 的 upsert 会校验 widget ID 归属——把某个 widget_id 挂到 dashboard tile 上时,后端会验证该 widget 属于当前 dashboard 所在团队,防止跨团队引用他人 widget。

两层访问控制:仪表盘 RBAC 与产品 RBAC 必须同时通过

一个用户能否看到/操作某个 widget tile,需要同时通过两个层级的检查,任一层失败都会拒绝:

层级 检查内容
仪表盘 RBAC dashboard:read / dashboard:write;针对 dashboard 对象本身的访问级别
产品 RBAC 针对 widget 所属产品(error_tracking、session_recording 等)的访问级别;前端锁 + 后端 run_widgets 必须一致

仪表盘 RBAC 体现在 API 层的 scope 标注上。dashboard.py 中的 action 按读写方向标注 required scope,例如 @action(methods=["GET"], detail=True, required_scopes=["dashboard:read"])(读 run_widgetsL3222)与大量 required_scopes=["dashboard:write"] 的写操作(tile 增删改、批量添加等,L2831-L3092)。对于 dashboard 对象级权限,DRF 端点还会解析出请求者对该 dashboard 的 user_access_level(见 L1254 附近的 effective_restriction_level / user_access_level 字段)。

产品 RBAC:canonical 规则与后端实现

产品级 RBAC 是 widget 平台的核心不变量(技能文档 SKILL.md 的平台规则第 1 条:RBAC 由 registry 驱动,禁止在 dashboard.py 中写 if widget_type == ... 的分支)。规范链路是:

  1. 每个 WidgetSpecregistry.py 中声明 required_product_access(产品资源名)与 required_scopes
  2. 注册表条目经 WIDGET_REGISTRY / get_widget_registry_entry 暴露;
  3. 运行时由 widget_access.py 中的 get_widget_product_access_errorcheck_widget_tile_product_access 执行门禁,覆盖 run_widgets 和所有 tile 变更路径。

后端执行的关键代码:

# products/dashboards/backend/widget_access.py
def get_widget_product_access_error(
    registry_entry: WidgetRegistryEntry,
    user_access_control: UserAccessControl,
    *,
    required_level: AccessControlLevel = "viewer",
) -> str | None:
    required_product_access = registry_entry.get("required_product_access")
    if not required_product_access:
        return None
    if not user_access_control.check_access_level_for_resource(
        cast(APIScopeObject, required_product_access),
        required_level,
    ):
        return get_widget_product_access_denied_message(required_product_access)
    return None

def check_widget_tile_product_access(widget, user_access_control) -> None:
    registry_entry = get_widget_registry_entry(widget.widget_type)
    if registry_entry is None:
        raise exceptions.PermissionDenied(f"Unknown widget type: {widget.widget_type}")
    ...

widget_access.py#L50-L78。在 dashboard.py 中,DashboardSerializer._check_widget_tile_product_accessL1799-L1803)被序列化和各 tile 动作调用(如 L1995L2857L2913),新建 tile 时也在 widget_create.py 中走同一检查。单测见 test_widget_access.py

实际注册表中,required_product_access 的取值与产品一一对应,例如(widget_specs/registry.py#L141-L150):

ERROR_TRACKING_LIST_WIDGET_TYPE: WidgetSpec(
    ...
    required_scopes=("error_tracking:read",),
    required_product_access="error_tracking",
    ...
)

activity_events_listrequired_product_access=None——表示该类型不额外受产品门禁约束,只受 required_scopes 与仪表盘 RBAC 约束。

两条重要边界规则:

  • required_scopes 是文档性质,不能用于用户侧强制。API 密钥(personal API key / OAuth token / ID Jag token)的 scope 校验走独立的 get_widget_api_scope_errorwidget_access.py#L29-L47),它把 xxx:read 的满足条件放宽为「持有 xxx:readxxx:write」,* 通配 scope 直接放行——这是给机器密钥用的,不是给用户 RBAC 用的。给终端用户强制权限永远用 required_product_access
  • 绝不在 dashboard.py 中按类型写 if 分支。门禁逻辑只能从 registry 条目读取,新类型通过注册 WidgetSpec 生效,无需改动 dashboard API。

前端必须镜像同一套门禁

前端有对应的注册表与检查函数,两者必须一致:

  • widgetProductAccess.ts 中的 WIDGET_PRODUCT_ACCESS_CHECKS 把每个 DashboardWidgetProductAccess 值映射到一个基于 userHasAccess(..., AccessControlLevel.Viewer) 的闭包;userHasDashboardWidgetProductAccess 对未声明门禁的类型直接放行。新增受控产品类型时,需同步扩展 DashboardWidgetProductAccess 类型与该映射表。
  • DashboardWidgetItem.tsxuserHasDashboardWidgetProductAccess(definition?.productAccess) 计算 tile 是否「锁定」。

关键安全原则:仅靠前端 locked 是不充分的——后端必须强制同一道门。 前端的锁定只是 UX 层面的提示,真正的访问边界由 run_widgets 与 tile 变更路径上的 check_widget_tile_product_access 保证。

错误追踪列表 tile 的特例:双变更入口

error_tracking_list 类型的行状态/负责人(assignee)修改有一个更宽的路径:只要满足「可编辑该 dashboard」或「拥有 Error tracking Editor 权限」即可。前端由 userCanMutateErrorTrackingIssuesOnDashboard 计算:

// products/dashboards/frontend/widgetProductAccess.ts
/** In-tile issue row edits and assignee filter picker: dashboard editor or Error tracking editor. */
export function userCanMutateErrorTrackingIssuesOnDashboard(canEditDashboard: boolean): boolean {
    return canEditDashboard || userHasAccess(AccessControlResourceType.ErrorTracking, AccessControlLevel.Editor)
}

DashboardWidgetItemL191 把它作为 canMutateErrorTrackingIssues 传入;conversations_recent_tickets 类型也有对称的 userCanMutateConversationsTicketsOnDashboard(可编辑 dashboard 或 Ticket Editor)。注意收窄的部分:tile 上过滤器(filter bar)的 PATCH 仍要求 dashboard edit 权限,不走这条放宽路径。

复制 / 移动 / 复制整个 dashboard 的语义

widget tile 的复制与移动遵循「深克隆数据行、tile 是独立外键」的模型:

操作 widget 行为
同一 dashboard 内复制 tile 深克隆 widget 行;duplicateTileSuccess 会对新 tile 触发 refreshDashboardWidgets,无需整页刷新即可加载数据
复制到另一个 dashboard 深克隆 widget 行 + 目标 dashboard 上新建 tile 行(若设置了名称则加 (Copy) 后缀)
移动到另一个 dashboard 只移动 tile 行;DashboardWidget 外键不变(widget 不跨 dashboard 共享)
复制整个 dashboard 始终深克隆 widget 行(即使 insights 走 duplicate_tiles: false 分支,widget 也不例外)

一个硬限制:button tile 至今不能跨 dashboard 复制或移动

从源码结构看,「复制/移动 = 深克隆 widget 行 + 新建或改指 tile 行」与模型设计自洽:DashboardWidget 通过 DashboardTile.widget 外键被引用,widget 行本身与团队绑定、与 dashboard 松耦合,因此移动 tile 只改 tile 行而无需触碰 widget 行;而复制到新 dashboard 时旧 widget 仍要被原 tile 引用,所以只能克隆一份。

跨项目复制(resource transfer)

把 dashboard 迁移到另一个项目(team)时,widget 行同样深克隆,且进入资源转移的「依赖预览」体系。转移访客实现见 DashboardWidgetVisitor

class DashboardWidgetVisitor(
    ResourceTransferVisitor,
    kind="DashboardWidget",
    excluded_fields=["last_modified_at"],
    friendly_name="Dashboard widget",
    user_facing=False,
):

该访客的 get_display_name 优先取 widget 的 name(若已设置),否则回落到 WIDGET_CATALOG 中该 widget_typelabel,再否则直接用类型字符串——这保证了转移预览中的可读性。配套的 DashboardTile 访客保持非用户可见(user_facing=False)。

一点事实核对:实现文档中称 DashboardWidgetVisitoruser_facing=True(默认值),但当前源码显式写的是 user_facing=False;基类 ResourceTransferVisitor 的默认值确实是 True,即源码相对默认值做了显式收窄。可以推断预览中 widget 的展示行为以源码为准:display_name 回退逻辑(name → catalog label → 类型名)是稳定契约,而是否在转移预览中列为可见依赖,以 dashboard_widget.py 当前的 user_facing=False 为准。相关端到端测试是 test_resource_transfer.py 中的 test_preview_returns_dashboard_with_widget_tilesL105)。

摆放位置决定 tile 渲染形态:私有 / 公开 / 导出 / 订阅

同一个 widget tile 在不同 DashboardPlacement 下行为完全不同,这是「共享」语义的核心表:

摆放位置 widget tile 行为
私有 dashboard 完整 tile:run_widgets 拉数据、live Component 渲染、允许时显示编辑控件
公开 / 共享链接DashboardPlacement.Public tile 会渲染头部元数据;不执行 run_widgets 拉取;主体显示 catalog 中的 sharedPlaceholder 文案,由 WidgetCardSharedPlaceholderBody 呈现
导出(DashboardPlacement.Export widget tile 隐藏isWidgetTileVisibleOnPlacement——只在导出时隐藏)
订阅 / 快照 只读——无编辑弹窗、tile 控件不可发起变更

公开共享 dashboard 的实现要点

  • isWidgetTileVisibleOnPlacementdashboardUtils.ts#L161)——只在 export 时返回 false;public/shared 依旧渲染 tile 壳。渲染判断在 DashboardItems.tsx#L685widget && dashboardWidgetsEnabled && isWidgetTileVisibleOnPlacement(placement)
  • dashboardLogic.dashboardWidgetsEnabled——公开视图下,只要 dashboard 含有 widget tile 就为 true(启用 tile 壳与布局),但当 placement === Public 时仍跳过 refreshDashboardWidgets,即不触发数据拉取。
  • SharedDashboardWidgetMetadataSerializerdashboard.py#L904)——当 tile 序列化器上下文带有 is_shared 时,widget 负载降级为仅元数据(不附带审计/用户字段等共享视图不需要的内容)。挂载点见 L965-L967if self.context.get("is_shared") and instance.widget_id is not None 时用该序列化器替换完整 widget 负载。
  • 共享入口路径——posthog/api/sharing.py 在构建共享 dashboard 的 tile 序列化器上下文时设置 is_shared: True
  • 前端占位符——DashboardWidgetItem.tsxplacement === Public 分支渲染 WidgetCardSharedPlaceholderBody,文案取 headerCatalogEntry.sharedPlaceholder ?? DEFAULT_SHARED_DASHBOARD_WIDGET_PLACEHOLDER(catalog 定义见 catalog.ts#L161);titleHref 在公开视图下被抑制,⋯ 菜单与编辑控件经既有 placement 辅助函数隐藏。
  • 测试——后端 test_sharing.py 覆盖共享负载中的 widget tile;前端 DashboardWidgetItem.test.tsx 覆盖公开占位符与产品门禁分支(mock 了 userHasDashboardWidgetProductAccess 的 true/false 两种取值)。

给新增 widget 类型的规则:在 catalog 条目上设置 sharedPlaceholder{ title, message },使用产品专属文案);只有当通用的回退文案可以接受时才可省略。

活动日志:widget 变更全量留痕

DashboardWidget 模型混入了 ModelActivityMixin,作用域名为 "DashboardWidget",所有创建/更新/删除都会被记录:

  • activity_logging.py 中的 handle_dashboard_widget_change 负责在变更时写入活动日志。
  • 不要往 config JSON 字段里存秘密——config 的每次变更都会被活动日志完整记录,敏感信息会随 diff 泄漏到审计流。

这条约束把「可审计」与「数据安全」绑在了一起:config 既是 widget 行为的配置源(Pydantic 校验后的 JSONField),又是审计对象,因此其中只应放可公开展示的参数。

小结:约束清单

把文档与源码对齐后,维护 widget 权限与共享行为时的完整约束清单是:

  1. 一切 widget 数据访问先过团队作用域,tile upsert 校验 widget 与 dashboard 同团队;
  2. 双层检查都通过才放行:dashboard:read/dashboard:write + registry 驱动的 required_product_access
  3. 门禁只在 registry 声明,dashboard.py 零类型分支;前端 WIDGET_PRODUCT_ACCESS_CHECKSDashboardWidgetItem 的锁必须与后端一致,且前端锁定不能替代后端强制;
  4. required_scopes 仅用于 API 密钥 scope 校验(read 可被 write 替代、* 全通过),不得用于终端用户 RBAC;
  5. 复制/移动/复制 dashboard 均为深克隆语义,button tile 不可跨 dashboard 迁移;
  6. 公开共享只渲染元数据 + sharedPlaceholder 占位符,不执行 run_widgets;导出隐藏 tile;订阅只读;
  7. 跨项目转移经 DashboardWidgetVisitor 深克隆,display name 按 name → catalog label → 类型名回退;
  8. DashboardWidget 全量纳入活动日志,config 中禁止存秘密。

对应的主要源码入口:DashboardWidget 模型widget_access.pywidget_specs/registry.pydashboard.pyactivity_logging.pywidgetProductAccess.tsDashboardWidgetItem.tsxcatalog.tsdashboardUtils.tssharing.pyresource transfer 访客。更宽的平台背景(文件地图、不可变规则)见 SKILL.md

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.89 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
602
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
396
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
526