diff --git a/.changeset/30213-impersonation-write-attribution.md b/.changeset/30213-impersonation-write-attribution.md new file mode 100644 index 00000000000..b3ad03f9f48 --- /dev/null +++ b/.changeset/30213-impersonation-write-attribution.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': patch +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/plugin-audit': patch +'@objectstack/trigger-record-change': patch +'@objectstack/service-automation': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +feat(audit): a write made while an administrator impersonates a user now records the administrator + +Clause-②: yes (widening) +Metadata-protocol: added + +An administrator who impersonates a user writes as that user, and until now nothing on the write said otherwise: the `sys_audit_log` row, the `sys_activity` row and the record were byte-identical to the user's own work. The session already knew (`sys_session.impersonated_by`); the write did not. + +- `ExecutionContext.impersonatedBy` (new, optional, attribution only) carries the administrator's `sys_user` id. The shared request-identity resolution sets it from the session read that admitted the caller, for a session credential only. `HookContext.provenance.impersonatedBy` hands it to hooks; it is never folded into `session`, so a hook that gates on the caller still sees the impersonated user. +- `userId`, `created_by`, ownership, `current_user.*` RLS and every permission stay the impersonated user's. Nothing in the authorization path reads the new field. +- The audit writer stamps `sys_audit_log.metadata.impersonated_by` on record writes and on `action_invocation` rows, and `impersonated_by` plus `impersonated_by_name` in the `sys_activity` row's metadata. Each key is present only on such a write; its absence means the user acted themselves. `user_id` and `actor` do not move. +- A flow started from such a session carries the administrator onto its own writes (`AutomationContext.impersonatedBy`), on both `runAs` arms: a record-triggered flow, a manual `POST /:name/trigger` and a `type: 'flow'` action alike. After an approval decision the decider's segment carries none. +- The console's record timeline and field history read 「Dev Admin 代王工操作」 (`detail.impersonatedActor`, in all ten console languages). + +No schema column and no migration: older rows simply carry no key. diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index afb5a090f80..e9784d337a4 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -59,6 +59,7 @@ const result = BaseEngineOptionsSchema.parse(data); | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -114,6 +115,7 @@ Options for DataEngine.aggregate operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -167,6 +169,7 @@ Options for DataEngine.count operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -221,6 +224,7 @@ Options for DataEngine.delete operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -297,6 +301,7 @@ Options for DataEngine.insert operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -356,6 +361,7 @@ Query options for IDataEngine.find() operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -441,6 +447,7 @@ Options for DataEngine.update operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -515,6 +522,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -601,6 +609,7 @@ QueryAST-aligned options for DataEngine.count operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -668,6 +677,7 @@ QueryAST-aligned options for DataEngine.delete operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -731,6 +741,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -822,6 +833,7 @@ QueryAST-aligned options for DataEngine.update operations | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index e31c216d763..a63f3a548ca 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -105,7 +105,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **submitted** | `Record` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image | | **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced) | | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | -| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | +| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string; impersonatedBy?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup, tree or user field that references a record being deleted, where a user field takes part only when it declares its deleteBehavior). Absent on every other write, including a user hand-clearing the same field; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. | | **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. | | **transaction** | `any` | optional | Database transaction handle | @@ -145,6 +145,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **flowRunId** | `string` | optional | Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request. | | **attributedUserId** | `string` | optional | The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as. | | **performedByClientId** | `string` | optional | [ADR-0090 D10 rule 4] The AGENT that performed this write — the OAuth client (`azp`, a registered sys_oauth_application id) the access token was issued to, when an AI client acted on behalf of the human in `session.userId`. Copied from `ExecutionContext.performedBy.clientId`, which only the /mcp OAuth door sets. ATTRIBUTION ONLY, exactly like `attributedUserId`: the audit writer records it in `sys_audit_log.metadata` as `performed_by` beside `on_behalf_of`; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every non-delegated write, so a hook tells the two apart by the key being there rather than by guesswork. | +| **impersonatedBy** | `string` | optional | The ADMINISTRATOR (a sys_user id) who made this write from an impersonation session, while `session.userId` is the impersonated user the write is authorized as. Copied from `ExecutionContext.impersonatedBy`, which the shared request-identity resolution sets from the session read. ATTRIBUTION ONLY, exactly like `performedByClientId`: the audit writer records it as `sys_audit_log.metadata.impersonated_by` and on the sys_activity row; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every write the user made themselves. | ### Nested Shape: `HookContext.user` diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index b23a2479d10..c2cf22e1e20 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -65,6 +65,7 @@ const result = AuthGateCodeSchema.parse(data); | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | | **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | +| **impersonatedBy** | `string` | optional | The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/packages/cli/test/lowered-body-face-enumeration.test.ts b/packages/cli/test/lowered-body-face-enumeration.test.ts index f849e2b64c0..c985e496b13 100644 --- a/packages/cli/test/lowered-body-face-enumeration.test.ts +++ b/packages/cli/test/lowered-body-face-enumeration.test.ts @@ -280,8 +280,8 @@ describe('the lowered body face, against the in-process HookContext (#22837)', ( expect(body.get('dispatch')).toEqual(new Set(['mode', 'index'])); expect(body.get('session')).toBe('passthrough'); // [#25905] Carried member by member, so a member the spec adds to it later is seen as absent. - expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId'])); - expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId'])); + expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy'])); + expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId', 'impersonatedBy'])); // A tombstone is no member: `session.roles` is `retiredKey(…)`. expect(inProcess.get('session')).not.toContain('roles'); expect(absent.length).toBeGreaterThan(0); diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index b4d9d42fbcb..fc16442279e 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -629,3 +629,26 @@ describe('a guest request is a named caller, never an empty context', () => { }); } }); + +describe('impersonatedBy — the administrator of an impersonation session, carried as attribution only', () => { + const IMPERSONATED: ResolvedAuthzContext = { ...HUMAN_MINIMAL, impersonatedBy: 'u_admin' }; + const assembly = (authz: ResolvedAuthzContext) => ({ + authz, oauth: undefined, localization: undefined, requestLocale: undefined, accessToken: undefined, authGate: undefined, + }); + + it('both entries carry it, beside a subject that stays the impersonated user', () => { + for (const ctx of [assembleExecutionContext(assembly(IMPERSONATED)), assembleExecutionContextOrGuest(assembly(IMPERSONATED))]) { + expect(ctx?.impersonatedBy).toBe('u_admin'); + expect(ctx?.userId).toBe(HUMAN_MINIMAL.userId); + expect(ctx?.positions).toEqual(HUMAN_MINIMAL.positions); + expect(ExecutionContextSchema.parse(ctx).impersonatedBy).toBe('u_admin'); + } + }); + + it('is absent for the user\'s own session and never on a guest envelope', () => { + expect('impersonatedBy' in assembleExecutionContext(assembly(HUMAN_MINIMAL))!).toBe(false); + const guest = assembleExecutionContextOrGuest(assembly({ ...ANONYMOUS, impersonatedBy: 'u_admin' })); + expect(guest?.principalKind).toBe('guest'); + expect('impersonatedBy' in guest!).toBe(false); + }); +}); diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index 442040f156d..63d4d69fce6 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -147,6 +147,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [ 'principalKind', 'onBehalfOf', 'performedBy', + 'impersonatedBy', 'audience', 'userId', 'tenantId', @@ -354,6 +355,13 @@ function entryFields( // what narrows the optional away — the guard that made `agent` truthy is // this same property. performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined, + // The administrator who started the impersonation session that admitted + // `userId` (the resolver's session arm reads `session.impersonatedBy`). + // Attribution, not authorization, in the shape `performedBy` uses: the + // subject stays the impersonated user above, whose grants the envelope + // carries, and no security middleware reads this. An anonymous entry has + // no session to have been minted for anyone. + impersonatedBy: anonymous ? undefined : authz.impersonatedBy, // [ADR-0090 D10/D11 · ruling of record 6355206139, letter B] The resolver's // population stamp: `'external'` for a holder of `org_external`, absent // (read as 'internal') otherwise. It rides an AGENT envelope too: the diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 714ff6b5aa5..8b71decc13e 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -165,6 +165,31 @@ describe('resolveAuthzContext — single source of truth', () => { expect(ctx.permissions).toContain('ai_seat'); }); + it('carries the impersonating administrator off the session read, and resolves the impersonated user\'s grants', async () => { + const ql = makeQl({ + sys_member: [{ user_id: 'u1', organization_id: 'o1', role: 'member' }], + sys_user_position: [], + }); + const impersonation = async () => ({ + user: { id: 'u1' }, + session: { userId: 'u1', activeOrganizationId: 'o1', impersonatedBy: 'u_admin' }, + }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: impersonation }); + expect(ctx.userId).toBe('u1'); + expect(ctx.credential).toBe('session'); + expect(ctx.impersonatedBy).toBe('u_admin'); + expect(ctx.positions).toContain('org_member'); + + // Control: the user's own session names no administrator; a null mark is no mark. + const own = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + expect('impersonatedBy' in own).toBe(false); + const cleared = await resolveAuthzContext({ + userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), + getSession: async () => ({ user: { id: 'u1' }, session: { activeOrganizationId: 'o1', impersonatedBy: null } }), + }); + expect('impersonatedBy' in cleared).toBe(false); + }); + it('anonymous (no session, no api key) → empty context', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.userId).toBeUndefined(); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index d7b28e61cd2..4786d92597a 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -159,6 +159,19 @@ export interface ResolvedAuthzContext { * ({@link authGate}), never by a cookie that rode along beside the key. */ credential?: 'api_key' | 'session'; + /** + * The administrator who started the impersonation session that admitted + * {@link userId} — `session.impersonatedBy` off the same session read + * (better-auth's admin plugin; `sys_session.impersonated_by`). Set only when + * {@link credential} is `'session'`; absent for an API key and for every + * session the user started themselves. + * + * ATTRIBUTION ONLY: carried onto `ExecutionContext.impersonatedBy` so the + * audit writer can name the person at the keyboard. Nothing in this + * resolver's grant aggregation reads it — {@link userId} stays the + * impersonated user, whose grants the request resolves. + */ + impersonatedBy?: string; /** * [ADR-0069 · #23287, ruling C′ item 3] The principal's authentication-policy * posture, decided once here for whichever credential admitted it: @@ -604,6 +617,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { + const row = buildActionInvocationAuditRow({ + ...CLICK, + context: { ...CLICK.context, impersonatedBy: 'usr_admin' } as any, + }); + expect(row.user_id).toBe('usr_1'); + expect(row.actor).toBe('usr_1'); + expect(JSON.parse(String(row.metadata))).toEqual({ + surface: 'rest', action_name: 'close_case', branch: 'update', outcome: 'succeeded', + impersonated_by: 'usr_admin', + }); + // Control: the user's own click claims no impersonation. + expect(JSON.parse(String(buildActionInvocationAuditRow(CLICK).metadata))).not.toHaveProperty('impersonated_by'); + }); + it('an object-less action names no object and no record', () => { const row = buildActionInvocationAuditRow({ ...CLICK, objectName: undefined, recordId: 'r1' }); expect(row.object_name).toBeNull(); diff --git a/packages/plugins/plugin-audit/src/action-invocation-audit.ts b/packages/plugins/plugin-audit/src/action-invocation-audit.ts index 190de7104f5..8bc57a8d85f 100644 --- a/packages/plugins/plugin-audit/src/action-invocation-audit.ts +++ b/packages/plugins/plugin-audit/src/action-invocation-audit.ts @@ -68,6 +68,7 @@ import { createFieldPresenceProbe, } from './audit-writers.js'; import type { AuthEventAuditLogger } from './auth-event-audit.js'; +import { IMPERSONATED_BY_KEY, impersonatorOf } from './impersonation-attribution.js'; /** The `sys_audit_log.action` value this writer emits. */ export const ACTION_INVOCATION_AUDIT_ACTION = 'action_invocation' as const; @@ -90,9 +91,11 @@ export interface ActionInvocationAuditEvent { /** * The invocation's envelope: the caller's `ExecutionContext` with * `surface` set by the door. Read for `surface`, `userId`, `actor`, - * `tenantId` and `performedBy` — nothing else. + * `tenantId`, `performedBy` and `impersonatedBy` — nothing else. */ - context: Pick | undefined; + context: + | Pick + | undefined; /** The action's declared name (the routed name for an undeclared one). */ actionName: string; /** The action's object; absent for an object-less (global) action. */ @@ -180,6 +183,10 @@ export function buildActionInvocationAuditRow( metadata.performed_by = performedBy; metadata.on_behalf_of = userId ?? null; } + // The impersonating administrator, under the key the CRUD mirror writes, + // present only when an impersonation session made the call. + const impersonatedBy = impersonatorOf(ctx); + if (impersonatedBy) metadata[IMPERSONATED_BY_KEY] = impersonatedBy; const objectName = nonEmpty(event.objectName); const row: Record = { diff --git a/packages/plugins/plugin-audit/src/activity-actor-name.test.ts b/packages/plugins/plugin-audit/src/activity-actor-name.test.ts index 0482993fd8b..185776e6bef 100644 --- a/packages/plugins/plugin-audit/src/activity-actor-name.test.ts +++ b/packages/plugins/plugin-audit/src/activity-actor-name.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; +import { assembleExecutionContext } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; @@ -49,6 +50,7 @@ const sysAuditLog = { user_id: f('user_id', 'text'), object_name: f('object_name', 'text'), record_id: f('record_id', 'text'), old_value: f('old_value', 'textarea'), new_value: f('new_value', 'textarea'), tenant_id: f('tenant_id', 'text'), + actor: f('actor', 'text'), metadata: f('metadata', 'textarea'), }, }; @@ -375,3 +377,103 @@ describe('[#22527] sys_activity.actor_avatar_url — the acting user\'s image, w expect(renamed.actor_avatar_url).toBe(GRACE_IMAGE); }); }); + +/** + * An administrator impersonating a user writes AS that user — `user_id`, + * `actor` and `actor_id` stay the user's, because the session is theirs — and + * both rows now also name the administrator, from the attribution-only + * `impersonatedBy` the request door carries. The envelope is built at the + * real door (`assembleExecutionContext`) from an authz context whose session + * arm read `impersonatedBy`, so a door that stops carrying it turns the first + * pin red, and the engine's provenance copy list is crossed on the way to the + * writer (this package resolves `@objectstack/objectql` through its `dist/`, + * so a change there reaches this suite after that package's build). + */ +describe('a write made from an impersonation session names the administrator beside the user', () => { + const AUTHZ = { + userId: ADA, + tenantId: 'org_1', + positions: ['member'], + permissions: [], + directPermissions: [], + systemPermissions: [], + org_user_ids: [ADA], + accessible_org_ids: ['org_1'], + }; + const envelope = (authz: Record) => + assembleExecutionContext({ + authz: authz as any, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }) as any; + + /** Grace (the administrator) impersonates Ada. */ + const impersonated = () => envelope({ ...AUTHZ, impersonatedBy: GRACE }); + /** Ada, in her own session. */ + const own = () => envelope({ ...AUTHZ }); + + const ledgerFor = (storeFor: (o: string) => Map>, recordId: string) => + Array.from(storeFor('sys_audit_log').values()).filter((r) => r.record_id === recordId); + const meta = (row: Record | undefined) => + row?.metadata == null ? null : (JSON.parse(String(row.metadata)) as Record); + + it('control — the door carries the administrator only for an impersonation session, and the subject stays the user', () => { + expect(impersonated().impersonatedBy).toBe(GRACE); + expect(impersonated().userId).toBe(ADA); + expect('impersonatedBy' in own()).toBe(false); + }); + + it('the ledger row records `impersonated_by`; `user_id` and `actor` stay the user', async () => { + const { engine, storeFor } = await boot(); + await engine.insert('biz_task', { id: 'i1', title: 'Fix ticket', status: 'open' }, { context: impersonated() }); + await engine.update('biz_task', { status: 'done' }, { where: { id: 'i1' }, context: impersonated() } as any); + + const rows = ledgerFor(storeFor, 'i1'); + expect(rows.map((r) => r.action)).toEqual(['create', 'update']); + for (const row of rows) { + expect(meta(row)).toEqual({ impersonated_by: GRACE }); + expect(row.user_id).toBe(ADA); + expect(row.actor).toBe(ADA); + } + }); + + it('the activity row carries the same fact, with the administrator\'s name beside it', async () => { + const { engine, storeFor } = await boot(); + await engine.insert('biz_task', { id: 'i2', title: 'Fix ticket', status: 'open' }, { context: impersonated() }); + + const [row] = activityFor(storeFor, 'i2'); + expect(row.actor_id).toBe(ADA); + expect(row.actor_name).toBe('Ada Lovelace'); + expect(meta(row)).toMatchObject({ impersonated_by: GRACE, impersonated_by_name: 'Grace Hopper' }); + }); + + it('the user\'s own write claims no impersonation, on either row — absence is the record', async () => { + const { engine, storeFor } = await boot(); + await engine.insert('biz_task', { id: 'i3', title: 'Own work', status: 'open' }, { context: own() }); + + const [ledger] = ledgerFor(storeFor, 'i3'); + expect(ledger.user_id).toBe(ADA); + expect(meta(ledger)).toBeNull(); + const [activity] = activityFor(storeFor, 'i3'); + expect(activity.actor_id).toBe(ADA); + expect(meta(activity)).not.toHaveProperty('impersonated_by'); + expect(meta(activity)).not.toHaveProperty('impersonated_by_name'); + }); + + it('the administrator never reaches a hook as the caller: the hook session is the user\'s, the mark rides provenance', async () => { + const { engine } = await boot(); + const seen: Array<{ session: unknown; provenance: unknown }> = []; + engine.registerHook('beforeInsert', async (ctx: any) => { + seen.push({ session: ctx.session, provenance: ctx.provenance }); + }, { object: 'biz_task' } as any); + await engine.insert('biz_task', { id: 'i4', title: 'Probe', status: 'open' }, { context: impersonated() }); + + expect(seen).toHaveLength(1); + expect((seen[0].session as any)?.userId).toBe(ADA); + expect(JSON.stringify(seen[0].session)).not.toContain(GRACE); + expect(seen[0].provenance).toEqual({ impersonatedBy: GRACE }); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts index ec778b87505..9e2b6b8dbd3 100644 --- a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts +++ b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts @@ -55,8 +55,8 @@ * administrator roster (`plugin-security`). None is a parent record's field * map, and narrowing them by a parent's field set would only delete their * own vocabulary. - * - `metadata` is not narrowed: the mirror records only the delegated-write - * attribution there. + * - `metadata` is not narrowed: the mirror records only attribution there — + * the delegated-write pair and the impersonating administrator. * * ## Fail closed * diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 1ac08c8b78d..4832a1b52ae 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -90,6 +90,7 @@ import { } from './activity-field-redaction.js'; import type { LedgerRecordWriteAction } from './audit-log-field-redaction.js'; import { SIGN_IN_STAMP_EVENT, isSignInStamp } from './sign-in-stamp.js'; +import { IMPERSONATED_BY_KEY, IMPERSONATED_BY_NAME_KEY, impersonatorOf } from './impersonation-attribution.js'; import type { MentionGateInput } from './mention-recipient-gate.js'; // [ADR-0139 E6] The ONE derivation of the two writer-stamped columns the // withheld-row rule reads, shared with the before-insert hook and the backfill. @@ -2068,6 +2069,14 @@ export function installAuditWriters( (ctx as any).provenance.performedByClientId.trim() ? (ctx as any).provenance.performedByClientId.trim() : undefined; + // The ADMINISTRATOR who made this write from an impersonation session. A + // FOURTH channel, for the same reason as the agent's: `session.userId` is + // the impersonated user — the session is theirs, the write authorizes as + // them and the record stays theirs — so without this the row was + // byte-identical to one that user wrote themselves. Stamped from + // `provenance`, never from `session`, because no caller-gating hook may + // read the administrator as the caller. + const impersonatedBy: string | undefined = impersonatorOf((ctx as any).provenance); // Principal label for attribution. Prefer the real user id; otherwise fall // back to a service/automation principal the host put on the context // (`ExecutionContext.actor`, e.g. `svc:`). This is what makes a @@ -2194,12 +2203,17 @@ export function installAuditWriters( // `user_id` column rather than from a second carrier: rule 4 asks for both // sides on the row, and one source for one fact is what keeps them from // ever disagreeing. - if (performedByClientId || signInStamp) { + // + // `impersonated_by` follows the same rule: present ONLY on a write made + // from an impersonation session, beside a `user_id` that stays the + // impersonated user. No key ⇒ that user acted themselves. + if (performedByClientId || impersonatedBy || signInStamp) { auditRow.metadata = safeStringify({ // A sign-in stamp names its event the way a `config_change` row does // (`metadata.event`); every other CRUD row still carries no event. ...(signInStamp ? { event: SIGN_IN_STAMP_EVENT } : {}), ...(performedByClientId ? { performed_by: performedByClientId, on_behalf_of: userId ?? null } : {}), + ...(impersonatedBy ? { [IMPERSONATED_BY_KEY]: impersonatedBy } : {}), }); } @@ -2345,10 +2359,21 @@ export function installAuditWriters( // rule, not data this writer persists. const actor: ActorSnapshot = mirrorsActivity && userId ? await resolveActorSnapshot(api, userId) : NO_ACTOR_SNAPSHOT; + // The impersonating administrator's name, off the same memoized read, so + // the timeline can say 「X 代 Y 操作」 without a lookup per row. The id is + // the fact; the name is a snapshot beside it, like `actor_name`. + const impersonator: ActorSnapshot = + mirrorsActivity && impersonatedBy ? await resolveActorSnapshot(api, impersonatedBy) : NO_ACTOR_SNAPSHOT; const activityMetadata = { old: oldValue, new: newValue, + ...(impersonatedBy + ? { + [IMPERSONATED_BY_KEY]: impersonatedBy, + ...(impersonator.name ? { [IMPERSONATED_BY_NAME_KEY]: impersonator.name } : {}), + } + : {}), ...(kind ? { [ACTIVITY_KIND_KEY]: kind.value } : {}), [ACTIVITY_TEXT_SOURCES_KEY]: textSources, ...(summaryMessage ? { [ACTIVITY_SUMMARY_MESSAGE_KEY]: summaryMessage } : {}), diff --git a/packages/plugins/plugin-audit/src/impersonation-attribution.ts b/packages/plugins/plugin-audit/src/impersonation-attribution.ts new file mode 100644 index 00000000000..5100539d589 --- /dev/null +++ b/packages/plugins/plugin-audit/src/impersonation-attribution.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Who was at the keyboard of an impersonation session — the metadata keys the + * audit writers stamp, in one place. + * + * An administrator who impersonates a user writes AS that user: the session is + * the user's, so `user_id`, `created_by`, ownership and every permission stay + * theirs. What the ledger could not say was that the user did not type it. The + * request-identity resolution now carries the administrator as the + * attribution-only `ExecutionContext.impersonatedBy` (the session's + * `impersonatedBy`), the engine hands it to hooks as + * `HookContext.provenance.impersonatedBy`, and the writers record it under + * these keys, in the additive shape an agent's write already uses + * (`performed_by` / `on_behalf_of`): + * + * - `sys_audit_log.metadata.impersonated_by` — the administrator's `sys_user` + * id, on the record-write row and on an action invocation's row; + * - `sys_activity.metadata.impersonated_by` — the same id on the timeline row, + * with `impersonated_by_name`, the administrator's name as the writer read + * it, beside it the way `actor_name` sits beside `actor_id`. + * + * Present ONLY on a write made from an impersonation session. The ABSENCE is + * the record: no key means the user in `user_id` acted themselves. ⛔ Neither + * key moves `user_id` or `actor` (ADR-0118 D1/D5 keeps `actor` two-valued and + * answers "who else acted" with an ADDED field), and nothing in the + * authorization path reads either. + */ + +/** The metadata key naming the impersonating administrator's `sys_user` id. */ +export const IMPERSONATED_BY_KEY = 'impersonated_by'; + +/** The `sys_activity` metadata key carrying that administrator's name snapshot. */ +export const IMPERSONATED_BY_NAME_KEY = 'impersonated_by_name'; + +/** + * The administrator an envelope names as the impersonator, or `undefined`. + * Read off whichever server-stamped carrier the caller holds — a hook's + * `provenance` or an `ExecutionContext` — and only as a non-blank string. + */ +export function impersonatorOf(carrier: { impersonatedBy?: unknown } | null | undefined): string | undefined { + const by = carrier?.impersonatedBy; + if (typeof by !== 'string') return undefined; + const trimmed = by.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index b60af4b9325..28c6dc1430c 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -2719,6 +2719,11 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // twin (`buildAutomationContext`): a refused run's message renders in // it. See `AutomationContext.locale`. ...(typeof ec?.locale === 'string' && ec.locale.length > 0 ? { locale: ec.locale } : {}), + // [#30213] The administrator an impersonation session names, the + // trigger door's twin (`buildAutomationContext`): attribution only, so + // the flow's own writes record who was at the keyboard. Absent on the + // user's own click. See `AutomationContext.impersonatedBy`. + ...(typeof ec?.impersonatedBy === 'string' && ec.impersonatedBy.trim() ? { impersonatedBy: ec.impersonatedBy.trim() } : {}), params: runParams, // [#19846] Which of those keys the CALLER supplied, stated here where it // is known rather than inferred downstream from the merged bag. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 11583f3df96..db295d8ec9c 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -230,6 +230,11 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext): // never re-derived, so a refused run's message renders in the language of // the console around it. `AutomationContext.locale` names its reader. if (typeof ec?.locale === 'string' && ec.locale.length > 0) automationContext.locale = ec.locale; + // [#30213] The administrator an impersonation session names — attribution + // only, forwarded so the run's writes record who was at the keyboard while + // `userId` stays the impersonated user. Absent on the user's own request. + // `AutomationContext.impersonatedBy` names its readers. + if (typeof ec?.impersonatedBy === 'string' && ec.impersonatedBy.trim()) automationContext.impersonatedBy = ec.impersonatedBy.trim(); return automationContext; } diff --git a/packages/runtime/src/flow-run-impersonated-by.test.ts b/packages/runtime/src/flow-run-impersonated-by.test.ts new file mode 100644 index 00000000000..e6ce1bbceab --- /dev/null +++ b/packages/runtime/src/flow-run-impersonated-by.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two flow doors forward the impersonating administrator — + * `AutomationContext.impersonatedBy` (#30213, ruling A). + * + * An administrator who impersonates a user acts AS that user: the request's + * `ExecutionContext.userId` is the user, and `ExecutionContext.impersonatedBy` + * names the administrator (attribution only, set by `resolveAuthzContext` from + * the session). A door that builds a run's context by picking identity keys off + * the envelope by name drops any key it does not pick, so the run's writes + * would read as the user's own while the same click's `action_invocation` row + * names the administrator. + * + * What this file pins — the PRODUCER half, at both doors. The consumer half + * (the run's data nodes carry `impersonatedBy` onto every write, on both + * `runAs` arms, and a decider's segment drops it) is + * `resume-decider-identity.integration.test.ts` in + * `@objectstack/service-automation`; the audit writer stamping it is + * `activity-actor-name.test.ts` in `@objectstack/plugin-audit`. + * + * 1. The trigger door (`buildAutomationContext`) forwards it, and both of its + * routes hand it to the engine; `userId` stays the impersonated user. + * 2. The action door (`dispatchFlowAction`) forwards it. + * 3. CONTROL: the user's own request adds no key, and a blank value adds none. + * 4. The request body cannot name one: a body key `impersonatedBy` stays a + * flow param. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { dispatchFlowAction, loadActionSubjectRecord } from './action-execution.js'; +import { buildAutomationContext } from './domains/automation.js'; + +const FLOW_NAME = 'quote_generation'; +const ADMIN = 'usr_admin'; + +const QUOTE = { + name: 'generate_quote', + label: 'Generate quote', + objectName: 'crm_opportunity', + type: 'flow', + target: FLOW_NAME, +}; + +function ec(impersonatedBy?: string) { + return { + userId: 'usr_wang', tenantId: 'org_1', positions: ['sales_rep'], permissions: ['sales_user'], systemPermissions: [], + ...(impersonatedBy !== undefined ? { impersonatedBy } : {}), + }; +} + +function makeAutomation() { + const execute = vi.fn(async (_flow: string, _context?: any) => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null)); + return { execute, getFlow }; +} +const contextOf = (automation: { execute: any }) => automation.execute.mock.calls[0]?.[1]; +const flowDeps = (automation: any): any => ({ + resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined), +}); +const REQUEST: any = { request: {}, environmentId: 'platform' }; + +describe('[#30213] the trigger door forwards the impersonating administrator', () => { + it('`buildAutomationContext` carries `executionContext.impersonatedBy`; `userId` stays the impersonated user', () => { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec(ADMIN) } as any, + ); + expect(ctx).toMatchObject({ userId: 'usr_wang', permissions: ['sales_user'], impersonatedBy: ADMIN }); + }); + + it('CONTROL: the user\'s own request, a blank value and an anonymous request add no key', () => { + for (const executionContext of [ec(), ec(' '), undefined]) { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, ...(executionContext ? { executionContext } : {}) } as any, + ); + expect('impersonatedBy' in ctx).toBe(false); + } + }); + + it('the body cannot name one: a flat `impersonatedBy` key stays a flow param', () => { + const flat: any = buildAutomationContext({ impersonatedBy: 'usr_forged' }, { request: {}, executionContext: ec() } as any); + expect('impersonatedBy' in flat).toBe(false); + expect(flat.params.impersonatedBy).toBe('usr_forged'); + }); + + for (const route of [ + { label: 'POST /:name/trigger', path: `/${FLOW_NAME}/trigger` }, + { label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW_NAME}` }, + ]) { + for (const impersonated of [true, false]) { + it(`${route.label} ${impersonated ? 'hands the engine the administrator' : 'CONTROL: hands the engine no key on the user\'s own request'}`, async () => { + const automation = makeAutomation(); + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const resolve = (n: string) => (n === 'automation' ? automation : n === 'protocol' ? protocol : undefined); + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + await new HttpDispatcher(kernel).handleAutomation( + route.path, 'POST', + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec(impersonated ? ADMIN : undefined) } as any, + ); + expect(automation.execute).toHaveBeenCalledTimes(1); + const ctx = contextOf(automation); + expect(ctx.userId).toBe('usr_wang'); + if (impersonated) expect(ctx.impersonatedBy).toBe(ADMIN); + else expect('impersonatedBy' in ctx).toBe(false); + }); + } + } +}); + +describe('[#30213] the action door forwards the impersonating administrator', () => { + it('`dispatchFlowAction` carries `ec.impersonatedBy`; `userId` stays the impersonated user', async () => { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec(ADMIN), envId: 'platform', + }); + expect(contextOf(automation)).toMatchObject({ userId: 'usr_wang', permissions: ['sales_user'], impersonatedBy: ADMIN }); + }); + + it('CONTROL: the user\'s own click and a blank value add no key', async () => { + for (const value of [undefined, ' ']) { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec(value), envId: 'platform', + }); + expect('impersonatedBy' in contextOf(automation)).toBe(false); + } + }); +}); diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 333dac92ac5..26867d3a279 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -1271,6 +1271,7 @@ function buildSandboxContext( flowRunId: provenanceRaw?.flowRunId, attributedUserId: provenanceRaw?.attributedUserId, performedByClientId: provenanceRaw?.performedByClientId, + impersonatedBy: provenanceRaw?.impersonatedBy, }); return { diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 17b87329125..f64f89a9edb 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -288,7 +288,7 @@ export interface ScriptContext { * middleware reads it, and neither does anything a body writes back. * Absent on the action face. */ - provenance?: { flowRunId?: string; attributedUserId?: string; performedByClientId?: string }; + provenance?: { flowRunId?: string; attributedUserId?: string; performedByClientId?: string; impersonatedBy?: string }; /** * The caller's options bag, PROJECTED to the two members ADR-0058's D2 * declares visible to the `before*` phase — `multi` and `where` — for the diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts index 9415abc159d..5d3cc6bc9e2 100644 --- a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -389,6 +389,33 @@ const dealManagerSet = PermissionSetSchema.parse({ fields: { 'deal.restricted_note': { readable: true, editable: false } }, }); +describe('a run started by an impersonation session\'s write names the administrator on its writes, until a decider takes over', () => { + for (const runAs of ['user', 'system'] as const) { + it(`runAs:'${runAs}': every write before the decision carries the administrator; the decider's segment carries none`, async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true, runAs }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER, impersonatedBy: 'usr_admin' } as never); + expect(paused.status).toBe('paused'); + + const before = h.ctxOf('submit'); + expect(before.userId, 'the subject stays the impersonated user').toBe('usr_rep'); + expect(before.impersonatedBy).toBe('usr_admin'); + + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + const after = h.ctxOf('after1'); + expect(after.userId).toBe('usr_mgr'); + expect('impersonatedBy' in after, 'the decider acted in their own name').toBe(false); + }); + } + + it('CONTROL: a run the user\'s own write started names no administrator', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + await h.engine.execute('review', { ...TRIGGER } as never); + expect('impersonatedBy' in h.ctxOf('submit')).toBe(false); + }); +}); + describe('on the real stack: attribution after the decision, and the refusal when the decider lacks a right', () => { let kernel: ObjectKernel; let ql: ObjectQL; diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index d14a476b100..2090f54fcc1 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -338,6 +338,10 @@ async function deciderSegmentContext( // platform administrator's submission would hand a member's decision // segment the deployment-level rows. Seeded for the decider below. delete switched.posture; + // The impersonating administrator belonged to the write that STARTED the + // run: the decider is a different person acting in their own name, so the + // segment they own names no impersonator. + delete switched.impersonatedBy; // The trigger user's email is theirs too: kept under the decider's id, // it would admit the decider to every row an email-keyed policy grants // the submitter. Removed on both postures, and seeded for the decider diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 64719eecad7..8ea2838dc4e 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -101,6 +101,14 @@ export interface RunIdentityContext { * state that run itself opened (the approvals record lock). */ flowRunId?: string; + /** + * The administrator who made the write that started this run from an + * impersonation session ({@link AutomationContext.impersonatedBy}), carried + * as the kernel `ExecutionContext.impersonatedBy` so the audit writer names + * them on the run's writes. Attribution only, like {@link flowRunId}: it + * neither widens nor narrows what the run may touch. + */ + impersonatedBy?: string; } /** @@ -330,6 +338,13 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R // The starter's language rides into every write, on both arms — see // RunDataContext.locale. const locale = typeof context?.locale === 'string' && context.locale.length > 0 ? context.locale : undefined; + // The administrator at the keyboard of the impersonation session whose write + // started this run, onto every write the run makes, on both arms — see + // RunIdentityContext.impersonatedBy. + const impersonatedBy = + typeof context?.impersonatedBy === 'string' && context.impersonatedBy.trim() + ? context.impersonatedBy.trim() + : undefined; if (context?.runAs === 'system') { // Name the flow as the acting service principal (`svc:flow:`, // ADR-0014 D2) — the audit writer falls back to `session.actor` when @@ -385,6 +400,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R ...(context.userId ? {} : { positions: [], permissions: [] }), ...(flowRunId ? { flowRunId } : {}), ...(locale ? { locale } : {}), + ...(impersonatedBy ? { impersonatedBy } : {}), }); } if (!context?.userId) { @@ -419,6 +435,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R if (context.tenantId) out.tenantId = context.tenantId; if (flowRunId) out.flowRunId = flowRunId; if (locale) out.locale = locale; + if (impersonatedBy) out.impersonatedBy = impersonatedBy; return out; } diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 75f06818841..4d3f396d921 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -176,6 +176,7 @@ "kernel/ExecutionContext:currency", "kernel/ExecutionContext:email", "kernel/ExecutionContext:flowRunId", + "kernel/ExecutionContext:impersonatedBy", "kernel/ExecutionContext:isSystem", "kernel/ExecutionContext:keptOptionValues", "kernel/ExecutionContext:locale", diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index a5d825bc543..9fe3719c577 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -297,6 +297,24 @@ export interface AutomationContext { * Callers do NOT set this — the engine derives it, exactly like {@link runAs}. */ flowRunId?: string; + /** + * The administrator (a `sys_user` id) at the keyboard when the request or + * write that started this run came from an impersonation session. Every + * door that starts a run from an envelope carrying it sets it: the + * record-change trigger from the triggering hook's + * `provenance.impersonatedBy`, and the manual trigger route and a + * `type: 'flow'` action from the caller's `ExecutionContext.impersonatedBy`. + * The run's data nodes carry it onto every write as + * `ExecutionContext.impersonatedBy`, so the audit writer names the + * administrator on the run's writes as on the request that started it. + * + * ATTRIBUTION ONLY: {@link userId} stays the impersonated user, whose + * grants a `runAs:'user'` run resolves, and nothing in the authorization + * path reads this. Absent on every run a user's own write started. Cleared + * when an approval decision hands the run to the decider, who is not + * impersonated. + */ + impersonatedBy?: string; /** Additional contextual data */ params?: Record; /** diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 5ef1a207d66..1117203a194 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -1378,14 +1378,17 @@ export const HookContextSchema = lazySchema(() => z.object({ * or the audit writer — can tell "the run / the person this write belongs * to" from "an unrelated caller". * - * Three marks, none authorizing, for three different questions: + * Four marks, none authorizing, for four different questions: * - `flowRunId` — WHAT produced the write (a machine origin, no person); * - `attributedUserId` — WHO is CREDITED for a write the system authorized * (a person, but never the subject the write was authorized as); * - `performedByClientId` — WHICH AGENT performed a write authorized as the * human in `session.userId` (ADR-0090 D10 rule 4's `performed_by`; the * `on_behalf_of` half is that same `session.userId`, so it is not carried - * a second time here). + * a second time here); + * - `impersonatedBy` — WHICH ADMINISTRATOR made a write from an + * impersonation session, authorized as the impersonated user in + * `session.userId`. * * Kept OUT of `session` on purpose. A writer can have provenance and no * identity at all — a schedule-triggered flow run resolves no principal — and @@ -1400,6 +1403,7 @@ export const HookContextSchema = lazySchema(() => z.object({ flowRunId: z.string().optional().describe('Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request.'), attributedUserId: z.string().optional().describe('The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as.'), performedByClientId: z.string().optional().describe('[ADR-0090 D10 rule 4] The AGENT that performed this write — the OAuth client (`azp`, a registered sys_oauth_application id) the access token was issued to, when an AI client acted on behalf of the human in `session.userId`. Copied from `ExecutionContext.performedBy.clientId`, which only the /mcp OAuth door sets. ATTRIBUTION ONLY, exactly like `attributedUserId`: the audit writer records it in `sys_audit_log.metadata` as `performed_by` beside `on_behalf_of`; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every non-delegated write, so a hook tells the two apart by the key being there rather than by guesswork.'), + impersonatedBy: z.string().optional().describe('The ADMINISTRATOR (a sys_user id) who made this write from an impersonation session, while `session.userId` is the impersonated user the write is authorized as. Copied from `ExecutionContext.impersonatedBy`, which the shared request-identity resolution sets from the session read. ATTRIBUTION ONLY, exactly like `performedByClientId`: the audit writer records it as `sys_audit_log.metadata.impersonated_by` and on the sys_activity row; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every write the user made themselves.'), }).optional().describe('Server-stamped write provenance (never client-supplied, never an authorization input)'), /** diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 71105d8adb3..ea0f5b26601 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -349,6 +349,35 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ surface: z.enum(['rest', 'mcp']).optional().describe('The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge\'s run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door\'s own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row.'), + /** + * WHO started the impersonation session this request runs in — the + * administrator's `sys_user` id, when better-auth's admin plugin minted the + * session for another user (`session.impersonatedBy`, stored as + * `sys_session.impersonated_by`). Absent on every other request, and the + * ABSENCE is the record: a write carrying no `impersonatedBy` was made by + * the user in {@link userId} themselves. + * + * The subject does not move: {@link userId} stays the impersonated user, so + * ownership stamps (`created_by`), `current_user.*` RLS and every permission + * still resolve to them — the session IS theirs, which is what impersonation + * means. This field only names the person at the keyboard, in the same + * additive shape {@link performedBy} names an agent. + * + * **ATTRIBUTION ONLY**, exactly like {@link attributedUserId} and + * {@link performedBy}: nothing in the authorization path reads it, it widens + * and narrows nothing, and it never becomes the subject the write is + * authorized as. Server-constructed only, never client-supplied — set by the + * shared request-identity resolution from the session read that admitted + * the caller, and only for a session credential (an API key carries none). + * + * Surfaced to hooks as `HookContext.provenance.impersonatedBy`, deliberately + * NOT folded into `session`: a hook that gates on the caller must keep seeing + * the impersonated user. Recorded by the audit writer as + * `sys_audit_log.metadata.impersonated_by` and on the `sys_activity` row's + * metadata; it does not move `actor` or `user_id` (ADR-0118 D1/D5). + */ + impersonatedBy: z.string().optional().describe('The administrator (a sys_user id) who started the impersonation session this request runs in — better-auth admin plugin session.impersonatedBy, stored as sys_session.impersonated_by. Absent on every other request; absence is the record that the user in userId acted themselves. userId stays the impersonated user, so ownership, current_user.* RLS and permissions still resolve to them. ATTRIBUTION ONLY — no security middleware reads it and it never becomes the authorization subject. Server-constructed only from the session read that admitted the caller, never client-supplied. Surfaced to hooks as HookContext.provenance.impersonatedBy and recorded by the audit writer as sys_audit_log.metadata.impersonated_by and on the sys_activity row metadata; it does not move actor or user_id.'), + /** Aggregated permission names (resolved from PermissionSet) */ permissions: z.array(z.string()).default([]), diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts index 0b89418f722..6db3b568028 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts @@ -148,6 +148,23 @@ describe('RecordChangeTrigger', () => { expect(hooks[0].packageId).toBe('com.objectstack.trigger.record-change:task_assigned_notify'); }); + it('hands the run the impersonating administrator off the write\'s provenance, beside the impersonated user', async () => { + const { engine, hooks } = fakeEngine(); + const trigger = new RecordChangeTrigger(engine, silentLogger()); + const seen: AutomationContext[] = []; + trigger.start(binding(), async (ctx) => { seen.push(ctx); }); + + await hooks[0].handler(hookCtx({ provenance: { impersonatedBy: 'u_admin' } } as Partial)); + // Control: the user's own write starts a run that names no administrator. + await hooks[0].handler(hookCtx()); + + expect(seen).toHaveLength(2); + expect(seen[0].userId).toBe('u9'); + expect(seen[0].impersonatedBy).toBe('u_admin'); + expect(seen[1].userId).toBe('u9'); + expect('impersonatedBy' in seen[1]).toBe(false); + }); + it('does not register a hook for an unsupported trigger event', () => { const { engine, hooks } = fakeEngine(); const trigger = new RecordChangeTrigger(engine, silentLogger()); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index a2b3208839e..00f0a8f3bf5 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -602,6 +602,12 @@ export class RecordChangeTrigger implements FlowTrigger { // alias was removed in v16, #3290); it feeds the automation context's // driver-layer `tenantId` field unchanged. ...(session.organizationId ? { tenantId: session.organizationId } : {}), + // The administrator at the keyboard when the triggering write came + // from an impersonation session, read off the write's PROVENANCE + // (never its session: the session is the impersonated user's), so + // the run's own writes name them too. Attribution only — the run + // still resolves `userId`'s grants, and no gate reads it. + ...impersonatedByOf(ctx), // [#28251] The write this run was dispatched inside, for its // `save_notice` nodes: the hook face of the write's own listener // (`ctx.notice`, bound by the engine on every insert / update @@ -797,3 +803,14 @@ export class RecordChangeTrigger implements FlowTrigger { return pending; } } + +/** + * The triggering write's impersonating administrator, as a context fragment: + * `{ impersonatedBy }` when the engine stamped `provenance.impersonatedBy` on + * the hook, `{}` otherwise — so a run started by a user's own write carries no + * key at all. + */ +function impersonatedByOf(ctx: { provenance?: { impersonatedBy?: unknown } }): { impersonatedBy?: string } { + const by = ctx.provenance?.impersonatedBy; + return typeof by === 'string' && by.trim() ? { impersonatedBy: by.trim() } : {}; +} diff --git a/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts index 836dda7b01b..18c0a120dd6 100644 --- a/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from 'vitest'; import { parseAuditValue, + auditImpersonatorId, collectAuditChanges, collectLookupIds, formatAuditValue, @@ -50,6 +51,18 @@ describe('parseAuditValue', () => { }); }); +describe('auditImpersonatorId', () => { + it('reads the administrator of an impersonation session off the row metadata', () => { + expect(auditImpersonatorId('{"impersonated_by":"u-admin"}')).toBe('u-admin'); + expect(auditImpersonatorId({ impersonated_by: 'u-admin', performed_by: 'cli' })).toBe('u-admin'); + }); + it("CONTROL: a row the user wrote themselves names no administrator", () => { + expect(auditImpersonatorId(null)).toBeNull(); + expect(auditImpersonatorId('{"performed_by":"cli","on_behalf_of":"u1"}')).toBeNull(); + expect(auditImpersonatorId('not json')).toBeNull(); + }); +}); + describe('collectAuditChanges', () => { it('keeps genuine field changes', () => { const changes = collectAuditChanges( diff --git a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts index 1f5e566314e..c1fd9e8a112 100644 --- a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts +++ b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts @@ -130,6 +130,18 @@ export function parseAuditValue(v: unknown): Record | null { return null; } +/** + * The administrator a `sys_audit_log` row says made its write from an + * impersonation session — `metadata.impersonated_by`, a `sys_user` id — or + * `null` for a write the user in `user_id` made themselves (the writer stamps + * the key on such writes only, so its absence is the record). + */ +export function auditImpersonatorId(metadata: unknown): string | null { + const parsed = parseAuditValue(metadata); + const by = parsed?.impersonated_by; + return typeof by === 'string' && by.trim() ? by.trim() : null; +} + /** * Union-diff two audit payloads into the changes worth showing. Fields whose * definition marks them computed or hidden are dropped, as are system/noise diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 0c251c23f34..5260a1be564 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef, type ReactElement } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RECORD_PAGE_TIMELINE_PANEL, RECORD_TIMELINE_PANEL_MAX_ENTRIES, RecordFieldChangesContext, RecordFieldHistoryContext, RecordTimelineTotalContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, composeImpersonatedActor, IMPERSONATED_ACTOR_FALLBACK, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RECORD_PAGE_TIMELINE_PANEL, RECORD_TIMELINE_PANEL_MAX_ENTRIES, RecordFieldChangesContext, RecordFieldHistoryContext, RecordTimelineTotalContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderPhoneContext, PageHeaderTrailingContext, PanelToggles, RecordTopBar, cn, useRecordStepKeys, type PanelToggleItem, type RecordTopBarStepper } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -86,7 +86,7 @@ import { // that import the app-shell barrel. import './record-approvals-renderer.js'; import { getRecordDisplayName } from '../utils/index.js'; -import { parseAuditValue, collectAuditChanges, collectLookupIds, formatAuditValue } from '../utils/auditHistoryDisplay.js'; +import { parseAuditValue, collectAuditChanges, collectLookupIds, formatAuditValue, auditImpersonatorId } from '../utils/auditHistoryDisplay.js'; import { useFavorites } from '../hooks/useFavorites.js'; import { RECORD_RELATED_COLUMN_PREFERENCE, @@ -485,10 +485,15 @@ function activityWindowQuery(objectName: string, recordId: string) { * `sys_comment`, or they are account events rather than record activity). * Everything else comes back as an item, including a type nobody has mapped. */ -function activityRowsToFeedItems(rows: readonly any[], systemActorLabel: string): FeedItem[] { +function activityRowsToFeedItems( + rows: readonly any[], + systemActorLabel: string, + impersonatedActorTemplate?: string, + unknownUserLabel?: string, +): FeedItem[] { const mapped: FeedItem[] = []; for (const row of rows) { - const item = activityRowToFeedItem(row, systemActorLabel); + const item = activityRowToFeedItem(row, systemActorLabel, impersonatedActorTemplate, unknownUserLabel); if (item) mapped.push(item); } return mapped; @@ -2794,7 +2799,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri $filter: { record_id: pureRecordId, object_name: objectDef.name }, $orderby: { created_at: 'desc' }, $top: 50, - $select: ['id', 'created_at', 'action', 'user_id', ...(hasActorColumn ? ['actor'] : []), 'old_value', 'new_value'], + $select: ['id', 'created_at', 'action', 'user_id', ...(hasActorColumn ? ['actor'] : []), 'old_value', 'new_value', 'metadata'], }) .then(async (res: any) => { if (cancelled) return; @@ -2802,10 +2807,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // 1) Resolve actor display names + avatars in a single batched call // so the timeline never falls back to a raw UUID. + // The administrator of an impersonation session rides the same + // read: the writer records them as `metadata.impersonated_by`. + const impersonators = items.map((it) => auditImpersonatorId(it?.metadata)); const userIds = Array.from( new Set( - items - .map((it) => it?.user_id) + [...items.map((it) => it?.user_id), ...impersonators] .filter((v): v is string => typeof v === 'string' && v.length > 0), ), ); @@ -2894,9 +2901,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri from: formatAuditValue(objectDef.fields?.[c.field], c.from, fmtCtx), to: formatAuditValue(objectDef.fields?.[c.field], c.to, fmtCtx), })); + // A write made from an impersonation session names the + // administrator beside the user: 「Dev Admin 代王工操作」. + const adminId = impersonators[idx]; + const userName = u?.name ?? actorLabel; + const adminName = adminId + ? (userMap.get(adminId)?.name ?? t('detail.unknownUser', { defaultValue: 'Unknown' })) + : null; return { ...it, - user_name: u?.name ?? actorLabel, + user_name: adminName + ? composeImpersonatedActor( + t('detail.impersonatedActor', { defaultValue: IMPERSONATED_ACTOR_FALLBACK, admin: '{{admin}}', user: '{{user}}' }), + adminName, + userName ?? t('detail.unknownUser', { defaultValue: 'Unknown' }), + ) + : userName, user_avatar: u?.image ?? null, changes: changes.length > 0 ? changes : undefined, }; @@ -3363,7 +3383,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return held === peek ? prev : { ...prev, [threadId]: peek }; }); if (!res?.data?.length) return; - const mapped = activityRowsToFeedItems(res.data, t('detail.systemActor', { defaultValue: 'System' })); + const mapped = activityRowsToFeedItems( + res.data, + t('detail.systemActor', { defaultValue: 'System' }), + t('detail.impersonatedActor', { defaultValue: IMPERSONATED_ACTOR_FALLBACK, admin: '{{admin}}', user: '{{user}}' }), + t('detail.unknownUser', { defaultValue: 'Unknown' }), + ); if (!mapped.length) return; // Merge by id into THIS record's slice (timeline events are // append-only); `mergeFeedRows` sorts by createdAt ascending so the @@ -3427,7 +3452,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // step to another record fills this one's slice, never the one on screen. Promise.resolve(dataSource.find('sys_activity', activityWindowQuery(objectName, pureRecordId))) .then((res: any) => { - const mapped = activityRowsToFeedItems(res?.data ?? [], t('detail.systemActor', { defaultValue: 'System' })); + const mapped = activityRowsToFeedItems( + res?.data ?? [], + t('detail.systemActor', { defaultValue: 'System' }), + t('detail.impersonatedActor', { defaultValue: IMPERSONATED_ACTOR_FALLBACK, admin: '{{admin}}', user: '{{user}}' }), + t('detail.unknownUser', { defaultValue: 'Unknown' }), + ); if (!mapped.length) return; setFeedItemsByRecord(prev => ({ ...prev, diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c7d4f69a3ed..160742cc7df 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1269,6 +1269,7 @@ const ar = { detail: { editInlineHint: "انقر نقرًا مزدوجًا للتحرير", systemActor: "النظام", + impersonatedActor: "{{admin}} نيابةً عن {{user}}", unknownUser: "غير معروف", created: "أُنشئ", updated: "حُدِّث", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8eacefe52c8..698b6a064e9 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1148,6 +1148,7 @@ const de = { detail: { editInlineHint: "Zum Bearbeiten doppelklicken", systemActor: "System", + impersonatedActor: "{{admin}} im Auftrag von {{user}}", unknownUser: "Unbekannt", back: "Zurück", edit: "Bearbeiten", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 038308ea631..afe34ac49dd 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1657,6 +1657,7 @@ const en = { daysAgo_other: '{{count}}d ago', // Activity feed actors systemActor: 'System', + impersonatedActor: '{{admin}} on behalf of {{user}}', unknownUser: 'Unknown', // Record meta footer (audit provenance). `created`/`updated` are the // actor-less variants — "Created by · 5m ago" dangles when created_by diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 296e74383fe..567197f3bb4 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1181,6 +1181,7 @@ const es = { detail: { editInlineHint: "Haga doble clic para editar", systemActor: "Sistema", + impersonatedActor: "{{admin}} en nombre de {{user}}", unknownUser: "Desconocido", back: "Volver", edit: "Editar", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index bfc8d5db061..ed6d4f92390 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1176,6 +1176,7 @@ const fr = { detail: { editInlineHint: "Double-cliquez pour modifier", systemActor: "Système", + impersonatedActor: "{{admin}} pour le compte de {{user}}", unknownUser: "Inconnu", created: "Créé", updated: "Mis à jour", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index d5b0ac5b3ea..7d3053328c0 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1148,6 +1148,7 @@ const ja = { detail: { editInlineHint: "ダブルクリックで編集", systemActor: "システム", + impersonatedActor: "{{admin}} が {{user}} として操作", unknownUser: "不明", back: "戻る", edit: "編集", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index a2fe8011f8f..f7cedb97129 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1148,6 +1148,7 @@ const ko = { detail: { editInlineHint: "더블클릭하여 편집", systemActor: "시스템", + impersonatedActor: "{{admin}}님이 {{user}}님 대신 작업", unknownUser: "알 수 없음", back: "뒤로", edit: "편집", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index c50265a414e..a1c0fc7fcb3 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1176,6 +1176,7 @@ const pt = { detail: { editInlineHint: "Clique duas vezes para editar", systemActor: "Sistema", + impersonatedActor: "{{admin}} em nome de {{user}}", unknownUser: "Desconhecido", created: "Criado", updated: "Atualizado", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 0cab7d5abad..d447427c55e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1210,6 +1210,7 @@ const ru = { detail: { editInlineHint: "Дважды щёлкните для редактирования", systemActor: "Система", + impersonatedActor: "{{admin}} от имени {{user}}", unknownUser: "Неизвестно", created: "Создано", updated: "Обновлено", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index a91341eb7c9..b6f5f9c9e4d 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1380,6 +1380,7 @@ const zh = { daysAgo_other: '{{count}}天前', // Activity feed actors systemActor: '系统', + impersonatedActor: '{{admin}} 代{{user}}操作', unknownUser: '未知用户', // Record meta footer (audit provenance)。createdBy/updatedBy 后面跟的是 // 操作者("创建人 Dev Admin · 5 分钟前");created/updated 是无操作者时的 diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index cfaf573b843..3e6e28dd4bc 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -87,6 +87,10 @@ export { ACTIVITY_TYPE_TO_FEED_TYPE, UNMAPPED_ACTIVITY_FEED_TYPE, activityRowToFeedItem, + // The impersonation actor line 「X 代 Y 操作」, shared with the console's + // audit-log field history so the two surfaces word it alike. + composeImpersonatedActor, + IMPERSONATED_ACTOR_FALLBACK, // `resetUnknownActivityTypeWarnings` is test-only and stays off the barrel // objectui#11195: the refused-read verdict joins the reading for the same // reason. `RecordDetailView` reads the same two tables, and a refusal it diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.actorId-12067.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.actorId-12067.test.tsx index c3d0cfe64ab..13399971322 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.actorId-12067.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.actorId-12067.test.tsx @@ -87,3 +87,29 @@ describe('record:activity self-fetch expands `actor_id` (objectui#12067)', () => expect(dataSource.find.mock.calls[0][1].$expand).toEqual(['actor_id']); }); }); + +describe('activityRowToFeedItem — a write made from an impersonation session names the administrator', () => { + const impersonated = (meta: Record) => ({ + ...base, + actor_name: '王工', + metadata: JSON.stringify({ old: null, new: {}, ...meta }), + }); + const ZH = '{{admin}} 代{{user}}操作'; + + it('reads 「Dev Admin 代王工操作」 with the translated template', () => { + const row = impersonated({ impersonated_by: 'u-admin', impersonated_by_name: 'Dev Admin' }); + expect(activityRowToFeedItem(row, '系统', ZH)?.actor).toBe('Dev Admin 代王工操作'); + }); + + it('falls back to the English template, and to the unknown-user text for an administrator the writer could not name', () => { + expect(activityRowToFeedItem(impersonated({ impersonated_by: 'u-admin', impersonated_by_name: 'Dev Admin' }), 'System')?.actor) + .toBe('Dev Admin on behalf of 王工'); + expect(activityRowToFeedItem(impersonated({ impersonated_by: 'u-admin' }), 'System', ZH, '未知')?.actor) + .toBe('未知 代王工操作'); + }); + + it("CONTROL: the user's own write reads as the user alone", () => { + expect(activityRowToFeedItem(impersonated({}), 'System', ZH)?.actor).toBe('王工'); + expect(activityRowToFeedItem({ ...base, actor_name: '王工' }, 'System', ZH)?.actor).toBe('王工'); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index 7b5fe9176ec..7d1938e5a7a 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -75,6 +75,7 @@ import { normalizeLimit, describeRefusedFeedLimit, isRefusedFeedRead, + IMPERSONATED_ACTOR_FALLBACK, type SysActivityRow, } from './recordActivityFeed'; import { useRecordAriaProps } from './recordComponentAria'; @@ -219,6 +220,8 @@ export const RecordActivityRenderer: React.FC = ({ const [selfDenied, setSelfDenied] = React.useState(false); const systemActorLabel = tt('detail.systemActor', 'System'); + const impersonatedActorTemplate = tt('detail.impersonatedActor', IMPERSONATED_ACTOR_FALLBACK); + const unknownUserLabel = tt('detail.unknownUser', 'Unknown'); React.useEffect(() => { if (!canSelfFetch) return; @@ -247,7 +250,7 @@ export const RecordActivityRenderer: React.FC = ({ const rows: SysActivityRow[] = Array.isArray(raw) ? (raw as SysActivityRow[]) : []; const mapped: FeedItem[] = []; for (const row of rows) { - const item = activityRowToFeedItem(row, systemActorLabel); + const item = activityRowToFeedItem(row, systemActorLabel, impersonatedActorTemplate, unknownUserLabel); if (item) mapped.push(item); } setFetchedHasMore(rows.length > Math.max(1, pageSize)); @@ -272,7 +275,7 @@ export const RecordActivityRenderer: React.FC = ({ if (!cancelled) setSelfLoading(false); }); return () => { cancelled = true; }; - }, [canSelfFetch, dataSource, objectName, recordId, pageSize, systemActorLabel]); + }, [canSelfFetch, dataSource, objectName, recordId, pageSize, systemActorLabel, impersonatedActorTemplate, unknownUserLabel]); // objectstack#27055, Target 3 — on the synthesized default record page this // block IS the 动态 stream: ONE stream of the discussion, the activity and diff --git a/ui/packages/plugin-detail/src/renderers/record-history.tsx b/ui/packages/plugin-detail/src/renderers/record-history.tsx index 0367661270e..49e62a76143 100644 --- a/ui/packages/plugin-detail/src/renderers/record-history.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-history.tsx @@ -21,7 +21,8 @@ import React from 'react'; import { useRecordContext } from '@object-ui/react'; import { HistoryTimeline, type HistoryActionField, type HistoryEntry } from '../HistoryTimeline'; -import { activityActorName } from './recordActivityFeed'; +import { activityActorName, activityImpersonatorName, composeImpersonatedActor, IMPERSONATED_ACTOR_FALLBACK } from './recordActivityFeed'; +import { useSafeTranslate } from '@object-ui/i18n'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -185,6 +186,10 @@ export const RecordHistoryRenderer: React.FC = ({ const dataSource = ctx?.dataSource; const canSelfFetch = hostEntries === undefined && !!dataSource?.find && !!objectName && recordId != null; + const tt = useSafeTranslate(); + const impersonatedActorTemplate = tt('detail.impersonatedActor', IMPERSONATED_ACTOR_FALLBACK); + const unknownUserLabel = tt('detail.unknownUser', 'Unknown'); + const [fetched, setFetched] = React.useState(null); const [selfLoading, setSelfLoading] = React.useState(false); @@ -225,7 +230,15 @@ export const RecordHistoryRenderer: React.FC = ({ // (no actor at all) from a user it could not name // (objectui#12527). Expanded, `actor_id` is the user's record. user_id: activityActorId(r), - user_name: activityActorName(r), + // A write made from an impersonation session names the + // administrator beside the user: 「Dev Admin 代王工操作」. + user_name: (() => { + const name = activityActorName(r); + const admin = activityImpersonatorName(r, unknownUserLabel); + return admin + ? composeImpersonatedActor(impersonatedActorTemplate, admin, name ?? unknownUserLabel) + : name; + })(), user_avatar: r.actor_avatar_url ?? null, summary: r.summary ?? null, } as HistoryEntry; @@ -235,7 +248,7 @@ export const RecordHistoryRenderer: React.FC = ({ .catch(() => { if (!cancelled) setFetched([]); }) .finally(() => { if (!cancelled) setSelfLoading(false); }); return () => { cancelled = true; }; - }, [canSelfFetch, dataSource, objectName, recordId, limit]); + }, [canSelfFetch, dataSource, objectName, recordId, limit, impersonatedActorTemplate, unknownUserLabel]); const entries: HistoryEntry[] = hostEntries ?? fetched ?? []; const loading = hostLoading ?? (canSelfFetch && fetched === null ? selfLoading || true : false); diff --git a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts index 793ca4b893d..c8060402261 100644 --- a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts +++ b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts @@ -957,6 +957,68 @@ export function activityActorName(row: SysActivityRow): string | null { return null; } +/** + * Who a `sys_activity` row says acted, as one line: {@link activityActorName} + * (else `systemActorLabel`), and, when an administrator made the write from an + * impersonation session, that administrator beside them. + */ +export function activityActorLine( + row: SysActivityRow, + systemActorLabel: string, + impersonatedActorTemplate?: string, + unknownUserLabel = 'Unknown', +): string { + const actor = activityActorName(row) ?? systemActorLabel; + // An administrator the writer could not name is still an administrator: the + // unknown-user text, never "System" and never the raw id. + const admin = activityImpersonatorName(row, unknownUserLabel); + return admin ? composeImpersonatedActor(impersonatedActorTemplate, admin, actor) : actor; +} + +/** + * The English template of the actor line a write made from an impersonation + * session reads as. `{{admin}}` is the administrator at the keyboard and + * `{{user}}` the impersonated user the write is recorded as; a caller passes + * the translated `detail.impersonatedActor` in its place. + */ +export const IMPERSONATED_ACTOR_FALLBACK = '{{admin}} on behalf of {{user}}'; + +/** + * The administrator who made this row's write from an impersonation session, + * or `null` for every write the user made themselves. The writer + * (`@objectstack/plugin-audit`) stamps `metadata.impersonated_by` with the + * administrator's id and `metadata.impersonated_by_name` with their name as it + * read it; the name is what can be shown, so a row that carries only the id + * still names an administrator, by the localized unknown-user text the caller + * passes as `unknownLabel`. + */ +export function activityImpersonatorName(row: SysActivityRow, unknownLabel?: string): string | null { + const raw = row?.metadata; + if (typeof raw !== 'string') return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const meta = parsed as { impersonated_by?: unknown; impersonated_by_name?: unknown }; + if (typeof meta.impersonated_by !== 'string' || !meta.impersonated_by.trim()) return null; + const name = meta.impersonated_by_name; + if (typeof name === 'string' && name.trim()) return name.trim(); + return unknownLabel ?? null; +} + +/** + * The actor line 「Dev Admin 代王工操作」: `template` (the translated + * `detail.impersonatedActor`, or {@link IMPERSONATED_ACTOR_FALLBACK}) with + * `{{admin}}` and `{{user}}` filled in. + */ +export function composeImpersonatedActor(template: string | undefined, admin: string, user: string): string { + const pattern = template && template.includes('{{admin}}') ? template : IMPERSONATED_ACTOR_FALLBACK; + return pattern.split('{{admin}}').join(admin).split('{{user}}').join(user); +} + /** * One `sys_activity` row → one {@link FeedItem}, or `null` when the row is not * record activity (see {@link ACTIVITY_TYPE_TO_FEED_TYPE}). @@ -983,6 +1045,8 @@ export function activityActorName(row: SysActivityRow): string | null { export function activityRowToFeedItem( row: SysActivityRow, systemActorLabel: string, + impersonatedActorTemplate?: string, + unknownUserLabel?: string, ): FeedItem | null { const rawType = String(row?.type); const known = Object.prototype.hasOwnProperty.call(ACTIVITY_TYPE_TO_FEED_TYPE, rawType); @@ -1004,7 +1068,9 @@ export function activityRowToFeedItem( type: feedType, // `systemActorLabel` only when no person can be named (objectui#12067): // a row a user made used to read "System" whenever `actor_name` was empty. - actor: activityActorName(row) ?? systemActorLabel, + // A write made from an impersonation session names the administrator + // beside the user it is recorded as: 「Dev Admin 代王工操作」. + actor: activityActorLine(row, systemActorLabel, impersonatedActorTemplate, unknownUserLabel), actorAvatarUrl: row.actor_avatar_url ?? undefined, body: row.summary ?? '', createdAt: activityTimestamp(row),