Skip to content

Commit cbb6df1

Browse files
fix(service-automation): a runAs:'user' run carries its user's posture rung, so a platform administrator's flow is served the ledger's deployment-level rows (#28098)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DTTrKLU12Z9sF3RMpMJaEeG)_ Fixes #28070 Clause-②: yes (widening) — `RunIdentityContext` (barrel-exported), and so the data context `resolveRunDataContext` answers, gains an optional `posture` member, and `FlowUserGrants` gains one; a `runAs: 'user'` run seeded with `PLATFORM_ADMIN` is served the rows that rung admits. Metadata-protocol: none Skills-docs: updated here (`content/docs/permissions/record-view-auditing.mdx`, one sentence beside #26750's). `grep -rn -i "posture" skills/objectstack-automation` and `grep -n -i "runAs" content/docs/automation/flows.mdx`: no text states which facts a `runAs: 'user'` run carries beyond positions / permissions / tenant, so nothing else to change. ## 维护者速读 - **改了什么**:`runAs: 'user'` 的自动化运行现在带上运行用户的姿态等级(posture rung),取自请求路径同一次 `resolveUserAuthzGrants` 解析,本包不做第二次推导。以平台管理员身份运行的流程,能读到审计账本里的部署级记录(`tenant_id` 为空),和他本人的请求一致。 - **为什么改**:#26750 之后部署级记录只给 `PLATFORM_ADMIN`,而运行上下文没有 rung,平台管理员的流程只看到组织行(收窄,不是泄露)。分诊裁定:走同一推导、不另起一套。 - **风险与回滚**:rung 只和推导出它的 grants 一起种下;门把已解析的 positions/permissions 带进来(含 agent 收窄上限)时不补 rung,不会放大。审批切换到裁决人时删掉提交人的 rung。`runAs: 'system'` 不变。回滚即 revert,无数据迁移。 - **先看**:`packages/services/service-automation/src/run-resume.ts`(`seedRunGrants` 与裁决切换各一处)、`runtime-identity.ts`(`resolveRunDataContext` 的 user 分支)、新测试 `runas-user-posture-rung.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/runas-user-posture-rung.integration.test.ts`,预期 12 条全绿。 - **需要你做的**:无,席位落地。 ## What changes - **The bridge** (`plugin.ts`). The grants bridge the automation plugin wires already calls `@objectstack/core`'s `resolveUserAuthzGrants` for the run's user; it now forwards that resolution's own `posture` beside the positions, permission sets, peers and email. Nothing in this package derives a rung. - **The seed** (`run-resume.ts`, `seedRunGrants`). The rung is seeded exactly when the run's positions and permission sets are resolved at setup (the `needsGrants` branch), so it always stands beside the grants it was derived from. A context that brought its own grants (a door's pre-resolved envelope, an agent's narrowed ceiling) gains no rung from a re-resolution, though it still gains its peers and email as before. - **The decision switch** (`run-resume.ts`, `deciderSegmentContext`). The submitter's rung is deleted with their other facts; the decider's segment is seeded with the decider's own. Without the delete, a decider the resolver answers nothing for would have kept a platform administrator's rung. - **The data context** (`runtime-identity.ts`). `SeededRunContext.posture` and `RunIdentityContext.posture` (both typed `AuthzPosture` from `@objectstack/spec/security`); `resolveRunDataContext`'s user arm carries the seeded rung through `runPosture`, which drops a value that names no rung. The system arm is untouched. - **Types** (`engine.ts`, two small hunks). `FlowUserGrants.posture`, typed as `SeededRunContext['posture']` so no import hunk; the `seedRunGrants` docblock (its contract) names the rung. ## Why Triage `6522159967`: a `runAs: 'user'` run derives its rung through the same derivation a request uses, for the user it runs as, with no second derivation. Re-measured on `main` @ `549203d0`: `grep -n posture packages/services/service-automation/src/run-resume.ts` hit only two unrelated comments (lines 314, 820; tenancy posture), and the control test below shows the administrator's request without the rung (the shape a run carried) served the organization row only. ## Verification Head `cfd3bf44`. - `runas-user-posture-rung.integration.test.ts`, 12 tests, two layers. Envelope (bare engine, recording node, resolver stub): the rung rides the data context for a user-only trigger, for a member, after an approval decision (the decider's), and is absent for carried grants, for a value that is not a rung, for a decider the resolver answers nothing for, and on `runAs: 'system'`. Real stack (ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3, identity objects, SecurityPlugin, AuditPlugin's ledger read gate, the plugin's own bridge, `single` posture): the platform administrator's run is served every deployment-level `platform_admin_standing_change` row plus the organization's one; an organization holder of `view_all_audit_log` is served the organization row only, by run and by request, with or without the rung; a `runAs: 'system'` run is served all, as before. - Ablation 1, `if (posture) out.posture = posture;` disabled in `runtime-identity.ts`: 4 red (the card's reproduction, plus three envelope cells); the member and system pins stay green (unchanged). - Ablation 2, `delete switched.posture;` removed in `run-resume.ts`: 1 red (the unresolved decider keeps the submitter's rung). - `pnpm --filter @objectstack/service-automation typecheck`: exit 0. `pnpm --filter @objectstack/service-automation test`: 228 files, 2888 tests, exit 0. - `dispatch-gates --commands` derived 93 families; they and `pnpm lint` are running locally. The reconciled `--ran` result goes in the os-dev report on the card; CI runs every family here too. ## Acceptance notes - Out of scope, card it: the REST automation door (`packages/runtime/src/domains/automation.ts:204-205`) and the action door (`packages/runtime/src/action-execution.ts:2077-2078`) forward the request's `positions` / `permissions` but not `ec.posture`, so a flow started there carries grants and no rung, and still fails closed for the rung's readers. Forwarding it touches `AutomationContext` (`packages/spec/src/contracts/automation-service.ts`) or the door's extra-key shape. The changeset and the docs sentence say so. - Request parity for the other readers of `ExecutionContext.posture` on a run (plugin-sharing `hasPlatformAuthority`, plugin-approvals' platform arm, observability's timing exposure): a platform administrator's run now answers as their request does. plugin-security's tenant-wall exemption already enforced the narrower of the carried rung and its capability probe, so a seeded rung never widens it. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Running locally with the gates; CI reads it.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The `维护者速读` block is the maintainer's summary, as on the lane's other PRs.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cwHfcrFZ8RzH1TiP48 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ec00cf commit cbb6df1

7 files changed

Lines changed: 415 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a `runAs: 'user'` run carries its user's posture rung, so a flow run as the platform administrator is served the ledger's deployment-level rows its user's request is served (#28070)
6+
7+
Clause-②: yes (widening) — `RunIdentityContext` (barrel-exported), and so the data context `resolveRunDataContext` answers, gains an optional `posture` member, and `FlowUserGrants` gains one; a `runAs: 'user'` run seeded with `PLATFORM_ADMIN` is served the rows that rung admits.
8+
Metadata-protocol: none
9+
10+
A request carries the posture rung its user resolves to (`ExecutionContext.posture`, derived in `@objectstack/core`'s `resolveUserAuthzGrants` and copied on by `resolveAuthzContext`). A `runAs: 'user'` automation run seeded the same user's positions, permission sets, peers and email through the same resolver, and no rung. Every reader keyed on the rung then answered the run as for a principal below it. The measured case: after #26750 the compliance ledger's deployment-level rows (`tenant_id` NULL) are served to `PLATFORM_ADMIN` alone, so a flow run as the platform administrator was served organization rows only. That failed closed (fewer rows, never more).
11+
12+
- **Now.** Run setup takes the rung off the resolution it already makes for the run's user (the automation plugin's grants bridge forwards `resolveUserAuthzGrants`' own `posture`; nothing in this package derives one), seeds it beside the positions and permission sets, and every data node carries it to the data engine. The platform administrator's run is served the deployment-level rows, as their request is.
13+
- **Only with the grants it came from.** The rung is seeded exactly when the run's positions and permission sets are resolved at setup. A context that brought its own grants (a door's pre-resolved envelope, an agent's narrowed ceiling) gains no rung from a re-resolution, so a rung never stands beside grants it was not derived from. A value that names no rung is not carried.
14+
- **After an approval decision.** The decision switch removes the submitter's rung with their other facts, and the decider's segment carries the decider's own.
15+
- **Unchanged.** A `runAs: 'system'` run (elevated; no rung is resolved or carried). A member's run is served what the member's request is served, as before: a rung below `PLATFORM_ADMIN` admits nothing a missing one did not, and plugin-security's tenant-wall exemption already enforced the narrower of the carried rung and its capability probe.
16+
- **Still without a rung.** A run started through the REST or action door, which forwards the request's positions and permission sets but not its rung, keeps failing closed for the rung's readers until that door forwards it.

‎content/docs/permissions/record-view-auditing.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ A row about no organization (its `tenant_id` empty: a global settings change, a
224224
user-import run, a platform-admin standing change, a user account created or
225225
reset through the admin endpoints) is served to the platform
226226
administrator alone, in every tenancy posture, `single` included; no other
227-
reader is served one, this capability's holders included.
227+
reader is served one, this capability's holders included. A `runAs: 'user'`
228+
flow whose grants the engine resolves for its user (a record-change run, or the
229+
segment after an approval decision) carries that user's posture, so one run as
230+
the platform administrator is served these rows as their request is; a run
231+
started through the REST or action door is not, yet.
228232
Rows carry the
229233
ADR-0057 `audit` lifecycle class: retained hot for
230234
90 days, then archived for seven years where an `archive` datasource is

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,15 @@ export interface FlowUserGrants {
802802
* reads it drops out: the run sees less, never more.
803803
*/
804804
email?: string;
805+
/**
806+
* The user's posture rung, derived from the grants above by the resolver a
807+
* request's envelope is built through (`resolveUserAuthzGrants`' own field,
808+
* under its own name). Run setup seeds it beside the positions and
809+
* permission sets ({@link SeededRunContext.posture}). A resolver that omits
810+
* it leaves the run without one, so every reader of the rung answers as for
811+
* a principal below it: the run sees less, never more.
812+
*/
813+
posture?: SeededRunContext['posture'];
805814
}
806815

807816
/**
@@ -5655,7 +5664,8 @@ export class AutomationEngine implements IAutomationService {
56555664

56565665
/**
56575666
* Seed a `runAs:'user'` run context's authorization facts — `positions`,
5658-
* `permissions`, the peer list `org_user_ids` and the user's `email` — for
5667+
* `permissions` with the posture rung derived from them, the peer list
5668+
* `org_user_ids` and the user's `email` — for
56595669
* its `userId`, through the wired grants resolver: the one resolver the
56605670
* request path seeds a request's envelope through
56615671
* (`resolveUserAuthzGrants`, bridged by the automation plugin). Mutates

‎packages/services/service-automation/src/plugin.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,10 @@ export class AutomationServicePlugin implements Plugin {
14571457
// policy reading `current_user.email` resolves for the
14581458
// run as it does for the user's own request.
14591459
...(grants.email ? { email: grants.email } : {}),
1460+
// The rung the request path carries for this user
1461+
// (`resolveAuthzContext` copies it off this same
1462+
// resolution), derived once there and never here.
1463+
...(grants.posture ? { posture: grants.posture } : {}),
14601464
...(tenantId ? { tenantId } : {}),
14611465
};
14621466
});

‎packages/services/service-automation/src/run-resume.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ export async function seedRunGrants(
215215
runContext.positions = Array.isArray(grants.positions) ? grants.positions : [];
216216
runContext.permissions = Array.isArray(grants.permissions) ? grants.permissions : [];
217217
if (grants.tenantId && !runContext.tenantId) runContext.tenantId = grants.tenantId;
218+
// The rung the resolver derived from these same grants,
219+
// as the user's request carries it. Seeded with them and
220+
// only with them: carried grants keep the rung they came
221+
// with, so a narrowed ceiling never gains a wider rung.
222+
if (grants.posture) (runContext as SeededRunContext).posture = grants.posture;
218223
}
219224
if (needsPeers && Array.isArray(grants.org_user_ids)) {
220225
(runContext as SeededRunContext).org_user_ids = grants.org_user_ids;
@@ -309,6 +314,10 @@ async function deciderSegmentContext(
309314
delete switched.positions;
310315
delete switched.permissions;
311316
delete switched.org_user_ids;
317+
// The trigger user's rung is theirs: kept under the decider's id, a
318+
// platform administrator's submission would hand a member's decision
319+
// segment the deployment-level rows. Seeded for the decider below.
320+
delete switched.posture;
312321
// The trigger user's email is theirs too: kept under the decider's id,
313322
// it would admit the decider to every row an email-keyed policy grants
314323
// the submitter. Removed on both postures, and seeded for the decider

0 commit comments

Comments
 (0)