Skip to content

service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356

Description

@baozhoutao

版本 / 包

16.0.0-rc.0 · @objectstack/service-automation + @objectstack/trigger-record-change(dist 7.3.0)。#1888 的后续:runAs 现已被引擎消费(system 提权正常),但 runAs:'user' 的凭证传播是空心的。

现象

runAs 缺省 'user' 的 record_change 流,其数据节点(update_record 等)并不按「触发用户的实际授权」执行,而是按只剩 member/everyone 兜底的零权限集用户执行——即便触发用户本身对目标对象有完整授权(权限集 *_ALL)。真实项目(os-tianshun-ehr,单租户)两条审批流双双中招,病征按对象配置分两副面孔:

  1. access.default:'private' 对象:流内 update_record 直接 403,话术
    not permitted for positions [org_member, everyone]——权限集授权完全不可见,整条流卡死;
  2. sharingModel:'public_read_write' 对象:对象级不拦,但 readonly 镜像字段(审批 stage 回写)走用户态被静默剥离——节点报 success(3ms)而 DB 真值恒空,极难察觉(见下「次级问题」)。

admin 触发同样中招(流上下文无 admin 直通)。

根因(代码级)

trigger-record-change buildContext():

userId: session.userId,
// Forward the writer's roles/org so a `runAs:'user'` flow enforces
// RLS exactly as the user who made the change, not a member fallback (#1888).
...Array.isArray(session.positions) && session.positions.length ? { positions: session.positions } : {},
...session.organizationId ? { tenantId: session.organizationId } : {},
  • permissions(权限集)从未透传——context 里压根没有这个键;
  • positions 仅在 hook session 恰好携带且非空时透传;实测 ObjectQL hook session 并不带,实际恒缺省。

service-automation resolveRunDataContext() 端如实消费:

if (context?.runAs === "system") return { isSystem: true, positions: [], permissions: [] };
if (!context?.userId) return void 0;
return { isSystem: false, userId, positions: context.positions ?? [], permissions: context.permissions ?? [] };

两端各自"正确",但中间没人负责把触发用户的岗位/权限集查出来塞进去——注释承诺的「enforces RLS exactly as the user who made the change」实际落空,落到的是 member 兜底。

期望

二选一(倾向前者):

  • 修实现:user 模式在触发时(或 resolveRunDataContext 端惰性)解析触发用户的 sys_user_permission_set 绑定与岗位,构造完整数据上下文——让 runAs:'user' 兑现"按触发人授权执行"的承诺;
  • 修文档/契约:若短期不做凭证解析,明确文档化「runAs:'user' 数据操作 = bare member 语义」,并给含 CRUD 节点且未显式声明 runAs 的流加 build 告警(现有的 unscoped-schedule 告警只覆盖"无 user"链路,不覆盖"有 user 但零凭证"链路)。

次级问题(可拆单):update_record 静默丢写不可观测

readonly 字段被服务端剥离时(录入语义本身没错),流步骤日志仍记 success,无任何 warning。建议 update_record 节点在「请求写 N 个字段、实际生效 < N」时至少 warn 一条——这次两条审批流被假 success 掩盖了整个 stage 回写链路失效。

应用侧现状(参考)

os-tianshun-ehr 两条审批流已按语义改声明 runAs:'system'(这些 CRUD 节点本就是引擎记账,审批决策仍由 approvals API 强制 actor∈pendingApprovers),项目侧不阻塞;但任何真需要"以触发人身份+其授权跑数据操作"的流(如按触发人行级过滤的查询节点)在 16rc 上暂无法实现。

Activity

  1. os-zhuang commented on Jul 22, 2026

    @os-zhuang
    Contributor

    主因已修复并发布,次级项已拆单,故关闭本单。

    主因(runAs:'user' 凭证空心) — 已由 #3389 修复(commit b20201fbd),随 16.1.0 发布。采用了本 issue 首选的「修实现」方案:

    • @objectstack/core:抽出 resolveUserAuthzGrants(ql, userId, opts),作为读 sys_member / sys_user_position / sys_*_permission_set 的唯一实现;HTTP 解析器改为委托它,行为不变。
    • @objectstack/service-automation:新增 AutomationEngine.setUserGrantsResolver,由插件桥接。runAs:'user' 运行在 run setup 处一次性解析触发人的 positions + permission sets,注入每个数据节点的 ObjectQL context。已携带 permissions 的上下文(REST 触发 / ADR-0090 on-behalf-of ceiling)保持原样,绝不放大;runAs:'system' 不变;解析失败 fail-safe(告警,保留裸用户,决不提权)。
    • @objectstack/trigger-record-change:buildContext 不再转发误导性的空壳 positions,只转发 userId + 租户,交由引擎权威解析。

    次级项(update_record 静默丢写不可观测) — 按本 issue 里「可拆单」的标注拆出为 #3407。该缺口独立于授权根因:readonly / readonlyWhen / FLS 这些语义上正确的字段剥离,即便在 #3389 之后依旧只在服务端 logger 打 warn,不进流程步骤日志。设计方向(引擎回传 droppedFields vs 日志路由)在新单里待拍板。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions