PostHog 仪表盘 Widget 的权限与共享机制:团队作用域、双层 RBAC 与公共占位符实现
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 模型。由此带来两条平台规则:
DashboardWidget.team是必填字段——widget 永远属于某个项目(team),所有读/写操作都按team_id过滤,跨项目的 widget 在模型层面不可达。- 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_widgets,L3222)与大量 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 == ... 的分支)。规范链路是:
- 每个
WidgetSpec在 registry.py 中声明required_product_access(产品资源名)与required_scopes; - 注册表条目经
WIDGET_REGISTRY/get_widget_registry_entry暴露; - 运行时由 widget_access.py 中的
get_widget_product_access_error与check_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_access(L1799-L1803)被序列化和各 tile 动作调用(如 L1995、L2857、L2913),新建 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_list 的 required_product_access=None——表示该类型不额外受产品门禁约束,只受 required_scopes 与仪表盘 RBAC 约束。
两条重要边界规则:
required_scopes是文档性质,不能用于用户侧强制。API 密钥(personal API key / OAuth token / ID Jag token)的 scope 校验走独立的get_widget_api_scope_error(widget_access.py#L29-L47),它把xxx:read的满足条件放宽为「持有xxx:read或xxx: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.tsx 用
userHasDashboardWidgetProductAccess(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)
}
DashboardWidgetItem 在 L191 把它作为 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_type 的 label,再否则直接用类型字符串——这保证了转移预览中的可读性。配套的 DashboardTile 访客保持非用户可见(user_facing=False)。
一点事实核对:实现文档中称 DashboardWidgetVisitor 为 user_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_tiles(L105)。
摆放位置决定 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 的实现要点
isWidgetTileVisibleOnPlacement(dashboardUtils.ts#L161)——只在 export 时返回 false;public/shared 依旧渲染 tile 壳。渲染判断在 DashboardItems.tsx#L685:widget && dashboardWidgetsEnabled && isWidgetTileVisibleOnPlacement(placement)。dashboardLogic.dashboardWidgetsEnabled——公开视图下,只要 dashboard 含有 widget tile 就为true(启用 tile 壳与布局),但当placement === Public时仍跳过refreshDashboardWidgets,即不触发数据拉取。SharedDashboardWidgetMetadataSerializer(dashboard.py#L904)——当 tile 序列化器上下文带有is_shared时,widget 负载降级为仅元数据(不附带审计/用户字段等共享视图不需要的内容)。挂载点见 L965-L967:if self.context.get("is_shared") and instance.widget_id is not None时用该序列化器替换完整 widget 负载。- 共享入口路径——posthog/api/sharing.py 在构建共享 dashboard 的 tile 序列化器上下文时设置
is_shared: True。 - 前端占位符——DashboardWidgetItem.tsx 在
placement === 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负责在变更时写入活动日志。 - 不要往
configJSON 字段里存秘密——config的每次变更都会被活动日志完整记录,敏感信息会随 diff 泄漏到审计流。
这条约束把「可审计」与「数据安全」绑在了一起:config 既是 widget 行为的配置源(Pydantic 校验后的 JSONField),又是审计对象,因此其中只应放可公开展示的参数。
小结:约束清单
把文档与源码对齐后,维护 widget 权限与共享行为时的完整约束清单是:
- 一切 widget 数据访问先过团队作用域,tile upsert 校验 widget 与 dashboard 同团队;
- 双层检查都通过才放行:
dashboard:read/dashboard:write+ registry 驱动的required_product_access; - 门禁只在 registry 声明,
dashboard.py零类型分支;前端WIDGET_PRODUCT_ACCESS_CHECKS与DashboardWidgetItem的锁必须与后端一致,且前端锁定不能替代后端强制; required_scopes仅用于 API 密钥 scope 校验(read 可被 write 替代、*全通过),不得用于终端用户 RBAC;- 复制/移动/复制 dashboard 均为深克隆语义,button tile 不可跨 dashboard 迁移;
- 公开共享只渲染元数据 +
sharedPlaceholder占位符,不执行run_widgets;导出隐藏 tile;订阅只读; - 跨项目转移经
DashboardWidgetVisitor深克隆,display name 按 name → catalog label → 类型名回退; DashboardWidget全量纳入活动日志,config中禁止存秘密。
对应的主要源码入口:DashboardWidget 模型、widget_access.py、widget_specs/registry.py、dashboard.py、activity_logging.py、widgetProductAccess.ts、DashboardWidgetItem.tsx、catalog.ts、dashboardUtils.ts、sharing.py 与 resource transfer 访客。更宽的平台背景(文件地图、不可变规则)见 SKILL.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0632
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00