
PostHog 仪表盘 Widget 的权限与共享机制团队作用域、双层 RBAC 与公共占位符实现【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthogPostHog 的仪表盘Dashboard除了传统的洞察图块外还支持一类产品级「widget tile」如错误追踪列表、实验列表、会话回放列表等它们由DashboardWidget模型承载、由run_widgetsAPI 拉取数据。这类 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_length64) name models.CharField(max_length400, nullTrue, blankTrue) config models.JSONField(defaultdict) team models.ForeignKey(posthog.Team, on_deletemodels.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需要同时通过两个层级的检查任一层失败都会拒绝层级检查内容仪表盘 RBACdashboard:read/dashboard:write针对 dashboard 对象本身的访问级别产品 RBAC针对 widget 所属产品error_tracking、session_recording 等的访问级别前端锁 后端run_widgets必须一致仪表盘 RBAC 体现在 API 层的 scope 标注上。dashboard.py 中的 action 按读写方向标注 required scope例如action(methods[GET], detailTrue, 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字段。产品 RBACcanonical 规则与后端实现产品级 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(fUnknown widget type: {widget.widget_type}) ...见 widget_access.py#L50-L78。在 dashboard.py 中DashboardSerializer._check_widget_tile_product_accessL1799-L1803被序列化和各 tile 动作调用如 L1995、L2857、L2913新建 tile 时也在 widget_create.py 中走同一检查。单测见 test_widget_access.py。实际注册表中required_product_access的取值与产品一一对应例如widget_specs/registry.py#L141-L150ERROR_TRACKING_LIST_WIDGET_TYPE: WidgetSpec( ... required_scopes(error_tracking:read,), required_product_accesserror_tracking, ... )activity_events_list的required_product_accessNone——表示该类型不额外受产品门禁约束只受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: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 行同样深克隆且进入资源转移的「依赖预览」体系。转移访客实现见 DashboardWidgetVisitorclass DashboardWidgetVisitor( ResourceTransferVisitor, kindDashboardWidget, excluded_fields[last_modified_at], friendly_nameDashboard widget, user_facingFalse, ):该访客的get_display_name优先取 widget 的name若已设置否则回落到WIDGET_CATALOG中该widget_type的label再否则直接用类型字符串——这保证了转移预览中的可读性。配套的DashboardTile访客保持非用户可见user_facingFalse。一点事实核对实现文档中称DashboardWidgetVisitor为user_facingTrue默认值但当前源码显式写的是user_facingFalse基类 ResourceTransferVisitor 的默认值确实是True即源码相对默认值做了显式收窄。可以推断预览中 widget 的展示行为以源码为准display_name回退逻辑name → catalog label → 类型名是稳定契约而是否在转移预览中列为可见依赖以 dashboard_widget.py 当前的user_facingFalse为准。相关端到端测试是 test_resource_transfer.py 中的test_preview_returns_dashboard_with_widget_tilesL105。摆放位置决定 tile 渲染形态私有 / 公开 / 导出 / 订阅同一个 widget tile 在不同DashboardPlacement下行为完全不同这是「共享」语义的核心表摆放位置widget tile 行为私有 dashboard完整 tilerun_widgets拉数据、liveComponent渲染、允许时显示编辑控件公开 / 共享链接DashboardPlacement.Publictile会渲染头部元数据不执行run_widgets拉取主体显示 catalog 中的sharedPlaceholder文案由WidgetCardSharedPlaceholderBody呈现导出DashboardPlacement.Exportwidget tile隐藏isWidgetTileVisibleOnPlacement——只在导出时隐藏订阅 / 快照只读——无编辑弹窗、tile 控件不可发起变更公开共享 dashboard 的实现要点isWidgetTileVisibleOnPlacementdashboardUtils.ts#L161——只在export时返回 falsepublic/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.tsx 在placement Public分支渲染WidgetCardSharedPlaceholderBody文案取headerCatalogEntry.sharedPlaceholder ?? DEFAULT_SHARED_DASHBOARD_WIDGET_PLACEHOLDERcatalog 定义见 catalog.ts#L161titleHref在公开视图下被抑制⋯ 菜单与编辑控件经既有 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。【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考