Skip to content

[Decision] Actions taken while impersonating a user are recorded only under that user #30213

Closed

Description

@objectstack-fleet

epic:#26676#1 · zhuangjianguo · session_cdb7a717def74e719988f089fbaa18ff

The maintainer, verbatim: 「S2:管理员模拟员工时做的修改,事后查不出是谁做的,这个要立决策卡,评估开发方案」 (2026-11-05). Business explanation is in the session; this card holds the options.

The question

When an administrator impersonates an employee and changes data, how should the record show who really did it?

Governing text:

  • packages/platform-objects/src/identity/sys-user.object.ts:476: the impersonation confirm says "actions will be logged".
  • Banner: 「您正在以 {{user}} 的身份操作 —— 所有操作都会记录为该用户。」
  • ADR-0090 D10 rule 4 (dual attribution) and ADR-0118 D1/D5 (actor keeps two values; "which non-user acted" is answered by an added attribution field).

Measured

On main b7075d9908, a ticket created in an impersonation session:

Where Records
sys_audit_log row user_id and actor both the impersonated user; ip_address and metadata empty
sys_activity 王工
Record created_by the impersonated user
Sign-in rows The session event does name the administrator (plugin-audit/src/auth-event-audit.ts)

So an investigation can see that an impersonation session existed, and cannot see which writes came from it. No administrator appears on the write.

Existing shapes to build on, read at dfafae8132:

  • audit-writers.ts writes metadata: { performed_by, on_behalf_of } for a delegated agent write, present only on such writes.
  • ExecutionContext.attributedUserId is the precedent for an attribution-only field, which the authorization path never reads.
  • service-print/src/print-routes.ts:176 refuses a print in an impersonation session, so reading impersonatedBy from the session already has a consumer.
  • rest-server.ts:3072 notes that impersonation resolves to the impersonated user at the session level.

Options and development cost

The seat's estimates come from reading the code; nothing was prototyped.

  • A. Record the administrator on each write, in the existing metadata shape. Add an attribution-only context field filled from session.impersonatedBy. The audit writer adds metadata.impersonated_by, and sys_activity carries the same. The audit log and the record timeline show 「Dev Admin 代王工操作」. user_id, created_by and permissions are unchanged. No schema change. Small to medium: spec, REST context, audit writers, console display. Limit: a metadata text column cannot be filtered well, so "everything this administrator did while impersonating" needs the session window or a scan.
  • B. A first-class impersonated_by column on sys_audit_log and sys_activity. Same display as A, plus a real filter. Medium: adds schema, migration and an index on both tables, and old rows stay empty. It is a second attribution shape next to performed_by.
  • C. Make impersonation read-only. Refuse writes in an impersonation session, as the print door already refuses. Attribution stops being a question. Small on the server, and one choke point must cover every write path. Support loses the ability to reproduce and fix a problem as the user, which is the common reason to impersonate. Needs a console state explaining the refusal.
  • D. Only correct the wording. The confirm says plainly that writes are recorded as the user. Almost no cost, and the gap stays.

Recommendation: A

四轴(中文):

  • 实际业务需求: 客户投诉、内部追责和合规审计都要回答「这是本人还是管理员做的」。管理员替员工排查和修数据又是模拟功能的日常用途,所以 C 会砍掉主要价值。A 和 B 都能回答问题。需要「按管理员查全部代操作」的频率更低,可以先靠模拟会话的时间窗定位。
  • 项目长远合理性: A 沿用代理写入已有的双重归属形状,全平台只有一种写法;B 多一种形状,但查询更好。将来审计查询确实需要时,可以从 A 升到 B,那时再加列和回填。
  • 防 AI 写代码犯错: 归属字段只用于记录、不参与授权,沿用 attributedUserId 的不变量,AI 不会误把它当主体。C 要覆盖所有写入口,漏一个就是静默缺口。
  • 创业阶段不扩散: A 不加列、不加迁移,只是把已有事实写下来。

A's sub-points to confirm:

  • Should the timeline show the administrator to everyone who can read the record, or only to administrators? The seat suggests everyone, because the record's owner has a reason to know.
  • The empty ip_address on record writes is a related gap. The seat suggests filling it in the same change if it is cheap, and not letting it hold up the rest.

Blocks nothing.

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Nov 5, 2026
  2. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#26676#1 · zhuangjianguo · session_cdb7a717def74e719988f089fbaa18ff

    Ruling recorded: option A. The maintainer, verbatim, in the direct session on 2026-11-05: 「A」

    Record the administrator on each write made in an impersonation session, in the additive shape agent writes already use. The subject is unchanged: user_id, created_by, ownership and permissions stay the impersonated user's.

    Shape

    • An attribution-only context field filled from the session's impersonatedBy. Nothing in the authorization path reads it, the same invariant as attributedUserId.
    • sys_audit_log adds the administrator in metadata, for example impersonated_by, present only on such a write. Absence means the user acted themselves.
    • sys_activity carries the same fact.
    • The audit log and the record timeline show 「Dev Admin 代王工操作」.

    Sub-points, read as the seat's stated recommendation. The maintainer's word did not name these separately, and if they say otherwise, their word replaces these lines:

    • Everyone who can read the record sees the administrator on the timeline, not only administrators.
    • The empty ip_address on record writes is filled in the same change if that is cheap. It does not hold up the rest.

    Verify before landing; report a fork if any of these fails rather than narrowing silently:

    • Every write door in an impersonation session stamps it, not only the generic data route: actions, flows started by the write, and comments.
    • The fact does not reach a caller-gating hook as the caller.
    • The console's display is new work. The seat found no reader of performed_by in ui/packages/app-shell/src or ui/packages/plugin-detail/src, so a shared rendering that also shows agent writes is welcome if it costs little.

    Not in this change: a dedicated column and filter by administrator (option B). That can follow if the query proves needed.

    Left for triage to route. No lane or claim is set here.

  3. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Nov 5, 2026
  4. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p2 · domain:server · pm:queue · 2026-11-05T02:58Z

    ⛔ Not a claim.

    • Read fresh. Ruled A (「A」, recorded on this card): each write made while impersonating records the administrator in the existing metadata shape, filled from session.impersonatedBy. An audit-attribution gap, so p2. The PR states what the audit reader shows.

    Generated by Claude Code

  5. added
    domain:serverDomain lane — seat card indexed by label:pm:seat
    enhancementNew feature or request
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 5, 2026
  6. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

    Claim: PM loop round 1 · 2026-11-05T21:40Z
    Session: session_016WV4czvZykUqhYxYXSSDt4
    Account: os-elon-musk (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: the card thread's designated claude/project-thread-* branch, named in a claim amendment once the thread reports it
    Worktree: objectstack-issue-30213
    Domain: domain:server
    Seat: domain:server#4
    File surface: the execution-context assembly that reads the session (packages/core/src/security/assemble-execution-context.ts, packages/plugins/plugin-auth/src/auth-actor-attribution.ts), the one optional attribution-only field beside attributedUserId in packages/spec/src/kernel/execution-context.zod.ts, the audit and activity writers (packages/plugins/plugin-audit/src/**), every write door the ruling's verify list names (data route, actions, flows started by the write, comments), the console audit/timeline display of 「X 代 Y 操作」 under ui/packages/**, their tests, one root .changeset/ file. ⛔ No schema column, no migration (option B is not ruled). Stop on breach; explain in the report
    Container & model: M, mode:thread, model: default (dispatch-gates --tier: Clause-② SUSPECT on packages/spec/src/kernel/execution-context.zod.ts; floor sonnet · default opus · ceiling fable; build at default, review owed at CONTRACT_REVIEW_TIER), effort: high
    Clause-②: yes
    Metadata-protocol: none
    Responsibility: n/a — not a defect card
    Thread-read: 6648465257
    Premise: holds per the ruling 6647756733 measured on main b7075d9908; the dev re-measures on origin/main first (an impersonation-session write stamps no administrator anywhere)
    Serial constraints cleared: none — no open PR's file list touches audit-writers.ts, execution-context.zod.ts, assemble-execution-context.ts, auth-actor-attribution.ts or engine-execution-plumbing.ts (read over the 36 open PRs at claim time)

    Clause-② note: the ruling adds one optional attribution field to the published ExecutionContext schema (a widening). It is built here under the ruling and owes one contract-review-tier review before the queue; if the dev can carry the fact without touching packages/spec/**, the claim amends to Clause-②: no.

  7. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

    Claim amendment · claim 6670267078 · 2026-11-05T21:48Z

    Branch: claude/project-thread-8i10co
    Dev session: session_016WV4cxMph1oXW6ujSSVm1G (the card thread this seat opened; worktree objectstack-issue-30213, base origin/main 47c457b2)
    File surface, added (measured by the dev; accepted by the seat as the doors ruling A's verify list names):

    • packages/spec/src/data/hook.zod.ts: HookContext.provenance.impersonatedBy (the audit writer reads provenance; the same shape as performedByClientId).
    • Flows started by the write: packages/spec/src/contracts/automation-service.ts (AutomationContext.impersonatedBy), packages/triggers/trigger-record-change/src/record-change-trigger.ts, packages/services/service-automation/src/runtime-identity.ts and run-resume.ts (stripped on the approval decider switch).
    • packages/runtime/src/sandbox/body-runner.ts and script-runner.ts (sandbox provenance projection, so the key is never declared-but-unpopulated), and the CLI lowered-body enumeration pin.
      Clause-②: yes (widening; the contract-review-tier review before the queue stands). Metadata-protocol: none.
      Fork recorded, not narrowed: comments write no sys_audit_log / sys_activity row at all today (sys_comment is in SKIP_OBJECTS), so stamping them needs a column or a new ledger row, both outside ruling A. The dev builds the data route, actions and flows, and reports comments as a fork for the maintainer.

    Everything else in claim 6670267078 stands.

  8. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-elon-musk · session_016WV4cxMph1oXW6ujSSVm1G
    os-dev-report

    {
      "issue": 30213,
      "status": "done",
      "branch": "claude/project-thread-8i10co",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/30800",
      "session": "session_016WV4cxMph1oXW6ujSSVm1G",
      "premise_still_valid": true,
      "summary": "Ruling A built: ExecutionContext.impersonatedBy and HookContext.provenance.impersonatedBy (attribution only, never in session) set by resolveAuthzContext from the session read; plugin-audit stamps sys_audit_log.metadata.impersonated_by, sys_activity impersonated_by + impersonated_by_name, and the action_invocation row; record-triggered flows carry it on both runAs arms and the approval decider segment drops it; console timeline/history render detail.impersonatedActor in 10 locales. Comments are a reported fork (sys_comment is in SKIP_OBJECTS), not built; ip_address not filled (not cheap). No column, no migration.",
      "tests": "plugin-audit 3 files 30/30; core 341/341; trigger-record-change 52/52; service-automation 26/26; console 3091 + RecordDetailView 102/102 files 651/651. Ablation: deleting the audit stamp line in audit-writers.ts -> activity-actor-name ledger pin 1 failed / 14 passed; restored, blob == HEAD.",
      "gates": "turbo build 119/119 on head 4110e4e3; typecheck exit 0 x10 packages; dispatch-gates --ran: 116 derived, 116 run, 0 NOT-MEASURED (check-plugin-teardown-shape --self-test exit 0 after fetching its base commit in the shallow clone; first run exit 3 from shallow history). CI check-runs on 4110e4e3: pending at report time.",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "line_budget": "45 files, +654 -28 = 682 < 3000",
      "files_changed": 45,
      "deviations": "Seat-accepted extra surface (comment 6670430008): spec hook.zod.ts provenance, automation-service AutomationContext, trigger-record-change, service-automation runtime-identity + run-resume, runtime sandbox, CLI pin. Agent writes (performed_by) not rendered by the new console line.",
      "mcp_calls": "4 — mcp__github__issue_read x2, mcp__github__pull_request_read x1 (reads only); subscribe_pr_activity x1 (session subscription, not a GitHub write)",
      "api_writes": "3 — git push (branch), relay pr_create (#30800, read-back identical 9906/9906 bytes), relay post-stamped comment on #30213",
      "open_questions": [
        {
          "question": "Comments write no audit/activity row (sys_comment in SKIP_OBJECTS), so an impersonated comment is indistinguishable. Which way?",
          "options": [
            "A — stop skipping sys_comment in the audit mirror: comments get ledger + activity rows with the stamp; every comment writes two more rows",
            "B — stamp on the comment row: sys_comment has no metadata/JSON field, so it needs a column + migration, excluded by ruling A; needs its own ruling",
            "C — leave as is, record a known gap in the ruling follow-up"
          ],
          "recommendation": "A, because it reuses the exact writer this PR stamps, needs no schema change, and gives comments the same ledger every other write has; cost is two rows per comment (S scope, one follow-up card)"
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  9. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

    Claim amendment · claim 6670267078 · 2026-11-06T03:55Z

    Declarations corrected from the gate's measurement on PR #30800: Clause-②: yes (widening), Metadata-protocol: added. ExecutionContext.impersonatedBy enters the packages/spec/authorable-surface/kernel.json shard, and check-changeset-no-major reported none-baseline-moved. @objectstack/spec takes minor. The changeset (head 465a5099) and the PR body carry both lines. The contract review stays at CONTRACT_REVIEW_TIER (schema face).

    Everything else in claim 6670267078 (as amended 6670430008) stands.

  10. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

    Landing record · 2026-11-06T06:39Z · PR #30800 merged through the queue as fd749a45e (ACCEPT on head 9d945da8; contract review FAIL 6676544432 on 4110e4e3, fixed; delta re-review PASS 6677397495 on 9d945da8). Ruling A has landed: a write made while impersonating stamps the administrator as attribution only (sys_audit_log.metadata.impersonated_by, sys_activity impersonated_by + impersonated_by_name, the action_invocation row, and flows the write starts), and the console shows the "on behalf of" line. Follow-ups: #30851 (plugin-auth identity writes; the approval resume decider segment). The sys_comment fork (A/B/C) stays with the maintainer. Card closed completed by Fixes; pm:dispatched removed in this stroke.

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

Metadata

Metadata

Assignees

Labels

domain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions